diff --git a/.cargo/config.toml b/.cargo/config.toml index 7eeb0b9dd..9d7f4221c 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -11,3 +11,6 @@ linker = "x86_64-w64-mingw32-gcc" [build] target-dir = "target" + +[target.wasm32-wasip1] +rustflags = ["-C", "link-arg=-zstack-size=262144"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 537060dc3..af19f2628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) * fix: resize state and correctness (https://github.com/zellij-org/zellij/pull/5656) * fix: compress kitty images if the host terminal supports it (https://github.com/zellij-org/zellij/pull/5608) * fix: add websocket heartbeat to webserver to prevernt idle disconnect through proxies (https://github.com/zellij-org/zellij/pull/4633) +* fix: memory-usage optimizations and multiple-pane-single-instance-plugins (https://github.com/zellij-org/zellij/pull/5662) ## [0.45.1] - 2026-08-28 * fix: nested-session detection over SSH (https://github.com/zellij-org/zellij/pull/5522) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5e49565a..ba78fff18 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ unaffected, since they embed the plugins from `zellij-utils/assets/plugins`. ## Packaging Zellij for a distribution -Builtin plugins (`status-bar`, `tab-bar`, ...) are `.wasm` files that are embedded into +Builtin plugins (`bars`, `strider`, ...) are `.wasm` files that are embedded into the Zellij binary at compile time. Pre-built copies live in `zellij-utils/assets/plugins` so that `cargo install zellij` works without further tooling. Distributions that forbid pre-built binaries in source packages can strip that folder and build the plugins from @@ -80,9 +80,9 @@ source instead: ```sh # 1. build the plugins from source (repeat for every plugin, or use `cargo x build --release`) cargo build --release --target wasm32-wasip1 \ - -p status-bar -p tab-bar -p compact-bar -p strider -p session-manager \ + -p bars -p strider -p session-manager \ -p configuration -p plugin-manager -p about -p share -p multiple-select \ - -p layout-manager -p link + -p layout-manager # 2. install the resulting artifacts from /wasm32-wasip1/release/*.wasm # into $PREFIX/share/zellij/plugins/ diff --git a/Cargo.lock b/Cargo.lock index ed32c5d26..8b4ab0657 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,22 @@ dependencies = [ "backtrace", ] +[[package]] +name = "bars" +version = "0.1.0" +dependencies = [ + "ansi_term", + "lazy_static", + "rand 0.10.2", + "regex", + "serde", + "serde_json", + "thiserror 2.0.19", + "unicode-width 0.2.2", + "zellij-tile", + "zellij-tile-utils", +] + [[package]] name = "base64" version = "0.22.1" @@ -656,16 +672,6 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dfdf9179d546b55ff3f88c9d93ecfaa3e9760163da5a1080af5243230dbbb70" -[[package]] -name = "compact-bar" -version = "0.1.0" -dependencies = [ - "ansi_term", - "unicode-width 0.2.2", - "zellij-tile", - "zellij-tile-utils", -] - [[package]] name = "concurrent-queue" version = "2.2.0" @@ -2121,13 +2127,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "link" -version = "0.1.0" -dependencies = [ - "zellij-tile", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2938,9 +2937,21 @@ dependencies = [ [[package]] name = "regex" -version = "1.8.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af83e617f331cc6ae2da5443c602dfa5af81e517212d9d611a5b3ba1777b5370" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2949,9 +2960,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.7.1" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "ring" @@ -3460,22 +3471,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "status-bar" -version = "0.1.0" -dependencies = [ - "ansi_term", - "lazy_static", - "rand 0.10.2", - "regex", - "serde", - "serde_json", - "thiserror 2.0.19", - "unicode-width 0.2.2", - "zellij-tile", - "zellij-tile-utils", -] - [[package]] name = "strider" version = "0.2.0" @@ -3645,16 +3640,6 @@ dependencies = [ "winx", ] -[[package]] -name = "tab-bar" -version = "0.1.0" -dependencies = [ - "ansi_term", - "unicode-width 0.2.2", - "zellij-tile", - "zellij-tile-utils", -] - [[package]] name = "tempfile" version = "3.10.1" diff --git a/Cargo.toml b/Cargo.toml index 7c3b99436..d3152bdb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,10 +42,8 @@ vte = { workspace = true } [workspace] members = [ - "default-plugins/compact-bar", - "default-plugins/status-bar", + "default-plugins/bars", "default-plugins/strider", - "default-plugins/tab-bar", "default-plugins/fixture-plugin-for-tests", "default-plugins/session-manager", "default-plugins/configuration", @@ -54,7 +52,6 @@ members = [ "default-plugins/share", "default-plugins/multiple-select", "default-plugins/layout-manager", - "default-plugins/link", "zellij-client", "zellij-integration-tests", "zellij-server", diff --git a/default-plugins/compact-bar/.cargo/config.toml b/default-plugins/bars/.cargo/config.toml similarity index 100% rename from default-plugins/compact-bar/.cargo/config.toml rename to default-plugins/bars/.cargo/config.toml diff --git a/default-plugins/status-bar/Cargo.toml b/default-plugins/bars/Cargo.toml similarity index 87% rename from default-plugins/status-bar/Cargo.toml rename to default-plugins/bars/Cargo.toml index 6a95b1005..4831a7a04 100644 --- a/default-plugins/status-bar/Cargo.toml +++ b/default-plugins/bars/Cargo.toml @@ -1,7 +1,6 @@ [package] -name = "status-bar" +name = "bars" version = "0.1.0" -authors = ["Brooks J Rady "] edition.workspace = true license.workspace = true diff --git a/default-plugins/compact-bar/LICENSE.md b/default-plugins/bars/LICENSE.md similarity index 100% rename from default-plugins/compact-bar/LICENSE.md rename to default-plugins/bars/LICENSE.md diff --git a/default-plugins/compact-bar/src/action_types.rs b/default-plugins/bars/src/compact_bar/action_types.rs similarity index 99% rename from default-plugins/compact-bar/src/action_types.rs rename to default-plugins/bars/src/compact_bar/action_types.rs index 626a31075..de63101d7 100644 --- a/default-plugins/compact-bar/src/action_types.rs +++ b/default-plugins/bars/src/compact_bar/action_types.rs @@ -35,7 +35,7 @@ pub enum ActionType { Detach, Quit, NewStackedPane, - Other(String), // Fallback for unhandled actions + Other(String), } impl ActionType { diff --git a/default-plugins/compact-bar/src/clipboard_utils.rs b/default-plugins/bars/src/compact_bar/clipboard_utils.rs similarity index 89% rename from default-plugins/compact-bar/src/clipboard_utils.rs rename to default-plugins/bars/src/compact_bar/clipboard_utils.rs index 0ce07936f..798574a9b 100644 --- a/default-plugins/compact-bar/src/clipboard_utils.rs +++ b/default-plugins/bars/src/compact_bar/clipboard_utils.rs @@ -1,4 +1,4 @@ -use crate::LinePart; +use crate::compact_bar::LinePart; use zellij_tile::prelude::*; pub fn text_copied_hint(copy_destination: CopyDestination) -> LinePart { @@ -6,7 +6,7 @@ pub fn text_copied_hint(copy_destination: CopyDestination) -> LinePart { CopyDestination::Command => "Text piped to external command", #[cfg(not(target_os = "macos"))] CopyDestination::Primary => "Text copied to system primary selection", - #[cfg(target_os = "macos")] // primary selection does not exist on macos + #[cfg(target_os = "macos")] CopyDestination::Primary => "Text copied to system clipboard", CopyDestination::System => "Text copied to system clipboard", }; diff --git a/default-plugins/compact-bar/src/keybind_utils.rs b/default-plugins/bars/src/compact_bar/keybind_utils.rs similarity index 95% rename from default-plugins/compact-bar/src/keybind_utils.rs rename to default-plugins/bars/src/compact_bar/keybind_utils.rs index 692dde0fd..e42d9fd22 100644 --- a/default-plugins/compact-bar/src/keybind_utils.rs +++ b/default-plugins/bars/src/compact_bar/keybind_utils.rs @@ -1,4 +1,4 @@ -use crate::action_types::ActionType; +use crate::compact_bar::action_types::ActionType; use std::collections::HashSet; use zellij_tile::prelude::actions::Action; use zellij_tile::prelude::*; @@ -6,7 +6,6 @@ use zellij_tile::prelude::*; pub struct KeybindProcessor; impl KeybindProcessor { - /// Find predetermined actions based on predicates while maintaining order pub fn find_predetermined_actions( mode_info: &ModeInfo, mode: InputMode, @@ -19,16 +18,13 @@ impl KeybindProcessor { let keybinds = mode_info.get_keybinds_for_mode(mode); let mut processed_action_types = HashSet::new(); - // Iterate through predicates in order to maintain the desired sequence for predicate in predicates { - // Find the first matching action for this predicate let mut found_match = false; for (_key, actions) in &keybinds { if let Some(first_action) = actions.first() { if predicate(first_action) { let action_type = ActionType::from_action(first_action); - // Skip if we've already processed this action type if processed_action_types.contains(&action_type) { found_match = true; break; @@ -36,7 +32,6 @@ impl KeybindProcessor { let mut matching_keys = Vec::new(); - // Find all keys that match this action type (including different directions) for (inner_key, inner_actions) in &keybinds { if let Some(inner_first_action) = inner_actions.first() { if ActionType::from_action(inner_first_action) == action_type { @@ -49,7 +44,6 @@ impl KeybindProcessor { let description = action_type.description(); let should_add_brackets_to_keys = mode != InputMode::Normal; - // Check if this is switching to normal mode let is_switching_to_locked = matches!( first_action, Action::SwitchToMode { @@ -72,7 +66,6 @@ impl KeybindProcessor { } } - // If we found a match for this predicate, we've processed it if found_match { continue; } @@ -81,7 +74,6 @@ impl KeybindProcessor { result } - /// Group keys into sets and separate different key types with '|' fn group_key_sets( keys: &[String], should_add_brackets_to_keys: bool, @@ -91,7 +83,6 @@ impl KeybindProcessor { return String::new(); } - // Filter out Esc and Enter keys when switching to normal mode, but only if other keys exist let filtered_keys: Vec = if is_switching_to_locked { let non_esc_enter_keys: Vec = keys .iter() @@ -100,10 +91,8 @@ impl KeybindProcessor { .collect(); if non_esc_enter_keys.is_empty() { - // If no other keys exist, keep the original keys keys.to_vec() } else { - // Use filtered keys (without Esc/Enter) non_esc_enter_keys } } else { @@ -118,7 +107,6 @@ impl KeybindProcessor { }; } - // Group keys by type let mut arrow_keys = Vec::new(); let mut hjkl_lower = Vec::new(); let mut hjkl_upper = Vec::new(); @@ -160,7 +148,6 @@ impl KeybindProcessor { let mut groups = Vec::new(); - // Add hjkl group if present (prioritize hjkl over arrows) if !hjkl_lower.is_empty() { Self::sort_hjkl(&mut hjkl_lower); groups.push(Self::format_key_group( @@ -170,7 +157,6 @@ impl KeybindProcessor { )); } - // Add HJKL group if present if !hjkl_upper.is_empty() { Self::sort_hjkl_upper(&mut hjkl_upper); groups.push(Self::format_key_group( @@ -180,7 +166,6 @@ impl KeybindProcessor { )); } - // Add arrow keys group if present if !arrow_keys.is_empty() { Self::sort_arrows(&mut arrow_keys); groups.push(Self::format_key_group( @@ -217,7 +202,6 @@ impl KeybindProcessor { )); } - // Add other keys with / separator if !other_keys.is_empty() { groups.push(other_keys.join("/")); } @@ -270,7 +254,6 @@ impl KeybindProcessor { let pos_b = order.iter().position(|&x| &x == b).unwrap_or(usize::MAX); pos_a.cmp(&pos_b) }); - // Remove "=" if both "+" and "=" are present if keys.contains(&"+") && keys.contains(&"=") { keys.retain(|k| k != &"="); } @@ -300,7 +283,6 @@ impl KeybindProcessor { } } - /// Get predetermined actions for a specific mode pub fn get_predetermined_actions( mode_info: &ModeInfo, mode: InputMode, diff --git a/default-plugins/compact-bar/src/line.rs b/default-plugins/bars/src/compact_bar/line.rs similarity index 99% rename from default-plugins/compact-bar/src/line.rs rename to default-plugins/bars/src/compact_bar/line.rs index aae8aec05..64936910e 100644 --- a/default-plugins/compact-bar/src/line.rs +++ b/default-plugins/bars/src/compact_bar/line.rs @@ -1,7 +1,7 @@ use ansi_term::ANSIStrings; use unicode_width::UnicodeWidthStr; -use crate::{LinePart, TabRenderData, ARROW_SEPARATOR}; +use crate::compact_bar::{LinePart, TabRenderData, ARROW_SEPARATOR}; use zellij_tile::prelude::*; use zellij_tile_utils::style; diff --git a/default-plugins/bars/src/compact_bar/mod.rs b/default-plugins/bars/src/compact_bar/mod.rs new file mode 100644 index 000000000..754bdec15 --- /dev/null +++ b/default-plugins/bars/src/compact_bar/mod.rs @@ -0,0 +1,857 @@ +mod action_types; +mod clipboard_utils; +mod keybind_utils; +mod line; +mod tab; +mod tooltip; + +use std::cmp::{max, min}; +use std::collections::{BTreeMap, BTreeSet}; +use std::convert::TryInto; + +use tab::get_tab_to_focus; +use zellij_tile::prelude::*; + +use crate::keybinds::KeybindStore; +use crate::ClientSeed; +use clipboard_utils::{system_clipboard_error, text_copied_hint}; +use line::tab_line; +use tab::tab_style; +use tooltip::TooltipRenderer; + +static ARROW_SEPARATOR: &str = ""; + +const CONFIG_IS_TOOLTIP: &str = "is_tooltip"; +const CONFIG_TOGGLE_TOOLTIP_KEY: &str = "tooltip"; +const MSG_TOGGLE_TOOLTIP: &str = "toggle_tooltip"; +const MSG_TOGGLE_PERSISTED_TOOLTIP: &str = "toggle_persisted_tooltip"; +const MSG_LAUNCH_TOOLTIP: &str = "launch_tooltip_if_not_launched"; + +#[derive(Debug, Default)] +pub struct LinePart { + part: String, + len: usize, + tab_index: Option, +} + +struct TabRenderData { + tabs: Vec, + active_tab_index: usize, + active_swap_layout_name: Option, + is_swap_layout_dirty: bool, +} + +#[derive(Default)] +struct SlotConfig { + config: BTreeMap, + is_tooltip: bool, + toggle_tooltip_key: Option, + persist: bool, + is_first_run: bool, +} + +#[derive(Default)] +struct ClientState { + tabs: Vec, + active_tab_idx: usize, + mode_info: ModeInfo, + display_area_rows: usize, + display_area_cols: usize, + text_copy_destination: Option, + display_system_clipboard_failure: bool, +} + +#[derive(Default)] +struct SlotClientState { + tab_line: Vec, + breadcrumb_range: Option<(usize, usize)>, +} + +#[derive(Default)] +pub struct CompactBar { + slots: BTreeMap, + slot_tabs: BTreeMap, + clients: BTreeMap, + slot_clients: BTreeMap<(SlotId, ClientId), SlotClientState>, + tooltip_is_active: bool, + configured_toggle_keys: BTreeSet<(String, ClientId)>, +} + +impl CompactBar { + pub fn has_slots(&self) -> bool { + !self.slots.is_empty() + } + + pub fn slot_added(&mut self, slot: &Slot) { + let config = slot.configuration.clone(); + let is_tooltip = config + .get(CONFIG_IS_TOOLTIP) + .and_then(|v| v.parse().ok()) + .unwrap_or(false); + let toggle_tooltip_key = if is_tooltip { + None + } else { + config.get(CONFIG_TOGGLE_TOOLTIP_KEY).cloned() + }; + let events = if is_tooltip { + vec![ + EventType::ModeUpdate, + EventType::TabUpdate, + EventType::InitialKeybinds, + ] + } else { + vec![ + EventType::TabUpdate, + EventType::PaneUpdate, + EventType::ModeUpdate, + EventType::Mouse, + EventType::CopyToClipboard, + EventType::InputReceived, + EventType::SystemClipboardFailure, + EventType::InitialKeybinds, + ] + }; + subscribe(&events); + self.slots.insert( + slot.id, + SlotConfig { + config, + is_tooltip, + toggle_tooltip_key, + persist: false, + is_first_run: is_tooltip, + }, + ); + } + + pub fn slot_removed(&mut self, slot_id: SlotId) { + self.slots.remove(&slot_id); + self.slot_tabs.remove(&slot_id); + self.slot_clients.retain(|(s, _), _| *s != slot_id); + } + + pub fn client_connected(&mut self, client_id: ClientId) { + self.clients.entry(client_id).or_default(); + } + + pub fn client_disconnected(&mut self, client_id: ClientId) { + self.clients.remove(&client_id); + self.slot_clients.retain(|(_, c), _| *c != client_id); + self.configured_toggle_keys.retain(|(_, c)| *c != client_id); + } + + pub fn client_ids(&self) -> impl Iterator + '_ { + self.clients.keys().copied() + } + + fn swap_keybinds(&mut self, client_id: ClientId, keybinds: &mut KeybindStore) -> bool { + match self.clients.get_mut(&client_id) { + Some(client) => keybinds.swap(client_id, &mut client.mode_info.keybinds), + None => false, + } + } + + pub fn reset(&mut self) { + self.clients.clear(); + self.slot_clients.clear(); + self.slot_tabs.clear(); + self.tooltip_is_active = false; + } + + pub fn snapshot(&self) -> BTreeMap { + self.clients + .iter() + .map(|(client_id, client)| { + ( + *client_id, + ClientSeed { + mode_info: client.mode_info.clone(), + tabs: client.tabs.clone(), + }, + ) + }) + .collect() + } + + pub fn seed(&mut self, seeds: &BTreeMap) { + for (client_id, seed) in seeds { + let client = self.clients.entry(*client_id).or_default(); + client.mode_info = seed.mode_info.clone(); + update_display_area(client, &seed.tabs); + if let Some(active_tab_index) = seed.tabs.iter().position(|t| t.active) { + client.active_tab_idx = active_tab_index + 1; + client.tabs = seed.tabs.clone(); + } + } + } + + pub fn ensure_toggle_keybinds(&mut self, client_id: Option) { + let Some(client_id) = client_id else { + return; + }; + for slot in self.slots.values() { + if slot.is_tooltip { + continue; + } + if let Some(toggle_key) = &slot.toggle_tooltip_key { + let entry = (toggle_key.clone(), client_id); + if !self.configured_toggle_keys.contains(&entry) { + reconfigure(bind_toggle_key_config(toggle_key, client_id), false); + self.configured_toggle_keys.insert(entry); + } + } + } + } + + fn has_toggle_key(&self) -> bool { + self.slots + .values() + .any(|s| !s.is_tooltip && s.toggle_tooltip_key.is_some()) + } + + fn main_slot_ids(&self) -> Vec { + self.slots + .iter() + .filter(|(_, s)| !s.is_tooltip) + .map(|(id, _)| *id) + .collect() + } + + fn tooltip_slot_ids(&self) -> Vec { + self.slots + .iter() + .filter(|(_, s)| s.is_tooltip) + .map(|(id, _)| *id) + .collect() + } + + fn active_tab_index_of(&self, client_id: ClientId) -> Option { + self.clients + .get(&client_id) + .filter(|c| c.active_tab_idx > 0) + .map(|c| c.active_tab_idx - 1) + } + + fn tooltip_slots_in_tab(&self, tab_index: Option) -> Vec { + self.tooltip_slot_ids() + .into_iter() + .filter(|id| match (tab_index, self.slot_tabs.get(id)) { + (Some(tab_index), Some(slot_tab)) => *slot_tab == tab_index, + _ => true, + }) + .collect() + } + + fn tooltip_slots_for_client(&self, client_id: ClientId) -> Vec { + self.tooltip_slots_in_tab(self.active_tab_index_of(client_id)) + } + + fn main_slot_for_client(&self, client_id: ClientId) -> Option { + let active_tab_index = self.active_tab_index_of(client_id); + let candidates: Vec = self + .slots + .iter() + .filter(|(_, s)| !s.is_tooltip && s.toggle_tooltip_key.is_some()) + .map(|(id, _)| *id) + .collect(); + candidates + .iter() + .find(|id| { + active_tab_index.is_some() && self.slot_tabs.get(*id) == active_tab_index.as_ref() + }) + .or_else(|| candidates.first()) + .copied() + } + + fn main_slot_in_active_tab(&self, client_id: ClientId) -> Option { + let active_tab_index = self + .clients + .get(&client_id) + .map(|c| c.active_tab_idx.saturating_sub(1))?; + self.slots + .iter() + .filter(|(_, s)| !s.is_tooltip && s.toggle_tooltip_key.is_some()) + .map(|(id, _)| *id) + .find(|id| self.slot_tabs.get(id) == Some(&active_tab_index)) + } + + pub fn update( + &mut self, + event: &Event, + context: EventContext, + keybinds: &mut KeybindStore, + ) -> RenderResponse { + match event { + Event::ModeUpdate(_) | Event::TabUpdate(_) | Event::InitialKeybinds(_) => { + for slot in self.slots.values_mut() { + slot.is_first_run = false; + } + }, + _ => {}, + } + match event { + Event::PaneUpdate(pane_manifest) => { + if self.handle_pane_update(pane_manifest) { + RenderResponse::Slots(self.main_slot_ids()) + } else { + RenderResponse::Nothing + } + }, + Event::Mouse(mouse_event) => { + if let (Some(slot_id), Some(client_id)) = (context.slot_id, context.client_id) { + self.handle_mouse_event(slot_id, client_id, mouse_event); + } + RenderResponse::Nothing + }, + _ => { + let target_clients: Vec = match context.client_id { + Some(client_id) => vec![client_id], + None => self.clients.keys().copied().collect(), + }; + let mut should_render = false; + for client_id in target_clients { + if self.update_client(event, client_id, keybinds) { + should_render = true; + } + } + if !should_render { + RenderResponse::Nothing + } else if let Some(client_id) = context.client_id { + RenderResponse::Client(client_id) + } else { + RenderResponse::Slots(self.slots.keys().copied().collect()) + } + }, + } + } + + fn update_client( + &mut self, + event: &Event, + client_id: ClientId, + keybinds: &mut KeybindStore, + ) -> bool { + match event { + Event::InitialKeybinds(_) => { + self.clients.entry(client_id).or_default(); + true + }, + Event::ModeUpdate(mode_info) => { + self.handle_mode_update(client_id, mode_info.clone(), keybinds) + }, + Event::TabUpdate(tabs) => self.handle_tab_update(client_id, tabs), + Event::CopyToClipboard(copy_destination) => { + self.handle_clipboard_copy(client_id, *copy_destination) + }, + Event::SystemClipboardFailure => self.handle_clipboard_failure(client_id), + Event::InputReceived => self.handle_input_received(client_id), + _ => false, + } + } + + fn handle_mode_update( + &mut self, + client_id: ClientId, + mode_info: ModeInfo, + keybinds: &mut KeybindStore, + ) -> bool { + let client = self.clients.entry(client_id).or_default(); + let should_render = client.mode_info != mode_info; + let old_mode = client.mode_info.mode; + let new_mode = mode_info.mode; + let base_mode = mode_info.base_mode.unwrap_or(InputMode::Normal); + + client.mode_info = mode_info; + + let lent = self.swap_keybinds(client_id, keybinds); + for slot_id in self.tooltip_slots_for_client(client_id) { + self.handle_tooltip_mode_update(slot_id, client_id, old_mode, new_mode, base_mode); + } + self.handle_main_mode_update(client_id, new_mode, base_mode); + if lent { + self.swap_keybinds(client_id, keybinds); + } + + should_render + } + + fn handle_main_mode_update( + &self, + client_id: ClientId, + new_mode: InputMode, + base_mode: InputMode, + ) { + if self.has_toggle_key() && new_mode != base_mode && !is_restricted_mode(new_mode) { + if let Some(slot_id) = self.main_slot_for_client(client_id) { + self.launch_tooltip_if_not_launched(slot_id, client_id, new_mode); + } + } + } + + fn handle_tooltip_mode_update( + &mut self, + slot_id: SlotId, + client_id: ClientId, + old_mode: InputMode, + new_mode: InputMode, + base_mode: InputMode, + ) { + let persist = self.slots.get(&slot_id).map(|s| s.persist).unwrap_or(false); + if !persist && (new_mode == base_mode || is_restricted_mode(new_mode)) { + let _ = close_slot(slot_id); + } else if new_mode != old_mode || persist { + self.update_tooltip_for_mode_change(slot_id, client_id, new_mode); + } + } + + fn handle_tab_update(&mut self, client_id: ClientId, tabs: &[TabInfo]) -> bool { + let client = self.clients.entry(client_id).or_default(); + update_display_area(client, tabs); + + if let Some(active_tab_index) = tabs.iter().position(|t| t.active) { + let active_tab_idx = active_tab_index + 1; + let previous_active_tab_idx = client.active_tab_idx; + let should_render = + client.active_tab_idx != active_tab_idx || client.tabs.as_slice() != tabs; + + client.active_tab_idx = active_tab_idx; + client.tabs = tabs.to_vec(); + + if previous_active_tab_idx != 0 && previous_active_tab_idx != active_tab_idx { + let tooltips = self.tooltip_slots_in_tab(Some(previous_active_tab_idx - 1)); + for slot_id in tooltips { + self.move_tooltip_to_new_tab(slot_id, active_tab_idx); + } + } + + should_render + } else { + false + } + } + + fn handle_pane_update(&mut self, pane_manifest: &PaneManifest) -> bool { + for (tab_index, panes) in &pane_manifest.panes { + for pane in panes { + if pane.is_plugin && self.slots.contains_key(&pane.id) { + self.slot_tabs.insert(pane.id, *tab_index); + } + } + } + if self.has_toggle_key() { + let previous_tooltip_state = self.tooltip_is_active; + self.tooltip_is_active = self.detect_tooltip_presence(pane_manifest); + previous_tooltip_state != self.tooltip_is_active + } else { + false + } + } + + fn handle_mouse_event(&mut self, slot_id: SlotId, client_id: ClientId, mouse_event: &Mouse) { + if self + .slots + .get(&slot_id) + .map(|s| s.is_tooltip) + .unwrap_or(true) + { + return; + } + let Some(client) = self.clients.get(&client_id) else { + return; + }; + let empty_slot_client = SlotClientState::default(); + let slot_client = self + .slot_clients + .get(&(slot_id, client_id)) + .unwrap_or(&empty_slot_client); + + match mouse_event { + Mouse::LeftClick(_, col) => { + let col = *col; + if matches!(slot_client.breadcrumb_range, Some((start, end)) if col >= start && col < end) + { + focus_host_session(); + } else if let Some(tab_idx) = + get_tab_to_focus(&slot_client.tab_line, client.active_tab_idx, col) + { + switch_tab_to(tab_idx.try_into().unwrap()); + } + }, + Mouse::ScrollUp(_) => { + let next_tab = min(client.active_tab_idx + 1, client.tabs.len()); + switch_tab_to(next_tab as u32); + }, + Mouse::ScrollDown(_) => { + let prev_tab = max(client.active_tab_idx.saturating_sub(1), 1); + switch_tab_to(prev_tab as u32); + }, + _ => {}, + } + } + + fn handle_clipboard_copy( + &mut self, + client_id: ClientId, + copy_destination: CopyDestination, + ) -> bool { + let client = self.clients.entry(client_id).or_default(); + let should_render = match client.text_copy_destination { + Some(current) => current != copy_destination, + None => true, + }; + + client.text_copy_destination = Some(copy_destination); + should_render + } + + fn handle_clipboard_failure(&mut self, client_id: ClientId) -> bool { + let client = self.clients.entry(client_id).or_default(); + client.display_system_clipboard_failure = true; + true + } + + fn handle_input_received(&mut self, client_id: ClientId) -> bool { + let client = self.clients.entry(client_id).or_default(); + let should_render = + client.text_copy_destination.is_some() || client.display_system_clipboard_failure; + client.text_copy_destination = None; + client.display_system_clipboard_failure = false; + should_render + } + + pub fn pipe( + &mut self, + message: PipeMessage, + context: EventContext, + keybinds: &mut KeybindStore, + ) -> RenderResponse { + if let Some(slot_id) = context.slot_id { + if self + .slots + .get(&slot_id) + .map(|s| s.is_tooltip) + .unwrap_or(false) + { + if message.is_private { + self.handle_tooltip_pipe(slot_id, &message); + } + return RenderResponse::Nothing; + } + } + if message.name == MSG_TOGGLE_TOOLTIP && message.is_private { + let client_id = message + .payload + .as_ref() + .and_then(|p| p.trim().parse::().ok()); + if let Some(client_id) = client_id { + if let Some(slot_id) = self.main_slot_in_active_tab(client_id) { + let mode = self + .clients + .get(&client_id) + .map(|c| c.mode_info.mode) + .unwrap_or(InputMode::Normal); + let lent = self.swap_keybinds(client_id, keybinds); + self.toggle_persisted_tooltip(slot_id, client_id, mode); + if lent { + self.swap_keybinds(client_id, keybinds); + } + } + } + } else if message.is_private + && (message.name == MSG_TOGGLE_PERSISTED_TOOLTIP || message.name == MSG_LAUNCH_TOOLTIP) + { + let targets = match context.client_id { + Some(client_id) => self.tooltip_slots_for_client(client_id), + None => self.tooltip_slot_ids(), + }; + for slot_id in targets { + self.handle_tooltip_pipe(slot_id, &message); + } + } + RenderResponse::Nothing + } + + fn handle_tooltip_pipe(&mut self, slot_id: SlotId, message: &PipeMessage) { + if message.name == MSG_TOGGLE_PERSISTED_TOOLTIP { + let Some(slot) = self.slots.get_mut(&slot_id) else { + return; + }; + if slot.is_first_run { + slot.persist = true; + } else { + #[cfg(target_family = "wasm")] + let _ = close_slot(slot_id); + } + } + } + + fn toggle_persisted_tooltip(&self, slot_id: SlotId, client_id: ClientId, new_mode: InputMode) { + #[allow(unused_variables)] + let message = self + .create_tooltip_message(slot_id, client_id, MSG_TOGGLE_PERSISTED_TOOLTIP, new_mode) + .with_args(create_persist_args()); + + #[cfg(target_family = "wasm")] + pipe_message_to_plugin(message); + } + + fn launch_tooltip_if_not_launched( + &self, + slot_id: SlotId, + client_id: ClientId, + new_mode: InputMode, + ) { + let message = self.create_tooltip_message(slot_id, client_id, MSG_LAUNCH_TOOLTIP, new_mode); + pipe_message_to_plugin(message); + } + + fn create_tooltip_message( + &self, + slot_id: SlotId, + client_id: ClientId, + name: &str, + mode: InputMode, + ) -> MessageToPlugin { + let mut tooltip_config = self + .slots + .get(&slot_id) + .map(|s| s.config.clone()) + .unwrap_or_default(); + tooltip_config.insert(CONFIG_IS_TOOLTIP.to_string(), "true".to_string()); + + MessageToPlugin::new(name) + .with_plugin_url("zellij:OWN_URL") + .with_plugin_config(tooltip_config) + .with_floating_pane_coordinates(self.calculate_tooltip_coordinates(client_id)) + .new_plugin_instance_should_have_pane_title(format!("{:?}", mode)) + } + + fn update_tooltip_for_mode_change( + &self, + slot_id: SlotId, + client_id: ClientId, + new_mode: InputMode, + ) { + let coordinates = self.calculate_tooltip_coordinates(client_id); + change_floating_panes_coordinates(vec![(PaneId::Plugin(slot_id), coordinates)]); + rename_plugin_pane(slot_id, format!("{:?}", new_mode)); + } + + fn move_tooltip_to_new_tab(&mut self, slot_id: SlotId, new_tab_index: usize) { + break_panes_to_tab_with_index( + &[PaneId::Plugin(slot_id)], + new_tab_index.saturating_sub(1), + false, + ); + self.slot_tabs + .insert(slot_id, new_tab_index.saturating_sub(1)); + } + + fn calculate_tooltip_coordinates(&self, client_id: ClientId) -> FloatingPaneCoordinates { + let default_client = ClientState::default(); + let client = self.clients.get(&client_id).unwrap_or(&default_client); + let tooltip_renderer = TooltipRenderer::new(&client.mode_info); + let (tooltip_rows, tooltip_cols) = + tooltip_renderer.calculate_dimensions(client.mode_info.mode); + + let width = tooltip_cols + 4; + let height = tooltip_rows + 2; + let x_position = 2; + let y_position = client.display_area_rows.saturating_sub(height + 2); + + FloatingPaneCoordinates::new( + Some(x_position.to_string()), + Some(y_position.to_string()), + Some(width.to_string()), + Some(height.to_string()), + Some(true), + Some(false), + ) + .unwrap_or_default() + } + + fn detect_tooltip_presence(&self, pane_manifest: &PaneManifest) -> bool { + for (_tab_index, panes) in &pane_manifest.panes { + for pane in panes { + if pane.is_plugin + && self + .slots + .get(&pane.id) + .map(|s| s.is_tooltip) + .unwrap_or(false) + && pane.pane_x != pane.pane_content_x + { + return true; + } + } + } + false + } + + pub fn render( + &mut self, + rows: usize, + cols: usize, + slot_id: SlotId, + client_id: ClientId, + keybinds: &mut KeybindStore, + ) { + let lent = self.swap_keybinds(client_id, keybinds); + self.render_client(rows, cols, slot_id, client_id); + if lent { + self.swap_keybinds(client_id, keybinds); + } + } + + fn render_client(&mut self, rows: usize, cols: usize, slot_id: SlotId, client_id: ClientId) { + let Some(slot) = self.slots.get(&slot_id) else { + return; + }; + let Some(client) = self.clients.get(&client_id) else { + return; + }; + if slot.is_tooltip { + let tooltip_renderer = TooltipRenderer::new(&client.mode_info); + tooltip_renderer.render(rows, cols); + return; + } + if let Some(copy_destination) = client.text_copy_destination { + let hint = text_copied_hint(copy_destination).part; + render_background_with_text(&client.mode_info, &hint); + } else if client.display_system_clipboard_failure { + let hint = system_clipboard_error().part; + render_background_with_text(&client.mode_info, &hint); + } else { + if client.tabs.is_empty() { + return; + } + + let tab_data = prepare_tab_data(client); + let tab_line_output = tab_line( + &client.mode_info, + tab_data, + cols, + slot.toggle_tooltip_key.clone(), + self.tooltip_is_active, + ); + let slot_client = self.slot_clients.entry((slot_id, client_id)).or_default(); + slot_client.tab_line = tab_line_output.parts; + slot_client.breadcrumb_range = tab_line_output.breadcrumb_range; + + let output = slot_client + .tab_line + .iter() + .fold(String::new(), |acc, part| acc + &part.part); + + render_background_with_text(&client.mode_info, &output); + } + } +} + +fn update_display_area(client: &mut ClientState, tabs: &[TabInfo]) { + for tab in tabs { + if tab.active { + client.display_area_rows = tab.display_area_rows; + client.display_area_cols = tab.display_area_columns; + break; + } + } +} + +fn is_restricted_mode(mode: InputMode) -> bool { + matches!( + mode, + InputMode::Locked + | InputMode::EnterSearch + | InputMode::RenameTab + | InputMode::RenamePane + | InputMode::Prompt + | InputMode::Tmux + ) +} + +fn create_persist_args() -> BTreeMap { + let mut args = BTreeMap::new(); + args.insert("persist".to_string(), String::new()); + args +} + +fn render_background_with_text(mode_info: &ModeInfo, text: &str) { + let background = mode_info.style.colors.text_unselected.background; + match background { + PaletteColor::Rgb((r, g, b)) => { + print!("{}\u{1b}[48;2;{};{};{}m\u{1b}[0K", text, r, g, b); + }, + PaletteColor::EightBit(color) => { + print!("{}\u{1b}[48;5;{}m\u{1b}[0K", text, color); + }, + } +} + +fn prepare_tab_data(client: &ClientState) -> TabRenderData { + let mut all_tabs = Vec::new(); + let mut active_tab_index = 0; + let mut active_swap_layout_name = None; + let mut is_swap_layout_dirty = false; + let mut is_alternate_tab = false; + let dimmed = client.mode_info.session_ascended == Some(true) + || client.mode_info.session_dimmed == Some(true); + + for tab in &client.tabs { + let tab_name = get_tab_display_name(client, tab); + + if tab.active { + active_tab_index = tab.position; + if client.mode_info.mode != InputMode::RenameTab { + is_swap_layout_dirty = tab.is_swap_layout_dirty; + active_swap_layout_name = tab.active_swap_layout_name.clone(); + } + } + + let styled_tab = tab_style( + tab_name, + tab, + is_alternate_tab, + client.mode_info.style.colors, + client.mode_info.capabilities, + dimmed, + ); + + is_alternate_tab = !is_alternate_tab; + all_tabs.push(styled_tab); + } + + TabRenderData { + tabs: all_tabs, + active_tab_index, + active_swap_layout_name, + is_swap_layout_dirty, + } +} + +fn get_tab_display_name(client: &ClientState, tab: &TabInfo) -> String { + let mut tab_name = tab.name.clone(); + if tab.active && client.mode_info.mode == InputMode::RenameTab && tab_name.is_empty() { + tab_name = "Enter name...".to_string(); + } + tab_name +} + +fn bind_toggle_key_config(toggle_key: &str, client_id: ClientId) -> String { + format!( + r#" + keybinds {{ + shared {{ + bind "{}" {{ + MessagePlugin "compact-bar" {{ + name "toggle_tooltip" + tooltip "{}" + payload "{}" + }} + }} + }} + }} + "#, + toggle_key, toggle_key, client_id + ) +} diff --git a/default-plugins/compact-bar/src/tab.rs b/default-plugins/bars/src/compact_bar/tab.rs similarity index 93% rename from default-plugins/compact-bar/src/tab.rs rename to default-plugins/bars/src/compact_bar/tab.rs index f3a0f5dde..67d7cd761 100644 --- a/default-plugins/compact-bar/src/tab.rs +++ b/default-plugins/bars/src/compact_bar/tab.rs @@ -1,4 +1,4 @@ -use crate::{line::tab_separator, LinePart}; +use crate::compact_bar::{line::tab_separator, LinePart}; use ansi_term::{ANSIString, ANSIStrings}; use unicode_width::UnicodeWidthStr; use zellij_tile::prelude::*; @@ -8,7 +8,6 @@ fn cursors<'a>( focused_client_slots: &'a [usize], colors: MultiplayerColors, ) -> (Vec>, usize) { - // cursor section, text length let mut len = 0; let mut cursors = vec![]; for display_slot in focused_client_slots.iter() { @@ -17,7 +16,7 @@ fn cursors<'a>( len += 1; } } - len += 2; // 2 for the brackets: [ and ] + len += 2; (cursors, len) } @@ -66,7 +65,7 @@ pub fn render_tab( style!(foreground_color, background_color).bold() }; let left_separator = style!(separator_fill_color, background_color).paint(separator); - let mut tab_text_len = text.width() + (separator_width * 2) + 2; // + 2 for padding + let mut tab_text_len = text.width() + (separator_width * 2) + 2; let tab_styled_text = text_style.paint(format!(" {} ", text)); @@ -115,7 +114,6 @@ pub fn tab_style( if tab.has_bell_notification || tab.is_flashing_bell { tabname.push_str(" [!]"); } - // we only color alternate tabs differently if we can't use the arrow fonts to separate them if !capabilities.arrow_fonts { is_alternate_tab = false; } @@ -130,7 +128,6 @@ pub(crate) fn get_tab_to_focus( ) -> Option { let clicked_line_part = get_clicked_line_part(tab_line, mouse_click_col)?; let clicked_tab_idx = clicked_line_part.tab_index?; - // tabs are indexed starting from 1 so we need to add 1 let clicked_tab_idx = clicked_tab_idx + 1; if clicked_tab_idx != active_tab_idx { return Some(clicked_tab_idx); diff --git a/default-plugins/compact-bar/src/tooltip.rs b/default-plugins/bars/src/compact_bar/tooltip.rs similarity index 93% rename from default-plugins/compact-bar/src/tooltip.rs rename to default-plugins/bars/src/compact_bar/tooltip.rs index 857b526a4..821d72212 100644 --- a/default-plugins/compact-bar/src/tooltip.rs +++ b/default-plugins/bars/src/compact_bar/tooltip.rs @@ -1,4 +1,4 @@ -use crate::keybind_utils::KeybindProcessor; +use crate::compact_bar::keybind_utils::KeybindProcessor; use zellij_tile::prelude::*; pub struct TooltipRenderer<'a> { @@ -25,8 +25,6 @@ impl<'a> TooltipRenderer<'a> { let ribbon_total_width = ribbon_content_width + 4; let total_element_width = text_width + ribbon_total_width + 1; - // Check if this element would exceed the available columns and render an ellipses - // if it does if base_x + x + total_element_width > cols { let remaining_space = cols.saturating_sub(base_x + x); let ellipsis = Text::new("...").opaque(); @@ -65,7 +63,7 @@ impl<'a> TooltipRenderer<'a> { }, _ => { let (_, tooltip_rows, tooltip_cols) = self.other_mode_tooltip(current_mode); - (tooltip_rows + 1, tooltip_cols) // + 1 for the invisible table title + (tooltip_rows + 1, tooltip_cols) }, } } @@ -110,7 +108,7 @@ impl<'a> TooltipRenderer<'a> { let actions_vec: Vec<_> = actions.into_iter().collect(); let mut table = Table::new().add_row(vec![" ".to_owned(); 2]); - let mut row_count = 1; // Start with header row + let mut row_count = 1; if actions_vec.is_empty() { let tooltip_text = match self.mode_info.mode { @@ -139,7 +137,7 @@ impl<'a> TooltipRenderer<'a> { row_count += 1; } - let total_width = key_width + action_width + 1; // +1 for separator + let total_width = key_width + action_width + 1; (table, row_count, total_width) } } diff --git a/default-plugins/bars/src/keybinds.rs b/default-plugins/bars/src/keybinds.rs new file mode 100644 index 000000000..335fce7dd --- /dev/null +++ b/default-plugins/bars/src/keybinds.rs @@ -0,0 +1,130 @@ +use std::collections::BTreeMap; +use zellij_tile::prelude::*; + +type TableId = usize; + +#[derive(Default)] +pub struct KeybindStore { + tables: BTreeMap, + clients: BTreeMap, + next_table_id: TableId, +} + +impl KeybindStore { + pub fn set(&mut self, client_id: ClientId, keybinds: KeybindsVec) { + let existing = self + .tables + .iter() + .find(|(_, table)| **table == keybinds) + .map(|(table_id, _)| *table_id); + let table_id = match existing { + Some(table_id) => table_id, + None => { + let table_id = self.next_table_id; + self.next_table_id += 1; + self.tables.insert(table_id, keybinds); + table_id + }, + }; + if let Some(previous) = self.clients.insert(client_id, table_id) { + self.release(previous); + } + } + + pub fn remove(&mut self, client_id: ClientId) { + if let Some(table_id) = self.clients.remove(&client_id) { + self.release(table_id); + } + } + + pub fn client_ids(&self) -> impl Iterator + '_ { + self.clients.keys().copied() + } + + pub fn swap(&mut self, client_id: ClientId, keybinds: &mut KeybindsVec) -> bool { + let table = self + .clients + .get(&client_id) + .and_then(|table_id| self.tables.get_mut(table_id)); + match table { + Some(table) => { + std::mem::swap(table, keybinds); + true + }, + None => false, + } + } + + fn release(&mut self, table_id: TableId) { + if !self.clients.values().any(|id| *id == table_id) { + self.tables.remove(&table_id); + } + } + + #[cfg(test)] + fn table_count(&self) -> usize { + self.tables.len() + } +} + +pub fn take_keybinds(event: &mut Event) -> Option { + match event { + Event::InitialKeybinds(keybinds) => Some(std::mem::take(keybinds)), + Event::ModeUpdate(mode_info) if !mode_info.keybinds.is_empty() => { + Some(std::mem::take(&mut mode_info.keybinds)) + }, + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use zellij_tile::prelude::actions::Action; + + fn table(c: char) -> KeybindsVec { + vec![( + InputMode::Normal, + vec![(KeyWithModifier::new(BareKey::Char(c)), vec![Action::Quit])], + )] + } + + #[test] + fn clients_with_the_same_keybinds_share_one_table() { + let mut store = KeybindStore::default(); + store.set(1, table('q')); + store.set(2, table('q')); + assert_eq!(store.table_count(), 1); + store.set(2, table('x')); + assert_eq!(store.table_count(), 2); + store.remove(1); + assert_eq!(store.table_count(), 1); + store.remove(2); + assert_eq!(store.table_count(), 0); + } + + #[test] + fn swapping_twice_restores_the_table() { + let mut store = KeybindStore::default(); + store.set(1, table('q')); + store.set(2, table('q')); + let mut lent = KeybindsVec::new(); + assert!(store.swap(1, &mut lent)); + assert_eq!(lent, table('q')); + assert!(store.swap(1, &mut lent)); + assert!(lent.is_empty()); + let mut other = KeybindsVec::new(); + assert!(store.swap(2, &mut other)); + assert_eq!(other, table('q')); + assert!(!store.swap(3, &mut other)); + } + + #[test] + fn take_keybinds_empties_the_event() { + let mut event = Event::InitialKeybinds(table('q')); + assert_eq!(take_keybinds(&mut event), Some(table('q'))); + assert_eq!(event, Event::InitialKeybinds(vec![])); + let mut event = Event::ModeUpdate(ModeInfo::default()); + assert_eq!(take_keybinds(&mut event), None); + } +} diff --git a/default-plugins/link/src/main.rs b/default-plugins/bars/src/link/mod.rs similarity index 71% rename from default-plugins/link/src/main.rs rename to default-plugins/bars/src/link/mod.rs index ed25aab15..0f3fd2769 100644 --- a/default-plugins/link/src/main.rs +++ b/default-plugins/bars/src/link/mod.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::path::{Path, PathBuf}; use zellij_tile::prelude::*; @@ -7,61 +7,60 @@ const FILE_PATH_REGEX: &str = r#"(?:^|\s)((?:(?:\./|\.\./|/)[A-Za-z0-9_./\-+@%,# const CWD_CONTEXT_KEY: &str = "cwd"; #[derive(Default)] -struct State { +pub struct Link { + slots: BTreeSet, + initialized: bool, known_terminal_panes: HashSet, - /// Tracks the current CWD for each terminal pane. pane_cwds: HashMap, - /// Tracks the directory entry names highlighted for each pane, - /// so they can be removed when the CWD changes. pane_dir_entries: HashMap>, - /// Session environment variables, fetched once on load. - /// Used for `~` and `$VAR` expansion in clicked paths. env_vars: BTreeMap, } -register_plugin!(State); - -impl ZellijPlugin for State { - fn load(&mut self, _configuration: BTreeMap) { - subscribe(&[ - EventType::PaneUpdate, - EventType::HighlightClicked, - EventType::CwdChanged, - ]); - // Set host folder to "/" so that /host maps to the real filesystem root, - // allowing std::fs operations on /host/. - change_host_folder(PathBuf::from("/")); - self.env_vars = get_session_environment_variables(); +impl Link { + pub fn has_slots(&self) -> bool { + !self.slots.is_empty() } - fn update(&mut self, event: Event) -> bool { + pub fn slot_added(&mut self, slot: &Slot) { + self.slots.insert(slot.id); + if !self.initialized { + self.initialized = true; + subscribe(&[ + EventType::PaneUpdate, + EventType::HighlightClicked, + EventType::CwdChanged, + ]); + change_host_folder(PathBuf::from("/")); + self.env_vars = get_session_environment_variables(); + } + } + + pub fn slot_removed(&mut self, slot_id: SlotId) { + self.slots.remove(&slot_id); + } + + pub fn update(&mut self, event: &Event) { match event { Event::PaneUpdate(pane_manifest) => { self.handle_pane_update(pane_manifest); }, Event::HighlightClicked { - pane_id: _, - pattern: _, matched_string, context, + .. } => { - self.handle_highlight_clicked(matched_string, context); + self.handle_highlight_clicked(matched_string.clone(), context.clone()); }, Event::CwdChanged(pane_id, new_cwd, _focused_client_ids) => { - self.handle_cwd_changed(pane_id, new_cwd); + self.handle_cwd_changed(*pane_id, new_cwd.clone()); }, _ => {}, } - false // never render — background-only plugin - } - - fn render(&mut self, _rows: usize, _cols: usize) { - // Background-only plugin. Never rendered. Intentionally empty. } } -impl State { - fn handle_pane_update(&mut self, pane_manifest: PaneManifest) { +impl Link { + fn handle_pane_update(&mut self, pane_manifest: &PaneManifest) { let mut current_panes: HashSet = HashSet::new(); for (_tab_index, panes) in &pane_manifest.panes { @@ -73,10 +72,8 @@ impl State { } } - // Set highlights on newly appeared terminal panes for &pane_id in ¤t_panes { if !self.known_terminal_panes.contains(&pane_id) { - // Fetch the pane's current CWD and scan its directory if let Ok(cwd) = get_pane_cwd(pane_id) { self.scan_and_store_dir_entries(pane_id, &cwd); self.pane_cwds.insert(pane_id, cwd); @@ -85,7 +82,6 @@ impl State { } } - // Clean up tracking state for panes that no longer exist for &pane_id in &self.known_terminal_panes { if !current_panes.contains(&pane_id) { self.pane_cwds.remove(&pane_id); @@ -105,8 +101,6 @@ impl State { self.pane_cwds.insert(pane_id, new_cwd.clone()); self.scan_and_store_dir_entries(pane_id, &new_cwd); - // clear_pane_highlights removes all highlights, then re-set everything - // (file-path regex + directory entry patterns) clear_pane_highlights(pane_id); self.set_all_highlights_for_pane(pane_id); } @@ -117,8 +111,6 @@ impl State { let expanded = expand_path(path_str, &self.env_vars); let path_str = expanded.as_str(); - // Resolve to a fully qualified path: if relative, join with the - // pane CWD stored in the highlight context. let absolute_path = if path_str.starts_with('/') { PathBuf::from(path_str) } else if let Some(cwd) = context.get(CWD_CONTEXT_KEY) { @@ -127,14 +119,11 @@ impl State { PathBuf::from(path_str) }; - // Validate the path exists via the /host/ filesystem mapping - // established at load time. This guards against regex false - // positives that match non-path text in terminal output. let host_path = Path::new("/host").join(absolute_path.strip_prefix("/").unwrap_or(&absolute_path)); let metadata = match std::fs::metadata(&host_path) { Ok(m) => m, - Err(_) => return, // path does not exist — silently ignore + Err(_) => return, }; if metadata.is_dir() { @@ -163,47 +152,19 @@ impl State { } } - /// (Re-)set all regex highlights for a pane: the general file-path regex - /// plus any directory-entry patterns derived from the pane's CWD. fn set_all_highlights_for_pane(&self, pane_id: PaneId) { let mut highlights = Vec::new(); - // Build context map containing the pane CWD (echoed back on click) let context = self.cwd_context_for_pane(pane_id); - // General file-path regex (always present) - highlights.push(RegexHighlight { - pattern: FILE_PATH_REGEX.to_owned(), - style: HighlightStyle::None, - layer: HighlightLayer::Hint, - context: context.clone(), - on_hover: true, - bold: false, - italic: true, - underline: true, - tooltip_text: Some("Open".to_string()), - }); + highlights.push(link_highlight(FILE_PATH_REGEX.to_owned(), context.clone())); - // Directory-entry patterns for the pane's current CWD - if let Some(entries) = self.pane_dir_entries.get(&pane_id) { - for entry_name in entries { - let path_chars = r#"[A-Za-z0-9_./\-+@%,#=~!\$\{\}\[\]]"#; - let pattern = format!( - "(?:^|\\s)({}(?:/{path_chars}+)?(?::\\d+(?::\\d+)?)?)(?::|\\s|$)", - regex_escape(entry_name), - ); - highlights.push(RegexHighlight { - pattern, - style: HighlightStyle::None, - layer: HighlightLayer::Hint, - context: context.clone(), - on_hover: true, - bold: false, - italic: true, - underline: true, - tooltip_text: Some("Open".to_string()), - }); - } + if let Some(pattern) = self + .pane_dir_entries + .get(&pane_id) + .and_then(|entries| dir_entries_pattern(entries)) + { + highlights.push(link_highlight(pattern, context)); } set_pane_regex_highlights(pane_id, highlights); @@ -224,12 +185,41 @@ impl State { } } -/// Scan a directory for first-level file and folder names. -/// Returns an empty vec on any error. -/// Maximum number of directory entries to scan. Directories with more entries -/// than this are skipped entirely to avoid excessive regex pattern count. const MAX_DIR_ENTRIES: usize = 500; +const PATH_CHARS: &str = r#"[A-Za-z0-9_./\-+@%,#=~!\$\{\}\[\]]"#; + +fn link_highlight(pattern: String, context: BTreeMap) -> RegexHighlight { + RegexHighlight { + pattern, + style: HighlightStyle::None, + layer: HighlightLayer::Hint, + context, + on_hover: true, + bold: false, + italic: true, + underline: true, + tooltip_text: Some("Open".to_string()), + } +} + +fn dir_entries_pattern(entries: &[String]) -> Option { + if entries.is_empty() { + return None; + } + let mut names: Vec<&str> = entries.iter().map(|e| e.as_str()).collect(); + names.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b))); + names.dedup(); + let alternatives = names + .iter() + .map(|name| regex_escape(name)) + .collect::>() + .join("|"); + Some(format!( + "(?:^|\\s)((?:{alternatives})(?:/{PATH_CHARS}+)?(?::\\d+(?::\\d+)?)?)(?::|\\s|$)" + )) +} + fn scan_directory(path: &Path) -> Vec { let mut entries = Vec::new(); let read_dir = match std::fs::read_dir(path) { @@ -249,7 +239,6 @@ fn scan_directory(path: &Path) -> Vec { entries } -/// Escape a string so it is treated as a literal in a regex pattern. fn regex_escape(s: &str) -> String { let mut escaped = String::with_capacity(s.len() + 8); for c in s.chars() { @@ -264,15 +253,7 @@ fn regex_escape(s: &str) -> String { escaped } -/// Expand `~` and `$VAR` / `${VAR}` references in a path string. -/// -/// - `~` at the start (followed by `/` or at end-of-string) is replaced with -/// the value of `HOME` from `env_vars`. -/// - `$VARNAME` and `${VARNAME}` anywhere in the string are replaced with the -/// corresponding value from `env_vars`. -/// - Unrecognized variables and a missing `HOME` are left as-is. fn expand_path(path: &str, env_vars: &BTreeMap) -> String { - // Step 1: tilde expansion (only leading ~) let after_tilde = if path == "~" { match env_vars.get("HOME") { Some(home) => home.clone(), @@ -287,7 +268,6 @@ fn expand_path(path: &str, env_vars: &BTreeMap) -> String { path.to_owned() }; - // Step 2: environment variable expansion ($VAR and ${VAR}) let bytes = after_tilde.as_bytes(); let len = bytes.len(); let mut result = String::with_capacity(len); @@ -296,18 +276,15 @@ fn expand_path(path: &str, env_vars: &BTreeMap) -> String { while i < len { if bytes[i] == b'$' && i + 1 < len { let (var_name, end_idx) = if bytes[i + 1] == b'{' { - // ${VAR} form if let Some(close) = after_tilde[i + 2..].find('}') { let name = &after_tilde[i + 2..i + 2 + close]; (name, i + 2 + close + 1) } else { - // No closing brace — not a valid variable reference result.push('$'); i += 1; continue; } } else { - // $VAR form — variable name is [A-Za-z_][A-Za-z0-9_]* let start = i + 1; if start < len && (bytes[start].is_ascii_alphabetic() || bytes[start] == b'_') { let mut end = start + 1; @@ -325,7 +302,6 @@ fn expand_path(path: &str, env_vars: &BTreeMap) -> String { if let Some(value) = env_vars.get(var_name) { result.push_str(value); } else { - // Unknown variable — preserve the original text result.push_str(&after_tilde[i..end_idx]); } i = end_idx; @@ -375,8 +351,6 @@ fn parse_path_and_line(matched_string: &str) -> (&str, Option) { mod tests { use super::*; - // --- parse_path_and_line tests --- - #[test] fn parse_path_and_line_simple_path() { let (path, line) = parse_path_and_line("src/main.rs"); @@ -405,8 +379,6 @@ mod tests { assert_eq!(line, None); } - // --- expand_path tests --- - #[test] fn expand_path_tilde() { let mut env = BTreeMap::new(); @@ -440,8 +412,6 @@ mod tests { assert_eq!(expand_path("/absolute/path", &env), "/absolute/path"); } - // --- regex_escape tests --- - #[test] fn regex_escape_special_chars() { assert_eq!(regex_escape("file.txt"), r"file\.txt"); @@ -455,7 +425,40 @@ mod tests { assert_eq!(regex_escape("hello_world"), "hello_world"); } - // --- scan_directory tests --- + #[test] + fn dir_entries_pattern_empty_is_none() { + assert_eq!(dir_entries_pattern(&[]), None); + } + + #[test] + fn dir_entries_pattern_combines_and_escapes() { + let entries = vec!["a.rs".to_owned(), "Cargo.toml".to_owned(), "(x)".to_owned()]; + let pattern = dir_entries_pattern(&entries).unwrap(); + assert_eq!( + pattern, + format!( + "(?:^|\\s)((?:Cargo\\.toml|a\\.rs|\\(x\\))(?:/{PATH_CHARS}+)?(?::\\d+(?::\\d+)?)?)(?::|\\s|$)" + ) + ); + } + + #[test] + fn dir_entries_pattern_matches_space_separated_names() { + let entries = vec!["src".to_owned(), "Cargo.toml".to_owned()]; + let regex = regex::Regex::new(&dir_entries_pattern(&entries).unwrap()).unwrap(); + let text = "Cargo.toml src src/main.rs:12 other"; + let mut found = vec![]; + let mut pos = 0; + while pos <= text.len() { + let Some(captures) = regex.captures_at(text, pos) else { + break; + }; + let m = captures.get(1).unwrap(); + found.push(m.as_str().to_owned()); + pos = m.end().max(pos + 1); + } + assert_eq!(found, vec!["Cargo.toml", "src", "src/main.rs:12"]); + } #[test] fn scan_directory_returns_entries() { diff --git a/default-plugins/bars/src/main.rs b/default-plugins/bars/src/main.rs new file mode 100644 index 000000000..c9d64e91b --- /dev/null +++ b/default-plugins/bars/src/main.rs @@ -0,0 +1,274 @@ +mod compact_bar; +mod keybinds; +mod link; +mod status_bar; +mod tab_bar; + +use std::collections::BTreeMap; +use zellij_tile::prelude::*; + +use compact_bar::CompactBar; +use keybinds::{take_keybinds, KeybindStore}; +use link::Link; +use status_bar::StatusBar; +use tab_bar::TabBar; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Role { + TabBar, + StatusBar, + CompactBar, + Link, +} + +impl Role { + fn from_configuration(configuration: &BTreeMap) -> Role { + match configuration.get("role").map(|r| r.as_str()) { + Some("status-bar") => Role::StatusBar, + Some("compact-bar") => Role::CompactBar, + Some("link") => Role::Link, + _ => Role::TabBar, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct ClientSeed { + pub mode_info: ModeInfo, + pub tabs: Vec, +} + +impl ClientSeed { + fn apply(&mut self, event: &Event) { + match event { + Event::ModeUpdate(mode_info) => { + self.mode_info = mode_info.clone(); + }, + Event::TabUpdate(tabs) => { + if tabs.iter().any(|t| t.active) { + self.tabs = tabs.clone(); + } + }, + _ => {}, + } + } +} + +#[derive(Default)] +struct State { + slots: BTreeMap, + pending: BTreeMap, + tab_bar: TabBar, + status_bar: StatusBar, + compact_bar: CompactBar, + link: Link, + keybinds: KeybindStore, +} + +register_shared_plugin!(State); + +impl State { + fn role_has_slots(&self, role: Role) -> bool { + match role { + Role::TabBar => self.tab_bar.has_slots(), + Role::StatusBar => self.status_bar.has_slots(), + Role::CompactBar => self.compact_bar.has_slots(), + Role::Link => self.link.has_slots(), + } + } + + fn any_bar_has_slots(&self) -> bool { + self.tab_bar.has_slots() || self.status_bar.has_slots() || self.compact_bar.has_slots() + } + + fn snapshot_for(&self, role: Role) -> BTreeMap { + if role != Role::TabBar && self.tab_bar.has_slots() { + self.tab_bar.snapshot() + } else if role != Role::StatusBar && self.status_bar.has_slots() { + self.status_bar.snapshot() + } else if role != Role::CompactBar && self.compact_bar.has_slots() { + self.compact_bar.snapshot() + } else { + self.pending.clone() + } + } + + fn store_keybinds(&mut self, event: &mut Event, client_id: Option) { + let Some(keybinds) = take_keybinds(event) else { + return; + }; + match client_id { + Some(client_id) => self.keybinds.set(client_id, keybinds), + None => { + let mut client_ids: Vec = self.keybinds.client_ids().collect(); + client_ids.extend(self.pending.keys().copied()); + client_ids.extend(self.tab_bar.client_ids()); + client_ids.extend(self.status_bar.client_ids()); + client_ids.extend(self.compact_bar.client_ids()); + client_ids.sort_unstable(); + client_ids.dedup(); + for client_id in client_ids { + self.keybinds.set(client_id, keybinds.clone()); + } + }, + } + } + + fn snapshot_of(&self, role: Role) -> BTreeMap { + match role { + Role::TabBar => self.tab_bar.snapshot(), + Role::StatusBar => self.status_bar.snapshot(), + Role::CompactBar => self.compact_bar.snapshot(), + Role::Link => BTreeMap::new(), + } + } +} + +impl ZellijSharedPlugin for State { + fn slot_added(&mut self, slot: Slot) { + let role = Role::from_configuration(&slot.configuration); + if slot.kind == SlotKind::Pane { + set_selectable(false); + } + if role != Role::Link && !self.role_has_slots(role) { + let seeds = self.snapshot_for(role); + match role { + Role::TabBar => self.tab_bar.seed(&seeds), + Role::StatusBar => self.status_bar.seed(&seeds), + Role::CompactBar => self.compact_bar.seed(&seeds), + Role::Link => {}, + } + } + self.slots.insert(slot.id, role); + match role { + Role::TabBar => self.tab_bar.slot_added(&slot), + Role::StatusBar => self.status_bar.slot_added(&slot), + Role::CompactBar => self.compact_bar.slot_added(&slot), + Role::Link => self.link.slot_added(&slot), + } + if self.any_bar_has_slots() { + self.pending.clear(); + } + } + + fn slot_removed(&mut self, slot_id: SlotId) { + let Some(role) = self.slots.remove(&slot_id) else { + return; + }; + match role { + Role::TabBar => self.tab_bar.slot_removed(slot_id), + Role::StatusBar => self.status_bar.slot_removed(slot_id), + Role::CompactBar => self.compact_bar.slot_removed(slot_id), + Role::Link => self.link.slot_removed(slot_id), + } + if role != Role::Link && !self.role_has_slots(role) { + if !self.any_bar_has_slots() { + self.pending = self.snapshot_of(role); + } + match role { + Role::TabBar => self.tab_bar.reset(), + Role::StatusBar => self.status_bar.reset(), + Role::CompactBar => self.compact_bar.reset(), + Role::Link => {}, + } + } + } + + fn client_connected(&mut self, client_id: ClientId) { + if self.tab_bar.has_slots() { + self.tab_bar.client_connected(client_id); + } + if self.status_bar.has_slots() { + self.status_bar.client_connected(client_id); + } + if self.compact_bar.has_slots() { + self.compact_bar.client_connected(client_id); + } + } + + fn client_disconnected(&mut self, client_id: ClientId) { + self.pending.remove(&client_id); + self.keybinds.remove(client_id); + self.tab_bar.client_disconnected(client_id); + self.status_bar.client_disconnected(client_id); + self.compact_bar.client_disconnected(client_id); + } + + fn update(&mut self, mut event: Event, context: EventContext) -> RenderResponse { + self.compact_bar.ensure_toggle_keybinds(context.client_id); + self.store_keybinds(&mut event, context.client_id); + match &event { + Event::Mouse(_) | Event::Key(_) => { + let role = context + .slot_id + .and_then(|slot_id| self.slots.get(&slot_id).copied()); + match role { + Some(Role::TabBar) => self.tab_bar.update(&event, context), + Some(Role::StatusBar) => self.status_bar.update(&event, context), + Some(Role::CompactBar) => { + self.compact_bar.update(&event, context, &mut self.keybinds) + }, + _ => RenderResponse::Nothing, + } + }, + _ => { + let mut render = RenderResponse::Nothing; + if !self.any_bar_has_slots() { + if let Some(client_id) = context.client_id { + if matches!( + event, + Event::ModeUpdate(_) | Event::TabUpdate(_) | Event::InitialKeybinds(_) + ) { + self.pending.entry(client_id).or_default().apply(&event); + } + } + } + if self.tab_bar.has_slots() || matches!(event, Event::Timer(_)) { + let tab_bar_render = self.tab_bar.update(&event, context); + if self.tab_bar.has_slots() { + render = render.merge(tab_bar_render); + } + } + if self.status_bar.has_slots() { + render = render.merge(self.status_bar.update(&event, context)); + } + if self.compact_bar.has_slots() { + render = + render.merge(self.compact_bar.update(&event, context, &mut self.keybinds)); + } + if self.link.has_slots() { + self.link.update(&event); + } + render + }, + } + } + + fn pipe(&mut self, pipe_message: PipeMessage, context: EventContext) -> RenderResponse { + self.compact_bar.ensure_toggle_keybinds(context.client_id); + if self.compact_bar.has_slots() { + self.compact_bar + .pipe(pipe_message, context, &mut self.keybinds) + } else { + RenderResponse::Nothing + } + } + + fn render(&mut self, rows: usize, cols: usize, slot_id: SlotId, client_id: ClientId) { + match self.slots.get(&slot_id) { + Some(Role::TabBar) => { + self.tab_bar + .render(rows, cols, slot_id, client_id, &mut self.keybinds) + }, + Some(Role::StatusBar) => { + self.status_bar + .render(rows, cols, slot_id, client_id, &mut self.keybinds) + }, + Some(Role::CompactBar) => { + self.compact_bar + .render(rows, cols, slot_id, client_id, &mut self.keybinds) + }, + _ => {}, + } + } +} diff --git a/default-plugins/status-bar/src/first_line.rs b/default-plugins/bars/src/status_bar/first_line.rs similarity index 83% rename from default-plugins/status-bar/src/first_line.rs rename to default-plugins/bars/src/status_bar/first_line.rs index 751f71f8f..a6b2b1b60 100644 --- a/default-plugins/status-bar/src/first_line.rs +++ b/default-plugins/bars/src/status_bar/first_line.rs @@ -2,11 +2,11 @@ use ansi_term::{unstyled_len, ANSIStrings}; use zellij_tile::prelude::actions::Action; use zellij_tile::prelude::*; -use crate::color_elements; -use crate::{ +use crate::status_bar::color_elements; +use crate::status_bar::{ action_key, action_key_group, get_common_modifiers, style_key_with_modifier, TO_NORMAL, }; -use crate::{ColoredElements, LinePart}; +use crate::status_bar::{ColoredElements, LinePart}; #[derive(Debug)] pub struct KeyShortcut { @@ -116,25 +116,6 @@ impl KeyShortcut { } } -/// Generate long mode shortcut tile. -/// -/// A long mode shortcut tile consists of a leading and trailing `separator`, a keybinding enclosed -/// in `<>` brackets and the name of the mode displayed in capitalized letters next to it. For -/// example, the default long mode shortcut tile for "Locked" mode is: ` LOCK `. -/// -/// # Arguments -/// -/// - `key`: A [`KeyShortcut`] that defines how the tile is displayed (active/disabled/...), what -/// action it belongs to (roughly equivalent to [`InputMode`]s) and the keybinding to trigger -/// this action. -/// - `palette`: A structure holding styling information. -/// - `separator`: The separator printed before and after the mode shortcut tile. The default is an -/// arrow head-like separator. -/// - `shared_super`: If set to true, all mode shortcut keybindings share a common modifier (see -/// [`get_common_modifier`]) and the modifier belonging to the keybinding is **not** printed in -/// the shortcut tile. -/// - `first_tile`: If set to true, the leading separator for this tile will be omitted so no gap -/// appears on the screen. fn long_mode_shortcut( key: &KeyShortcut, palette: ColoredElements, @@ -177,13 +158,13 @@ fn long_mode_shortcut( suffix_separator, ]) .to_string(), - len: start_separator.chars().count() // Separator - + 2 // " <" - + key_binding.chars().count() // Key binding - + 2 // "> " - + key_hint.chars().count() // Key hint (mode) - + 1 // " " - + separator.chars().count(), // Separator + len: start_separator.chars().count() + + 2 + + key_binding.chars().count() + + 2 + + key_hint.chars().count() + + 1 + + separator.chars().count(), } } @@ -229,34 +210,16 @@ fn shortened_modifier_shortcut( suffix_separator, ]) .to_string(), - len: start_separator.chars().count() // Separator - + 2 // " <" - + key_binding.chars().count() // Key binding - + 2 // "> " - + key_hint.chars().count() // Key hint (mode) - + 1 // " " - + separator.chars().count(), // Separator + len: start_separator.chars().count() + + 2 + + key_binding.chars().count() + + 2 + + key_hint.chars().count() + + 1 + + separator.chars().count(), } } -/// Generate short mode shortcut tile. -/// -/// A short mode shortcut tile consists of a leading and trailing `separator` and a keybinding. For -/// example, the default short mode shortcut tile for "Locked" mode is: ` g `. -/// -/// # Arguments -/// -/// - `key`: A [`KeyShortcut`] that defines how the tile is displayed (active/disabled/...), what -/// action it belongs to (roughly equivalent to [`InputMode`]s) and the keybinding to trigger -/// this action. -/// - `palette`: A structure holding styling information. -/// - `separator`: The separator printed before and after the mode shortcut tile. The default is an -/// arrow head-like separator. -/// - `shared_super`: If set to true, all mode shortcut keybindings share a common modifier (see -/// [`get_common_modifier`]) and the modifier belonging to the keybinding is **not** printed in -/// the shortcut tile. -/// - `first_tile`: If set to true, the leading separator for this tile will be omitted so no gap -/// appears on the screen. fn short_mode_shortcut( key: &KeyShortcut, palette: ColoredElements, @@ -287,11 +250,11 @@ fn short_mode_shortcut( let suffix_separator = colors.suffix_separator.paint(separator); LinePart { part: ANSIStrings(&[prefix_separator, char_shortcut, suffix_separator]).to_string(), - len: separator.chars().count() // Separator - + 1 // " " - + key_binding.chars().count() // Key binding - + 1 // " " - + separator.chars().count(), // Separator + len: separator.chars().count() + + 1 + + key_binding.chars().count() + + 1 + + separator.chars().count(), } } @@ -302,7 +265,6 @@ fn key_indicators( separator: &str, mode_info: &ModeInfo, ) -> LinePart { - // Print full-width hints let (shared_modifiers, mut line_part) = superkey(palette, separator, mode_info); for key in keys { let line_empty = line_part.len == 0; @@ -314,7 +276,6 @@ fn key_indicators( return line_part; } - // Full-width doesn't fit, try shortened modifiers (eg. "^C" instead of "Ctrl") line_part = superkey(palette, separator, mode_info).1; for key in keys { let line_empty = line_part.len == 0; @@ -327,7 +288,6 @@ fn key_indicators( return line_part; } - // Full-width doesn't fit, try shortened hints (just keybindings, no meanings/actions) line_part = superkey(palette, separator, mode_info).1; for key in keys { let line_empty = line_part.len == 0; @@ -339,7 +299,6 @@ fn key_indicators( return line_part; } - // Shortened doesn't fit, print nothing line_part = LinePart::default(); line_part } @@ -374,8 +333,7 @@ fn swap_layout_status( let mut swap_layout_name = format!(" {} ", swap_layout_name); swap_layout_name.make_ascii_uppercase(); let keycode = swap_layout_keycode(mode_info); - let swap_layout_name_len = swap_layout_name.len() + 3; // 2 for the arrow separators, one for the screen end buffer - // + let swap_layout_name_len = swap_layout_name.len() + 3; macro_rules! style_swap_layout_indicator { ($style_name:ident) => {{ ( @@ -407,10 +365,7 @@ fn swap_layout_status( prefix_separator, swap_layout_name, suffix_separator ); let (part, full_len) = if mode_info.mode == InputMode::Locked { - ( - format!("{}", swap_layout_indicator), - swap_layout_name_len, // 1 is the space between - ) + (format!("{}", swap_layout_indicator), swap_layout_name_len) } else { ( format!( @@ -420,10 +375,10 @@ fn swap_layout_status( swap_layout_indicator, colored_elements.superkey_prefix.paint(" ") ), - keycode.len + swap_layout_name_len + 1, // 1 is the space between + keycode.len + swap_layout_name_len + 1, ) }; - let short_len = swap_layout_name_len + 1; // 1 is the space between + let short_len = swap_layout_name_len + 1; if full_len <= max_len { Some(LinePart { part, @@ -442,29 +397,13 @@ fn swap_layout_status( } } -/// Get the keybindings for switching `InputMode`s and `Quit` visible in status bar. -/// -/// Return a Vector of `Key`s where each `Key` is a shortcut to switch to some `InputMode` or Quit -/// zellij. Given the vast amount of things a user can configure in their zellij config, this -/// function has some limitations to keep in mind: -/// -/// - The vector is not deduplicated: If switching to a certain `InputMode` is bound to multiple -/// `Key`s, all of these bindings will be part of the returned vector. There is also no -/// guaranteed sort order. Which key ends up in the status bar in such a situation isn't defined. -/// - The vector will **not** contain the ' ', '\n' and 'Esc' keys: These are the default bindings -/// to get back to normal mode from any input mode, but they aren't of interest when searching -/// for the super key. If for any input mode the user has bound only these keys to switching back -/// to `InputMode::Normal`, a '?' will be displayed as keybinding instead. pub fn mode_switch_keys(mode_info: &ModeInfo) -> Vec { mode_info .get_mode_keybinds() .iter() .filter_map(|(key, vac)| match vac.first() { - // No actions defined, ignore None => None, Some(vac) => { - // We ignore certain "default" keybindings that switch back to normal InputMode. - // These include: ' ', '\n', 'Esc' if matches!( key, KeyWithModifier { @@ -482,7 +421,6 @@ pub fn mode_switch_keys(mode_info: &ModeInfo) -> Vec { } if let actions::Action::SwitchToMode { input_mode: mode } = vac { return match mode { - // Store the keys that switch to displayed modes InputMode::Normal | InputMode::Locked | InputMode::Pane @@ -497,7 +435,6 @@ pub fn mode_switch_keys(mode_info: &ModeInfo) -> Vec { if let actions::Action::Quit = vac { return Some(key.clone()); } - // Not a `SwitchToMode` or `Quit` action, ignore None }, }) @@ -509,14 +446,12 @@ pub fn superkey( separator: &str, mode_info: &ModeInfo, ) -> (Vec, LinePart) { - // Find a common modifier if any let common_modifiers = get_common_modifiers(mode_switch_keys(mode_info).iter().collect()); if common_modifiers.is_empty() { return (common_modifiers, LinePart::default()); } let prefix_text = if mode_info.capabilities.arrow_fonts { - // Add extra space in simplified ui format!( " {} + ", common_modifiers @@ -551,7 +486,6 @@ pub fn to_char(kv: Vec) -> Option { let key = kv .iter() .filter(|key| { - // These are general "keybindings" to get back to normal, they aren't interesting here. !matches!( key, KeyWithModifier { @@ -569,21 +503,12 @@ pub fn to_char(kv: Vec) -> Option { .collect::>() .into_iter() .next(); - // Maybe the user bound one of the ignored keys? if key.is_none() { return kv.first().cloned(); } key.cloned() } -/// Get the [`KeyShortcut`] for a specific [`InputMode`]. -/// -/// Iterates over the contents of `shortcuts` to find the [`KeyShortcut`] with the [`KeyAction`] -/// matching the [`InputMode`]. Returns a mutable reference to the entry in `shortcuts` if a match -/// is found or `None` otherwise. -/// -/// In case multiple entries in `shortcuts` match `mode` (which shouldn't happen), the first match -/// is returned. fn get_key_shortcut_for_mode<'a>( shortcuts: &'a mut [KeyShortcut], mode: &InputMode, @@ -615,7 +540,6 @@ pub fn first_line( let supports_arrow_fonts = !help.capabilities.arrow_fonts; let colored_elements = color_elements(help.style.colors, !supports_arrow_fonts, false); let binds = &help.get_mode_keybinds(); - // Unselect all by default let mut default_keys = vec![ KeyShortcut::new( KeyMode::Unselected, @@ -699,7 +623,6 @@ pub fn first_line( key_shortcut.key = to_char(action_key(binds, &[TO_NORMAL])); } - // In locked mode we must disable all other mode keybindings if help.mode == InputMode::Locked { for key in default_keys.iter_mut().skip(1) { key.mode = KeyMode::Disabled; @@ -707,7 +630,6 @@ pub fn first_line( } if help.mode == InputMode::Tmux { - // Tmux tile is hidden by default default_keys.push(KeyShortcut::new( KeyMode::Selected, KeyAction::Tmux, @@ -743,13 +665,6 @@ pub fn first_line( } #[cfg(test)] -/// Unit tests. -/// -/// Note that we cheat a little here, because the number of things one may want to test is endless, -/// and creating a Mockup of [`ModeInfo`] by hand for all these testcases is nothing less than -/// torture. Hence, we test the most atomic units thoroughly ([`long_mode_shortcut`] and -/// [`short_mode_shortcut`]) and then test the public API ([`first_line`]) to ensure correct -/// operation. mod tests { use super::*; @@ -758,7 +673,6 @@ mod tests { color_elements(palette, false, false) } - // Strip style information from `LinePart` and return a raw String instead fn unstyle(line_part: LinePart) -> String { let string = line_part.to_string(); @@ -784,7 +698,6 @@ mod tests { } #[test] - // Displayed like selected(alternate), but different styling fn long_mode_shortcut_unselected_with_binding() { let key = KeyShortcut::new( KeyMode::Unselected, @@ -800,7 +713,6 @@ mod tests { } #[test] - // Treat exactly like "unselected" variant fn long_mode_shortcut_unselected_alternate_with_binding() { let key = KeyShortcut::new( KeyMode::UnselectedAlternate, @@ -816,7 +728,6 @@ mod tests { } #[test] - // KeyShortcuts without binding are only displayed when "disabled" (for locked mode indications) fn long_mode_shortcut_selected_without_binding() { let key = KeyShortcut::new(KeyMode::Selected, KeyAction::Session, None); let color = colored_elements(); @@ -828,7 +739,6 @@ mod tests { } #[test] - // First tile doesn't print a starting separator fn long_mode_shortcut_selected_with_binding_first_tile() { let key = KeyShortcut::new( KeyMode::Selected, @@ -844,7 +754,6 @@ mod tests { } #[test] - // Modifier is the superkey, mustn't appear in angled brackets fn long_mode_shortcut_selected_with_ctrl_binding_shared_superkey() { let key = KeyShortcut::new( KeyMode::Selected, @@ -860,7 +769,6 @@ mod tests { } #[test] - // Modifier must be in the angled brackets fn long_mode_shortcut_selected_with_ctrl_binding_no_shared_superkey() { let key = KeyShortcut::new( KeyMode::Selected, @@ -876,7 +784,6 @@ mod tests { } #[test] - // Must be displayed as usual, but it is styled to be greyed out which we don't test here fn long_mode_shortcut_disabled_with_binding() { let key = KeyShortcut::new( KeyMode::Disabled, @@ -892,7 +799,6 @@ mod tests { } #[test] - // Must be displayed but without keybinding fn long_mode_shortcut_disabled_without_binding() { let key = KeyShortcut::new(KeyMode::Disabled, KeyAction::Session, None); let color = colored_elements(); @@ -904,9 +810,6 @@ mod tests { } #[test] - // Test all at once - // Note that when "shared_super" is true, the tile **cannot** be the first on the line, so we - // ignore **first** here. fn long_mode_shortcut_selected_with_ctrl_binding_and_shared_super_and_first_tile() { let key = KeyShortcut::new( KeyMode::Selected, @@ -1071,7 +974,6 @@ mod tests { } #[test] - // Observe: Modes missing in between aren't displayed! fn first_line_default_layout_shared_super() { #[rustfmt::skip] let mode_info = ModeInfo{ diff --git a/default-plugins/status-bar/src/main.rs b/default-plugins/bars/src/status_bar/mod.rs similarity index 75% rename from default-plugins/status-bar/src/main.rs rename to default-plugins/bars/src/status_bar/mod.rs index f12da1cc5..c85af7b23 100644 --- a/default-plugins/status-bar/src/main.rs +++ b/default-plugins/bars/src/status_bar/mod.rs @@ -24,31 +24,44 @@ use second_line::{ }; use tip::utils::get_cached_tip_name; -// for more of these, copy paste from: https://en.wikipedia.org/wiki/Box-drawing_character +use crate::keybinds::KeybindStore; +use crate::ClientSeed; + static ARROW_SEPARATOR: &str = ""; static MORE_MSG: &str = " ... "; -/// Shorthand for `Action::SwitchToMode{input_mode: InputMode::Normal}`. const TO_NORMAL: Action = Action::SwitchToMode { input_mode: InputMode::Normal, }; #[derive(Default)] -struct State { - tabs: Vec, +struct SlotConfig { tip_name: String, + classic_ui: bool, +} + +#[derive(Default)] +struct ClientState { + tabs: Vec, mode_info: ModeInfo, text_copy_destination: Option, display_system_clipboard_failure: bool, - classic_ui: bool, base_mode_is_locked: bool, - cached_keybinds: KeybindsVec, - new_pane_ribbon_range: Option<(usize, usize)>, - floating_ribbon_range: Option<(usize, usize)>, new_pane_ribbon_hovered: bool, floating_ribbon_hovered: bool, } -register_plugin!(State); +#[derive(Default)] +struct SlotClientState { + new_pane_ribbon_range: Option<(usize, usize)>, + floating_ribbon_range: Option<(usize, usize)>, +} + +#[derive(Default)] +pub struct StatusBar { + slots: BTreeMap, + clients: BTreeMap, + slot_clients: BTreeMap<(SlotId, ClientId), SlotClientState>, +} #[derive(Default)] pub struct LinePart { @@ -75,7 +88,6 @@ pub struct ColoredElements { pub unselected: SegmentStyle, pub unselected_alternate: SegmentStyle, pub disabled: SegmentStyle, - // superkey pub superkey_prefix: Style, pub superkey_suffix_separator: Style, } @@ -90,10 +102,6 @@ pub struct SegmentStyle { pub suffix_separator: Style, } -// I really hate this, but I can't come up with a good solution for this, -// we need different colors from palette for the default theme -// plus here we can add new sources in the future, like Theme -// that can be defined in the config perhaps fn color_elements( palette: Styling, different_color_alternates: bool, @@ -229,15 +237,25 @@ fn col_in_range(range: Option<(usize, usize)>, col: usize) -> bool { } } -impl ZellijPlugin for State { - fn load(&mut self, configuration: BTreeMap) { - // TODO: Should be able to choose whether to use the cache through config. - self.tip_name = get_cached_tip_name(); - self.classic_ui = configuration +impl StatusBar { + pub fn has_slots(&self) -> bool { + !self.slots.is_empty() + } + + pub fn slot_added(&mut self, slot: &Slot) { + let tip_name = get_cached_tip_name(); + let classic_ui = slot + .configuration .get("classic") .map(|c| c == "true") .unwrap_or(false); - set_selectable(false); + self.slots.insert( + slot.id, + SlotConfig { + tip_name, + classic_ui, + }, + ); subscribe(&[ EventType::ModeUpdate, EventType::TabUpdate, @@ -250,36 +268,105 @@ impl ZellijPlugin for State { ]); } - fn update(&mut self, event: Event) -> bool { + pub fn slot_removed(&mut self, slot_id: SlotId) { + self.slots.remove(&slot_id); + self.slot_clients.retain(|(s, _), _| *s != slot_id); + } + + pub fn client_connected(&mut self, client_id: ClientId) { + self.clients.entry(client_id).or_default(); + } + + pub fn client_disconnected(&mut self, client_id: ClientId) { + self.clients.remove(&client_id); + self.slot_clients.retain(|(_, c), _| *c != client_id); + } + + pub fn client_ids(&self) -> impl Iterator + '_ { + self.clients.keys().copied() + } + + pub fn reset(&mut self) { + self.clients.clear(); + self.slot_clients.clear(); + } + + pub fn snapshot(&self) -> BTreeMap { + self.clients + .iter() + .map(|(client_id, client)| { + ( + *client_id, + ClientSeed { + mode_info: client.mode_info.clone(), + tabs: client.tabs.clone(), + }, + ) + }) + .collect() + } + + pub fn seed(&mut self, seeds: &BTreeMap) { + for (client_id, seed) in seeds { + let client = self.clients.entry(*client_id).or_default(); + client.mode_info = seed.mode_info.clone(); + client.base_mode_is_locked = client.mode_info.base_mode == Some(InputMode::Locked); + client.tabs = seed.tabs.clone(); + } + } + + pub fn update(&mut self, event: &Event, context: EventContext) -> RenderResponse { + let target_clients: Vec = match context.client_id { + Some(client_id) => vec![client_id], + None => self.clients.keys().copied().collect(), + }; + let mut should_render = false; + for client_id in target_clients { + if self.update_client(event, client_id, context.slot_id) { + should_render = true; + } + } + if !should_render { + RenderResponse::Nothing + } else if let Some(client_id) = context.client_id { + RenderResponse::Client(client_id) + } else { + RenderResponse::Slots(self.slots.keys().copied().collect()) + } + } + + fn update_client( + &mut self, + event: &Event, + client_id: ClientId, + slot_id: Option, + ) -> bool { + let client = self.clients.entry(client_id).or_default(); + let empty_slot_client = SlotClientState::default(); + let slot_client = slot_id + .and_then(|slot_id| self.slot_clients.get(&(slot_id, client_id))) + .unwrap_or(&empty_slot_client); let mut should_render = false; match event { - Event::InitialKeybinds(keybinds) => { - self.cached_keybinds = keybinds; - if !self.cached_keybinds.is_empty() { - self.mode_info.keybinds = self.cached_keybinds.clone(); - } + Event::InitialKeybinds(_) => { should_render = true; }, - Event::ModeUpdate(mut mode_info) => { - if mode_info.keybinds.is_empty() && !self.cached_keybinds.is_empty() { - mode_info.keybinds = self.cached_keybinds.clone(); - } else if !mode_info.keybinds.is_empty() { - self.cached_keybinds = mode_info.keybinds.clone(); - } - if self.mode_info != mode_info { + Event::ModeUpdate(mode_info) => { + if &client.mode_info != mode_info { should_render = true; } - self.mode_info = mode_info; - self.base_mode_is_locked = self.mode_info.base_mode == Some(InputMode::Locked); + client.mode_info = mode_info.clone(); + client.base_mode_is_locked = client.mode_info.base_mode == Some(InputMode::Locked); }, Event::TabUpdate(tabs) => { - if self.tabs != tabs { + if &client.tabs != tabs { should_render = true; } - self.tabs = tabs; + client.tabs = tabs.clone(); }, Event::CopyToClipboard(copy_destination) => { - match self.text_copy_destination { + let copy_destination = *copy_destination; + match client.text_copy_destination { Some(text_copy_destination) => { if text_copy_destination != copy_destination { should_render = true; @@ -289,38 +376,40 @@ impl ZellijPlugin for State { should_render = true; }, } - self.text_copy_destination = Some(copy_destination); + client.text_copy_destination = Some(copy_destination); }, Event::SystemClipboardFailure => { should_render = true; - self.display_system_clipboard_failure = true; + client.display_system_clipboard_failure = true; }, Event::InputReceived => { - if self.text_copy_destination.is_some() - || self.display_system_clipboard_failure == true + if client.text_copy_destination.is_some() + || client.display_system_clipboard_failure == true { should_render = true; } - self.text_copy_destination = None; - self.display_system_clipboard_failure = false; + client.text_copy_destination = None; + client.display_system_clipboard_failure = false; }, Event::Mouse(mouse_event) => match mouse_event { Mouse::LeftClick(_, col) => { - if col_in_range(self.new_pane_ribbon_range, col) { + let col = *col; + if col_in_range(slot_client.new_pane_ribbon_range, col) { new_pane(); - } else if col_in_range(self.floating_ribbon_range, col) { + } else if col_in_range(slot_client.floating_ribbon_range, col) { toggle_floating_panes(None); } }, Mouse::Hover(_, col) => { - let new_pane_hovered = col_in_range(self.new_pane_ribbon_range, col); + let col = *col; + let new_pane_hovered = col_in_range(slot_client.new_pane_ribbon_range, col); let floating_hovered = - !new_pane_hovered && col_in_range(self.floating_ribbon_range, col); - if self.new_pane_ribbon_hovered != new_pane_hovered - || self.floating_ribbon_hovered != floating_hovered + !new_pane_hovered && col_in_range(slot_client.floating_ribbon_range, col); + if client.new_pane_ribbon_hovered != new_pane_hovered + || client.floating_ribbon_hovered != floating_hovered { - self.new_pane_ribbon_hovered = new_pane_hovered; - self.floating_ribbon_hovered = floating_hovered; + client.new_pane_ribbon_hovered = new_pane_hovered; + client.floating_ribbon_hovered = floating_hovered; should_render = true; } }, @@ -331,50 +420,77 @@ impl ZellijPlugin for State { should_render } - fn render(&mut self, rows: usize, cols: usize) { - let supports_arrow_fonts = !self.mode_info.capabilities.arrow_fonts; + pub fn render( + &mut self, + rows: usize, + cols: usize, + slot_id: SlotId, + client_id: ClientId, + keybinds: &mut KeybindStore, + ) { + let lent = self.swap_keybinds(client_id, keybinds); + self.render_client(rows, cols, slot_id, client_id); + if lent { + self.swap_keybinds(client_id, keybinds); + } + } + + fn swap_keybinds(&mut self, client_id: ClientId, keybinds: &mut KeybindStore) -> bool { + match self.clients.get_mut(&client_id) { + Some(client) => keybinds.swap(client_id, &mut client.mode_info.keybinds), + None => false, + } + } + + fn render_client(&mut self, rows: usize, cols: usize, slot_id: SlotId, client_id: ClientId) { + let Some(client) = self.clients.get(&client_id) else { + return; + }; + let Some(slot) = self.slots.get(&slot_id) else { + return; + }; + let supports_arrow_fonts = !client.mode_info.capabilities.arrow_fonts; let separator = if supports_arrow_fonts { ARROW_SEPARATOR } else { "" }; - let background = self.mode_info.style.colors.text_unselected.background; + let background = client.mode_info.style.colors.text_unselected.background; - if rows == 1 && !self.classic_ui { + if rows == 1 && !slot.classic_ui { let fill_bg = match background { PaletteColor::Rgb((r, g, b)) => format!("\u{1b}[48;2;{};{};{}m\u{1b}[0K", r, g, b), PaletteColor::EightBit(color) => format!("\u{1b}[48;5;{}m\u{1b}[0K", color), }; - let active_tab = self.tabs.iter().find(|t| t.active); + let active_tab = client.tabs.iter().find(|t| t.active); let (line, new_pane_ribbon_range, floating_ribbon_range) = one_line_ui( - &self.mode_info, + &client.mode_info, active_tab, cols, separator, - self.base_mode_is_locked, - self.text_copy_destination, - self.display_system_clipboard_failure, - self.new_pane_ribbon_hovered, - self.floating_ribbon_hovered, + client.base_mode_is_locked, + client.text_copy_destination, + client.display_system_clipboard_failure, + client.new_pane_ribbon_hovered, + client.floating_ribbon_hovered, ); - self.new_pane_ribbon_range = new_pane_ribbon_range; - self.floating_ribbon_range = floating_ribbon_range; + let slot_client = self.slot_clients.entry((slot_id, client_id)).or_default(); + slot_client.new_pane_ribbon_range = new_pane_ribbon_range; + slot_client.floating_ribbon_range = floating_ribbon_range; print!("{}{}", line, fill_bg); return; } - self.new_pane_ribbon_range = None; - self.floating_ribbon_range = None; + let slot_client = self.slot_clients.entry((slot_id, client_id)).or_default(); + slot_client.new_pane_ribbon_range = None; + slot_client.floating_ribbon_range = None; - //TODO: Switch to UI components here - let active_tab = self.tabs.iter().find(|t| t.active); - let first_line = first_line(&self.mode_info, active_tab, cols, separator); - let second_line = self.second_line(cols); - let show_nested_session_hint = self.mode_info.session_dimmed.unwrap_or(false) - || self.mode_info.session_ascended.unwrap_or(false); + let active_tab = client.tabs.iter().find(|t| t.active); + let first_line = first_line(&client.mode_info, active_tab, cols, separator); + let second_line = second_line(client, &slot.tip_name, cols); + let show_nested_session_hint = client.mode_info.session_dimmed.unwrap_or(false) + || client.mode_info.session_ascended.unwrap_or(false); - // [48;5;238m is white background, [0K is so that it fills the rest of the line - // [m is background reset, [0K is so that it clears the rest of the line match background { PaletteColor::Rgb((r, g, b)) => { if rows > 1 { @@ -382,7 +498,7 @@ impl ZellijPlugin for State { } else { if show_nested_session_hint { print!("\u{1b}[m{}\u{1b}[0K", second_line); - } else if self.mode_info.mode == InputMode::Normal { + } else if client.mode_info.mode == InputMode::Normal { print!("{}\u{1b}[48;2;{};{};{}m\u{1b}[0K", first_line, r, g, b); } else { print!("\u{1b}[m{}\u{1b}[0K", second_line); @@ -395,7 +511,7 @@ impl ZellijPlugin for State { } else { if show_nested_session_hint { print!("\u{1b}[m{}\u{1b}[0K", second_line); - } else if self.mode_info.mode == InputMode::Normal { + } else if client.mode_info.mode == InputMode::Normal { print!("{}\u{1b}[48;5;{}m\u{1b}[0K", first_line, color); } else { print!("\u{1b}[m{}\u{1b}[0K", second_line); @@ -410,47 +526,45 @@ impl ZellijPlugin for State { } } -impl State { - fn second_line(&self, cols: usize) -> LinePart { - let active_tab = self.tabs.iter().find(|t| t.active); +fn second_line(client: &ClientState, tip_name: &str, cols: usize) -> LinePart { + let active_tab = client.tabs.iter().find(|t| t.active); - if self.mode_info.session_dimmed.unwrap_or(false) { - return descended_into_nested_session_hint(&self.mode_info, cols); - } - if self.mode_info.session_ascended.unwrap_or(false) { - return ascended_to_host_session_hint(&self.mode_info, cols); - } - if let Some(copy_destination) = self.text_copy_destination { - text_copied_hint(copy_destination) - } else if self.display_system_clipboard_failure { - system_clipboard_error(&self.mode_info.style.colors) - } else if let Some(active_tab) = active_tab { - if active_tab.is_fullscreen_active { - match self.mode_info.mode { - InputMode::Normal => fullscreen_panes_to_hide( - &self.mode_info.style.colors, - active_tab.panes_to_hide, - ), - InputMode::Locked => locked_fullscreen_panes_to_hide( - &self.mode_info.style.colors, - active_tab.panes_to_hide, - ), - _ => keybinds(&self.mode_info, &self.tip_name, cols), - } - } else if active_tab.are_floating_panes_visible { - match self.mode_info.mode { - InputMode::Normal => floating_panes_are_visible(&self.mode_info), - InputMode::Locked => { - locked_floating_panes_are_visible(&self.mode_info.style.colors) - }, - _ => keybinds(&self.mode_info, &self.tip_name, cols), - } - } else { - keybinds(&self.mode_info, &self.tip_name, cols) + if client.mode_info.session_dimmed.unwrap_or(false) { + return descended_into_nested_session_hint(&client.mode_info, cols); + } + if client.mode_info.session_ascended.unwrap_or(false) { + return ascended_to_host_session_hint(&client.mode_info, cols); + } + if let Some(copy_destination) = client.text_copy_destination { + text_copied_hint(copy_destination) + } else if client.display_system_clipboard_failure { + system_clipboard_error(&client.mode_info.style.colors) + } else if let Some(active_tab) = active_tab { + if active_tab.is_fullscreen_active { + match client.mode_info.mode { + InputMode::Normal => fullscreen_panes_to_hide( + &client.mode_info.style.colors, + active_tab.panes_to_hide, + ), + InputMode::Locked => locked_fullscreen_panes_to_hide( + &client.mode_info.style.colors, + active_tab.panes_to_hide, + ), + _ => keybinds(&client.mode_info, tip_name, cols), + } + } else if active_tab.are_floating_panes_visible { + match client.mode_info.mode { + InputMode::Normal => floating_panes_are_visible(&client.mode_info), + InputMode::Locked => { + locked_floating_panes_are_visible(&client.mode_info.style.colors) + }, + _ => keybinds(&client.mode_info, tip_name, cols), } } else { - LinePart::default() + keybinds(&client.mode_info, tip_name, cols) } + } else { + LinePart::default() } } @@ -468,12 +582,6 @@ pub fn get_common_modifiers(mut keyvec: Vec<&KeyWithModifier>) -> Vec)>` and contains -/// all keybindings for the current mode and one or more `p` patterns which match a sequence of -/// actions to search for. If within the keymap a sequence of actions matching `p` is found, all -/// keys that trigger the action pattern are returned as vector of `Vec`. pub fn action_key( keymap: &[(KeyWithModifier, Vec)], action: &[Action], @@ -496,9 +604,6 @@ pub fn action_key( .collect::>() } -/// Get multiple keys for multiple actions. -/// -/// An extension of [`action_key`] that iterates over all action tuples and collects the results. pub fn action_key_group( keymap: &[(KeyWithModifier, Vec)], actions: &[&[Action]], @@ -510,25 +615,6 @@ pub fn action_key_group( ret } -/// Style a vector of [`Key`]s with the given [`Palette`]. -/// -/// Creates a line segment of style ``, with correct theming applied: The brackets have the -/// regular text color, the enclosed keys are painted green and bold. If the keys share a common -/// modifier (See [`get_common_modifier`]), it is printed in front of the keys, painted green and -/// bold, separated with a `+`: `MOD + `. -/// -/// If multiple [`Key`]s are given, the individual keys are separated with a `|` char. This does -/// not apply to the following groups of keys which are treated specially and don't have a -/// separator between them: -/// -/// - "hjkl" -/// - "HJKL" -/// - "←↓↑→" -/// - "←→" -/// - "↓↑" -/// -/// The returned Vector of [`ANSIString`] is suitable for transformation into an [`ANSIStrings`] -/// type. pub fn style_key_with_modifier( keyvec: &[KeyWithModifier], palette: &Styling, @@ -567,7 +653,6 @@ pub fn style_key_with_modifier( }; ret.push(painted_modifier); - // Prints key group start let group_start_str = if no_common_modifier { "<" } else { " + <" }; if let Some(background) = background { let background = palette_match!(background); @@ -581,7 +666,6 @@ pub fn style_key_with_modifier( ret.push(Style::new().fg(text_color).paint(group_start_str)); } - // Prints the keys let key = keyvec .iter() .map(|key| { @@ -604,7 +688,6 @@ pub fn style_key_with_modifier( }) .collect::>(); - // Special handling of some pre-defined keygroups let key_string = key.join(""); let key_separator = match &key_string[..] { "HJKL" => "", @@ -728,7 +811,7 @@ pub mod tests { KeyWithModifier::new(BareKey::Char('z')).with_alt_modifier(), ]; let ret = get_common_modifiers(keyvec.iter().collect()); - assert_eq!(ret, vec![]); // no common modifiers + assert_eq!(ret, vec![]); } #[test] @@ -739,7 +822,7 @@ pub mod tests { KeyWithModifier::new(BareKey::Char('z')).with_alt_modifier(), ]; let ret = get_common_modifiers(keyvec.iter().collect()); - assert_eq!(ret, vec![]); // no common modifiers + assert_eq!(ret, vec![]); } #[test] @@ -791,7 +874,6 @@ pub mod tests { fn action_key_group_two_patterns() { let keymap = big_keymap(); let ret = action_key_group(&keymap, &[&[Action::ScrollDown], &[Action::ScrollUp]]); - // Mind the order! assert_eq!( ret, vec![ diff --git a/default-plugins/status-bar/src/one_line_ui.rs b/default-plugins/bars/src/status_bar/one_line_ui.rs similarity index 97% rename from default-plugins/status-bar/src/one_line_ui.rs rename to default-plugins/bars/src/status_bar/one_line_ui.rs index 63d3a7851..9087dbf09 100644 --- a/default-plugins/status-bar/src/one_line_ui.rs +++ b/default-plugins/bars/src/status_bar/one_line_ui.rs @@ -8,13 +8,13 @@ use zellij_tile::prelude::actions::Action; use zellij_tile::prelude::*; use zellij_tile_utils::{palette_match, style}; -use crate::first_line::{to_char, KeyAction, KeyMode, KeyShortcut}; -use crate::second_line::{ +use crate::status_bar::first_line::{to_char, KeyAction, KeyMode, KeyShortcut}; +use crate::status_bar::second_line::{ ascended_to_host_session_hint, descended_into_nested_session_hint, system_clipboard_error, text_copied_hint, }; -use crate::{action_key, action_key_group, color_elements, MORE_MSG, TO_NORMAL}; -use crate::{ColoredElements, LinePart}; +use crate::status_bar::{action_key, action_key_group, color_elements, MORE_MSG, TO_NORMAL}; +use crate::status_bar::{ColoredElements, LinePart}; use unicode_width::UnicodeWidthStr; pub fn one_line_ui( @@ -739,7 +739,6 @@ fn render_common_modifiers( separator: &str, ) { let prefix_text = if mode_info.capabilities.arrow_fonts { - // Add extra space in simplified ui format!( " {} + ", common_modifiers @@ -787,7 +786,7 @@ fn render_secondary_info( let (secondary_keybinds, new_pane_range, floating_range) = secondary_keybinds(&help, tab_info, max_len, new_pane_hovered, floating_hovered); secondary_info.append(&secondary_keybinds); - let remaining_space = max_len.saturating_sub(secondary_info.len).saturating_sub(1); // 1 for the end padding of the line + let remaining_space = max_len.saturating_sub(secondary_info.len).saturating_sub(1); let mut padding = String::new(); let mut padding_len = 0; for _ in 0..remaining_space { @@ -830,7 +829,6 @@ fn secondary_keybinds( let mut floating_range; let binds = &help.get_mode_keybinds(); let should_show_focus_and_resize_shortcuts = should_show_focus_and_resize_shortcuts(tab_info); - // New Pane let new_pane_action_key = action_key( binds, &[Action::NewPane { @@ -850,7 +848,6 @@ fn secondary_keybinds( vec![] }; - // Resize let resize_increase_action_key = action_key( binds, &[Action::Resize { @@ -881,10 +878,8 @@ fn secondary_keybinds( resize_shortcuts.push(resize_decrease_key.clone()); } - // Move focus let mut move_focus_shortcuts: Vec = vec![]; - // Left let move_focus_left_action_key = action_key( binds, &[Action::MoveFocusOrTab { @@ -898,7 +893,6 @@ fn secondary_keybinds( if let Some(move_focus_left_key) = move_focus_left_key { move_focus_shortcuts.push(move_focus_left_key.clone()); } - // Down let move_focus_left_action_key = action_key( binds, &[Action::MoveFocus { @@ -912,7 +906,6 @@ fn secondary_keybinds( if let Some(move_focus_left_key) = move_focus_left_key { move_focus_shortcuts.push(move_focus_left_key.clone()); } - // Up let move_focus_left_action_key = action_key( binds, &[Action::MoveFocus { @@ -926,7 +919,6 @@ fn secondary_keybinds( if let Some(move_focus_left_key) = move_focus_left_key { move_focus_shortcuts.push(move_focus_left_key.clone()); } - // Right let move_focus_left_action_key = action_key( binds, &[Action::MoveFocusOrTab { @@ -1293,7 +1285,7 @@ fn hovered_ribbon_wrap(body: String, palette: Styling, supports_arrow_fonts: boo let ribbon_bg = palette.ribbon_unselected.emphasis_1; let outer_bg = palette.text_unselected.background; let arrow = if supports_arrow_fonts { - crate::ARROW_SEPARATOR + crate::status_bar::ARROW_SEPARATOR } else { "" }; @@ -1401,8 +1393,7 @@ fn add_shortcut( return ret; } - ret.append(&style_key_with_modifier(&keys, key_color_index, dimmed)); // TODO: alternate - // + ret.append(&style_key_with_modifier(&keys, key_color_index, dimmed)); let ribbon = if dimmed { serialize_ribbon(&Text::new(format!("{}", text)).disabled()) } else if selected { @@ -1413,9 +1404,9 @@ fn add_shortcut( ret.part = format!("{}{}", ret.part, ribbon); let supports_arrow_fonts = !help.capabilities.arrow_fonts; ret.len += if supports_arrow_fonts { - text.width() + 4 // padding and arrow fonts + text.width() + 4 } else { - text.width() + 2 // padding + text.width() + 2 }; ret } @@ -1475,9 +1466,9 @@ fn add_shortcut_with_inline_key( ret.part = ribbon; let supports_arrow_fonts = !capabilities.arrow_fonts; ret.len += if supports_arrow_fonts { - text.width() + key_string.width() + 7 // padding, group boundaries and arrow fonts + text.width() + key_string.width() + 7 } else { - text.width() + key_string.width() + 5 // padding and group boundaries + text.width() + key_string.width() + 5 }; ret @@ -1515,9 +1506,9 @@ fn add_shortcut_with_key_only( ret.part = ribbon; let supports_arrow_fonts = !help.capabilities.arrow_fonts; ret.len += if supports_arrow_fonts { - key_string.width() + 4 // 4 => arrow fonts + padding + key_string.width() + 4 } else { - key_string.width() + 2 // 2 => padding + key_string.width() + 2 }; ret } @@ -1525,7 +1516,7 @@ fn add_shortcut_with_key_only( fn add_keygroup_separator(help: &ModeInfo, max_len: usize) -> Option { let supports_arrow_fonts = !help.capabilities.arrow_fonts; let separator = if supports_arrow_fonts { - crate::ARROW_SEPARATOR + crate::status_bar::ARROW_SEPARATOR } else { " " }; @@ -1582,7 +1573,7 @@ fn add_keygroup_separator(help: &ModeInfo, max_len: usize) -> Option { .bold() .paint(format!(" {} ", mode_help_text)), ); - ret.len += mode_help_text.width() + 2; // 2 => padding + ret.len += mode_help_text.width() + 2; } bits.push( Style::new() @@ -1606,7 +1597,7 @@ fn add_keygroup_separator(help: &ModeInfo, max_len: usize) -> Option { .paint(format!("{}", separator)), ); ret.part = format!("{}{}", ret.part, ANSIStrings(&bits)); - ret.len += 3; // padding and arrow fonts + ret.len += 3; if ret.len <= max_len { Some(ret) @@ -1644,27 +1635,20 @@ fn get_keys_and_hints(mi: &ModeInfo) -> Vec<(String, String, Vec> = vec![]; let mut km = vec![]; for (key, acvec) in old_keymap { if known_actions.contains(&acvec) { - // This action is known already continue; } else { known_actions.push(acvec.to_vec()); @@ -1689,12 +1673,6 @@ fn get_keys_and_hints(mi: &ModeInfo) -> Vec<(String, String, Vec = action_key_group(&old_keymap, &[&[A::GoToPreviousTab], &[A::GoToNextTab]]); @@ -2007,7 +1985,6 @@ fn style_key_with_modifier( .collect::>() .join("-"); - // Prints the keys let key = keyvec .iter() .map(|key| { @@ -2019,7 +1996,6 @@ fn style_key_with_modifier( }) .collect::>(); - // Special handling of some pre-defined keygroups let key_string = key.join(""); let key_separator = match &key_string[..] { "HJKL" => "", diff --git a/default-plugins/status-bar/src/second_line.rs b/default-plugins/bars/src/status_bar/second_line.rs similarity index 90% rename from default-plugins/status-bar/src/second_line.rs rename to default-plugins/bars/src/status_bar/second_line.rs index 63f845a65..1231c28d2 100644 --- a/default-plugins/status-bar/src/second_line.rs +++ b/default-plugins/bars/src/status_bar/second_line.rs @@ -7,7 +7,7 @@ use zellij_tile::prelude::actions::Action; use zellij_tile::prelude::*; use zellij_tile_utils::palette_match; -use crate::{ +use crate::status_bar::{ action_key, action_key_group, style_key_with_modifier, tip::{data::TIPS, TipFn}, LinePart, MORE_MSG, TO_NORMAL, @@ -81,29 +81,6 @@ fn full_shortcut_list_nonstandard_mode(help: &ModeInfo) -> LinePart { line_part } -/// Collect all relevant keybindings and hints to display. -/// -/// Creates a vector with tuples containing the following entries: -/// -/// - A String to display for this keybinding when there are no size restrictions, -/// - A shortened String (where sensible) to display if the whole second line becomes too long, -/// - A `Vec` of the keys that map to this keyhint -/// -/// This vector is created by iterating over the keybindings for the current [`InputMode`] and -/// storing all Keybindings that match pre-defined patterns of `Action`s. For example, the -/// `InputMode::Pane` input mode determines which keys to display for the "Move focus" hint by -/// searching the keybindings for anything that matches the `Action::MoveFocus(_)` action. Since by -/// default multiple keybindings map to some action patterns (e.g. `Action::MoveFocus(_)` is bound -/// to "hjkl", the arrow keys and "Alt + "), we deduplicate the vector of all keybindings -/// before processing it. -/// -/// Therefore we sort it by the [`Key`]s of the current keymap and deduplicate the resulting sorted -/// vector by the `Vec` action vectors bound to the keys. As such, when multiple keys map -/// to the same sequence of actions, the keys that appear first in the [`Key`] structure will be -/// displayed. -// Please don't let rustfmt play with the formatting. It will stretch out the function to about -// three times the length and all the keybinding vectors we generate become virtually unreadable -// for humans. #[rustfmt::skip] fn get_keys_and_hints(mi: &ModeInfo) -> Vec<(String, String, Vec)> { use Action as A; @@ -115,26 +92,19 @@ fn get_keys_and_hints(mi: &ModeInfo) -> Vec<(String, String, Vec> = vec![]; let mut km = vec![]; for (key, acvec) in old_keymap { if known_actions.contains(&acvec) { - // This action is known already continue; } else { known_actions.push(acvec.to_vec()); @@ -156,12 +126,6 @@ fn get_keys_and_hints(mi: &ModeInfo) -> Vec<(String, String, Vec = action_key_group(&old_keymap, &[&[A::GoToPreviousTab], &[A::GoToNextTab]]); @@ -254,7 +218,7 @@ fn get_keys_and_hints(mi: &ModeInfo) -> Vec<(String, String, Vec Lin } pub fn keybinds(help: &ModeInfo, tip_name: &str, max_width: usize) -> LinePart { - // It is assumed that there is at least one TIP data in the TIPS HasMap. let tip_body = TIPS .get(tip_name) .unwrap_or_else(|| TIPS.get("quicknav").unwrap()); @@ -427,7 +390,7 @@ pub fn text_copied_hint(copy_destination: CopyDestination) -> LinePart { CopyDestination::Command => "Text piped to external command", #[cfg(not(target_os = "macos"))] CopyDestination::Primary => "Text copied to system primary selection", - #[cfg(target_os = "macos")] // primary selection does not exist on macos + #[cfg(target_os = "macos")] CopyDestination::Primary => "Text copied to system clipboard", CopyDestination::System => "Text copied to system clipboard", }; @@ -460,7 +423,7 @@ pub fn fullscreen_panes_to_hide(palette: &Styling, panes_to_hide: usize) -> Line + puls.chars().count() + panes.chars().count() + hide.chars().count() - + 5; // 3 for ():'s around shortcut, 2 for the space + + 5; LinePart { part: format!( "{}{}{}{}{}{}", @@ -518,7 +481,7 @@ pub fn floating_panes_are_visible(mode_info: &ModeInfo) -> LinePart { + p.chars().count() + p_right_separator.chars().count() + to_hide.chars().count() - + 5; // 3 for ():'s around floating_panes, 2 for the space + + 5; LinePart { part: format!( "{}{}{}{}{}{}{}{}{}{}", @@ -553,7 +516,7 @@ pub fn locked_fullscreen_panes_to_hide(palette: &Styling, panes_to_hide: usize) + puls.chars().count() + panes.chars().count() + hide.chars().count() - + 5; // 3 for ():'s around shortcut, 2 for the space + + 5; LinePart { part: format!( "{}{}{}{}{}{}{}", @@ -591,16 +554,9 @@ pub fn locked_floating_panes_are_visible(palette: &Styling) -> LinePart { } #[cfg(test)] -/// Unit tests. -/// -/// Note that we cheat a little here, because the number of things one may want to test is endless, -/// and creating a Mockup of [`ModeInfo`] by hand for all these testcases is nothing less than -/// torture. Hence, we test the most atomic unit thoroughly ([`full_length_shortcut`] and then test -/// the public API ([`keybinds`]) to ensure correct operation. mod tests { use super::*; - // Strip style information from `LinePart` and return a raw String instead fn unstyle(line_part: LinePart) -> String { let string = line_part.to_string(); @@ -633,7 +589,6 @@ mod tests { } #[test] - // When there is no binding, we print no shortcut either fn full_length_shortcut_without_key() { let keyvec = vec![]; let palette = Styling::default(); @@ -732,10 +687,8 @@ mod tests { assert_eq!(ret, " / Ctrl + Foobar"); } - //pub fn keybinds(help: &ModeInfo, tip_name: &str, max_width: usize) -> LinePart { #[test] - // Note how it leaves out elements that don't exist! fn keybinds_wide() { let mode_info = ModeInfo { mode: InputMode::Pane, @@ -800,7 +753,6 @@ mod tests { } #[test] - // Note how "Move focus" becomes "Move" fn keybinds_tight_width() { let mode_info = ModeInfo { mode: InputMode::Pane, diff --git a/default-plugins/status-bar/src/tip/cache.rs b/default-plugins/bars/src/status_bar/tip/cache.rs similarity index 97% rename from default-plugins/status-bar/src/tip/cache.rs rename to default-plugins/bars/src/status_bar/tip/cache.rs index e62e658d9..f3a9c76c0 100644 --- a/default-plugins/status-bar/src/tip/cache.rs +++ b/default-plugins/bars/src/status_bar/tip/cache.rs @@ -24,13 +24,10 @@ pub type LocalCacheResult = Result; #[derive(Error, Debug)] pub enum LocalCacheError { - // Io error #[error("IoError: {0}")] Io(#[from] io::Error), - // Io error with path context #[error("IoError: {0}, File: {1}")] IoPath(io::Error, PathBuf), - // Deserialization error #[error("Deserialization error: {0}")] Serde(#[from] serde_json::Error), } diff --git a/default-plugins/bars/src/status_bar/tip/consts.rs b/default-plugins/bars/src/status_bar/tip/consts.rs new file mode 100644 index 000000000..380716781 --- /dev/null +++ b/default-plugins/bars/src/status_bar/tip/consts.rs @@ -0,0 +1,2 @@ +pub const DEFAULT_CACHE_FILE_PATH: &str = "/tmp/status-bar-tips.cache"; +pub const MAX_CACHE_HITS: usize = 20; diff --git a/default-plugins/status-bar/src/tip/data/compact_layout.rs b/default-plugins/bars/src/status_bar/tip/data/compact_layout.rs similarity index 86% rename from default-plugins/status-bar/src/tip/data/compact_layout.rs rename to default-plugins/bars/src/status_bar/tip/data/compact_layout.rs index 14881174a..372d76582 100644 --- a/default-plugins/status-bar/src/tip/data/compact_layout.rs +++ b/default-plugins/bars/src/status_bar/tip/data/compact_layout.rs @@ -4,8 +4,8 @@ use ansi_term::{ Style, }; -use crate::LinePart; -use crate::{action_key, style_key_with_modifier}; +use crate::status_bar::LinePart; +use crate::status_bar::{action_key, style_key_with_modifier}; use zellij_tile::prelude::{actions::Action, *}; use zellij_tile_utils::palette_match; @@ -23,8 +23,6 @@ macro_rules! strings { } pub fn compact_layout_full(help: &ModeInfo) -> LinePart { - // Tip: UI taking up too much space? Start Zellij with - // zellij -l compact or remove pane frames with Ctrl +

+ let green_color = palette_match!(help.style.colors.text_unselected.emphasis_0); let mut bits = vec![ @@ -41,8 +39,6 @@ pub fn compact_layout_full(help: &ModeInfo) -> LinePart { } pub fn compact_layout_medium(help: &ModeInfo) -> LinePart { - // Tip: To save screen space, start Zellij with - // zellij -l compact or remove pane frames with Ctrl +

+ let green_color = palette_match!(help.style.colors.text_unselected.emphasis_0); let mut bits = vec![ @@ -59,8 +55,6 @@ pub fn compact_layout_medium(help: &ModeInfo) -> LinePart { } pub fn compact_layout_short(help: &ModeInfo) -> LinePart { - // Save screen space, start Zellij with - // zellij -l compact or remove pane frames with Ctrl +

+ let green_color = palette_match!(help.style.colors.text_unselected.emphasis_0); let mut bits = vec![ diff --git a/default-plugins/status-bar/src/tip/data/edit_scrollbuffer.rs b/default-plugins/bars/src/status_bar/tip/data/edit_scrollbuffer.rs similarity index 89% rename from default-plugins/status-bar/src/tip/data/edit_scrollbuffer.rs rename to default-plugins/bars/src/status_bar/tip/data/edit_scrollbuffer.rs index 55905e432..3a88a7dec 100644 --- a/default-plugins/status-bar/src/tip/data/edit_scrollbuffer.rs +++ b/default-plugins/bars/src/status_bar/tip/data/edit_scrollbuffer.rs @@ -4,7 +4,7 @@ use ansi_term::{ Style, }; -use crate::{action_key, style_key_with_modifier, LinePart}; +use crate::status_bar::{action_key, style_key_with_modifier, LinePart}; use zellij_tile::prelude::{actions::Action, *}; use zellij_tile_utils::palette_match; @@ -22,8 +22,6 @@ macro_rules! strings { } pub fn edit_scrollbuffer_full(help: &ModeInfo) -> LinePart { - // Tip: Search through the scrollbuffer using your default $EDITOR with - // Ctrl + + let green_color = palette_match!(help.style.colors.text_unselected.emphasis_0); let mut bits = vec![ @@ -37,8 +35,6 @@ pub fn edit_scrollbuffer_full(help: &ModeInfo) -> LinePart { } pub fn edit_scrollbuffer_medium(help: &ModeInfo) -> LinePart { - // Tip: Search the scrollbuffer using your $EDITOR with - // Ctrl + + let green_color = palette_match!(help.style.colors.text_unselected.emphasis_0); let mut bits = vec![ @@ -52,8 +48,6 @@ pub fn edit_scrollbuffer_medium(help: &ModeInfo) -> LinePart { } pub fn edit_scrollbuffer_short(help: &ModeInfo) -> LinePart { - // Search using $EDITOR with - // Ctrl + + let green_color = palette_match!(help.style.colors.text_unselected.emphasis_0); let mut bits = vec![ diff --git a/default-plugins/status-bar/src/tip/data/floating_panes_mouse.rs b/default-plugins/bars/src/status_bar/tip/data/floating_panes_mouse.rs similarity index 88% rename from default-plugins/status-bar/src/tip/data/floating_panes_mouse.rs rename to default-plugins/bars/src/status_bar/tip/data/floating_panes_mouse.rs index 24124be60..af5f16c00 100644 --- a/default-plugins/status-bar/src/tip/data/floating_panes_mouse.rs +++ b/default-plugins/bars/src/status_bar/tip/data/floating_panes_mouse.rs @@ -1,6 +1,6 @@ use ansi_term::{unstyled_len, ANSIString, ANSIStrings, Style}; -use crate::{action_key, style_key_with_modifier, LinePart}; +use crate::status_bar::{action_key, style_key_with_modifier, LinePart}; use zellij_tile::prelude::{actions::Action, *}; macro_rules! strings { @@ -17,7 +17,6 @@ macro_rules! strings { } pub fn floating_panes_mouse_full(help: &ModeInfo) -> LinePart { - // Tip: Toggle floating panes with Ctrl +

+ and move them with keyboard or mouse let mut bits = vec![ Style::new().paint(" Tip: "), Style::new().paint("Toggle floating panes with "), @@ -28,7 +27,6 @@ pub fn floating_panes_mouse_full(help: &ModeInfo) -> LinePart { } pub fn floating_panes_mouse_medium(help: &ModeInfo) -> LinePart { - // Tip: Toggle floating panes with Ctrl +

+ let mut bits = vec![ Style::new().paint(" Tip: "), Style::new().paint("Toggle floating panes with "), @@ -38,7 +36,6 @@ pub fn floating_panes_mouse_medium(help: &ModeInfo) -> LinePart { } pub fn floating_panes_mouse_short(help: &ModeInfo) -> LinePart { - // Ctrl +

+ => floating panes let mut bits = add_keybinds(help); bits.push(Style::new().paint(" => floating panes")); strings!(&bits) diff --git a/default-plugins/status-bar/src/tip/data/mod.rs b/default-plugins/bars/src/status_bar/tip/data/mod.rs similarity index 98% rename from default-plugins/status-bar/src/tip/data/mod.rs rename to default-plugins/bars/src/status_bar/tip/data/mod.rs index 731fa7772..4f882baaa 100644 --- a/default-plugins/status-bar/src/tip/data/mod.rs +++ b/default-plugins/bars/src/status_bar/tip/data/mod.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use lazy_static::lazy_static; -use crate::tip::TipBody; +use crate::status_bar::tip::TipBody; mod compact_layout; mod edit_scrollbuffer; diff --git a/default-plugins/status-bar/src/tip/data/move_focus_hjkl_tab_switch.rs b/default-plugins/bars/src/status_bar/tip/data/move_focus_hjkl_tab_switch.rs similarity index 87% rename from default-plugins/status-bar/src/tip/data/move_focus_hjkl_tab_switch.rs rename to default-plugins/bars/src/status_bar/tip/data/move_focus_hjkl_tab_switch.rs index 5815a6382..98e13768a 100644 --- a/default-plugins/status-bar/src/tip/data/move_focus_hjkl_tab_switch.rs +++ b/default-plugins/bars/src/status_bar/tip/data/move_focus_hjkl_tab_switch.rs @@ -1,6 +1,6 @@ use ansi_term::{unstyled_len, ANSIString, ANSIStrings, Style}; -use crate::{action_key_group, style_key_with_modifier, LinePart}; +use crate::status_bar::{action_key_group, style_key_with_modifier, LinePart}; use zellij_tile::prelude::{actions::Action, *}; macro_rules! strings { @@ -17,7 +17,6 @@ macro_rules! strings { } pub fn move_focus_hjkl_tab_switch_full(help: &ModeInfo) -> LinePart { - // Tip: When changing focus with Alt + <←↓↑→> moving off screen left/right focuses the next tab. let mut bits = vec![ Style::new().paint(" Tip: "), Style::new().paint("When changing focus with "), @@ -28,7 +27,6 @@ pub fn move_focus_hjkl_tab_switch_full(help: &ModeInfo) -> LinePart { } pub fn move_focus_hjkl_tab_switch_medium(help: &ModeInfo) -> LinePart { - // Tip: Changing focus with Alt + <←↓↑→> off screen focuses the next tab. let mut bits = vec![ Style::new().paint(" Tip: "), Style::new().paint("Changing focus with "), @@ -39,7 +37,6 @@ pub fn move_focus_hjkl_tab_switch_medium(help: &ModeInfo) -> LinePart { } pub fn move_focus_hjkl_tab_switch_short(help: &ModeInfo) -> LinePart { - // Alt + <←↓↑→> off screen edge focuses next tab. let mut bits = add_keybinds(help); bits.push(Style::new().paint(" off screen edge focuses next tab.")); strings!(&bits) @@ -59,7 +56,6 @@ fn add_keybinds<'a>(help: &'a ModeInfo) -> Vec> { ], ); - // Let's see if we have some pretty groups in common here let mut arrows = vec![]; let mut letters = vec![]; for key in move_focus_keys.into_iter() { diff --git a/default-plugins/status-bar/src/tip/data/move_tabs.rs b/default-plugins/bars/src/status_bar/tip/data/move_tabs.rs similarity index 86% rename from default-plugins/status-bar/src/tip/data/move_tabs.rs rename to default-plugins/bars/src/status_bar/tip/data/move_tabs.rs index 1679b6b0c..d4d0e5510 100644 --- a/default-plugins/status-bar/src/tip/data/move_tabs.rs +++ b/default-plugins/bars/src/status_bar/tip/data/move_tabs.rs @@ -7,7 +7,7 @@ use ansi_term::{ use zellij_tile::prelude::*; use zellij_tile_utils::palette_match; -use crate::LinePart; +use crate::status_bar::LinePart; macro_rules! strings { ($ANSIStrings:expr) => {{ @@ -23,8 +23,6 @@ macro_rules! strings { } pub fn move_tabs_full(help: &ModeInfo) -> LinePart { - // Tip: Wrong order of tabs? You can move them to left and right with: - // Alt + i (left) and Alt + o (right) let green_color = palette_match!(help.style.colors.text_unselected.emphasis_2); let bits = vec![ @@ -39,8 +37,6 @@ pub fn move_tabs_full(help: &ModeInfo) -> LinePart { } pub fn move_tabs_medium(help: &ModeInfo) -> LinePart { - // Tip: You can move tabs to left and right with: - // Alt + i (left) and Alt + o (right) let green_color = palette_match!(help.style.colors.text_unselected.emphasis_2); let bits = vec![ @@ -55,7 +51,6 @@ pub fn move_tabs_medium(help: &ModeInfo) -> LinePart { } pub fn move_tabs_short(help: &ModeInfo) -> LinePart { - // Move tabs with: Alt + i (left) and Alt + o (right) let green_color = palette_match!(help.style.colors.text_unselected.emphasis_2); let bits = vec![ diff --git a/default-plugins/status-bar/src/tip/data/quicknav.rs b/default-plugins/bars/src/status_bar/tip/data/quicknav.rs similarity index 97% rename from default-plugins/status-bar/src/tip/data/quicknav.rs rename to default-plugins/bars/src/status_bar/tip/data/quicknav.rs index 367417422..1edaa8c63 100644 --- a/default-plugins/status-bar/src/tip/data/quicknav.rs +++ b/default-plugins/bars/src/status_bar/tip/data/quicknav.rs @@ -1,6 +1,6 @@ use ansi_term::{unstyled_len, ANSIString, ANSIStrings, Style}; -use crate::{action_key, action_key_group, style_key_with_modifier, LinePart}; +use crate::status_bar::{action_key, action_key_group, style_key_with_modifier, LinePart}; use zellij_tile::prelude::{actions::Action, *}; macro_rules! strings { @@ -123,7 +123,6 @@ fn add_keybinds<'a>(help: &'a ModeInfo) -> Keygroups<'a> { }], ], ); - // Let's see if we have some pretty groups in common here let mut arrows = vec![]; let mut letters = vec![]; for key in move_focus_keys.into_iter() { diff --git a/default-plugins/status-bar/src/tip/data/send_mouse_click_to_terminal.rs b/default-plugins/bars/src/status_bar/tip/data/send_mouse_click_to_terminal.rs similarity index 88% rename from default-plugins/status-bar/src/tip/data/send_mouse_click_to_terminal.rs rename to default-plugins/bars/src/status_bar/tip/data/send_mouse_click_to_terminal.rs index 62639b304..a7dfdb321 100644 --- a/default-plugins/status-bar/src/tip/data/send_mouse_click_to_terminal.rs +++ b/default-plugins/bars/src/status_bar/tip/data/send_mouse_click_to_terminal.rs @@ -4,7 +4,7 @@ use ansi_term::{ Style, }; -use crate::LinePart; +use crate::status_bar::LinePart; use zellij_tile::prelude::*; use zellij_tile_utils::palette_match; @@ -22,7 +22,6 @@ macro_rules! strings { } pub fn mouse_click_to_terminal_full(help: &ModeInfo) -> LinePart { - // Tip: SHIFT + bypasses Zellij and sends the mouse click directly to the terminal let green_color = palette_match!(help.style.colors.text_unselected.emphasis_2); let orange_color = palette_match!(help.style.colors.text_unselected.emphasis_0); @@ -36,7 +35,6 @@ pub fn mouse_click_to_terminal_full(help: &ModeInfo) -> LinePart { } pub fn mouse_click_to_terminal_medium(help: &ModeInfo) -> LinePart { - // Tip: SHIFT + sends the click directly to the terminal let green_color = palette_match!(help.style.colors.text_unselected.emphasis_2); let orange_color = palette_match!(help.style.colors.text_unselected.emphasis_0); strings!(&[ @@ -49,7 +47,6 @@ pub fn mouse_click_to_terminal_medium(help: &ModeInfo) -> LinePart { } pub fn mouse_click_to_terminal_short(help: &ModeInfo) -> LinePart { - // Tip: SHIFT + => sends click to terminal. let green_color = palette_match!(help.style.colors.text_unselected.emphasis_2); let orange_color = palette_match!(help.style.colors.text_unselected.emphasis_0); diff --git a/default-plugins/status-bar/src/tip/data/sync_tab.rs b/default-plugins/bars/src/status_bar/tip/data/sync_tab.rs similarity index 88% rename from default-plugins/status-bar/src/tip/data/sync_tab.rs rename to default-plugins/bars/src/status_bar/tip/data/sync_tab.rs index 2413a69d4..8195848dd 100644 --- a/default-plugins/status-bar/src/tip/data/sync_tab.rs +++ b/default-plugins/bars/src/status_bar/tip/data/sync_tab.rs @@ -1,6 +1,6 @@ use ansi_term::{unstyled_len, ANSIString, ANSIStrings, Style}; -use crate::{action_key, style_key_with_modifier, LinePart}; +use crate::status_bar::{action_key, style_key_with_modifier, LinePart}; use zellij_tile::prelude::{actions::Action, *}; macro_rules! strings { @@ -17,7 +17,6 @@ macro_rules! strings { } pub fn sync_tab_full(help: &ModeInfo) -> LinePart { - // Tip: Sync a tab and write keyboard input to all panes with Ctrl + + let mut bits = vec![ Style::new().paint(" Tip: "), Style::new().paint("Sync a tab and write keyboard input to all its panes with "), @@ -27,7 +26,6 @@ pub fn sync_tab_full(help: &ModeInfo) -> LinePart { } pub fn sync_tab_medium(help: &ModeInfo) -> LinePart { - // Tip: Sync input to panes in a tab with Ctrl + + let mut bits = vec![ Style::new().paint(" Tip: "), Style::new().paint("Sync input to panes in a tab with "), @@ -37,7 +35,6 @@ pub fn sync_tab_medium(help: &ModeInfo) -> LinePart { } pub fn sync_tab_short(help: &ModeInfo) -> LinePart { - // Sync input in a tab with Ctrl + + let mut bits = vec![Style::new().paint(" Sync input in a tab with ")]; bits.extend(add_keybinds(help)); strings!(&bits) diff --git a/default-plugins/status-bar/src/tip/data/use_mouse.rs b/default-plugins/bars/src/status_bar/tip/data/use_mouse.rs similarity index 83% rename from default-plugins/status-bar/src/tip/data/use_mouse.rs rename to default-plugins/bars/src/status_bar/tip/data/use_mouse.rs index a30d5faef..fe6092348 100644 --- a/default-plugins/status-bar/src/tip/data/use_mouse.rs +++ b/default-plugins/bars/src/status_bar/tip/data/use_mouse.rs @@ -4,7 +4,7 @@ use ansi_term::{ Style, }; -use crate::LinePart; +use crate::status_bar::LinePart; use zellij_tile::prelude::*; use zellij_tile_utils::palette_match; @@ -22,8 +22,6 @@ macro_rules! strings { } pub fn use_mouse_full(help: &ModeInfo) -> LinePart { - // Tip: Use the mouse to switch pane focus, scroll through the pane - // scrollbuffer, switch or scroll through tabs let green_color = palette_match!(help.style.colors.text_unselected.emphasis_2); strings!(&[ @@ -34,8 +32,6 @@ pub fn use_mouse_full(help: &ModeInfo) -> LinePart { } pub fn use_mouse_medium(help: &ModeInfo) -> LinePart { - // Tip: Use the mouse to switch panes/tabs or scroll through the pane - // scrollbuffer let green_color = palette_match!(help.style.colors.text_unselected.emphasis_2); strings!(&[ @@ -46,7 +42,6 @@ pub fn use_mouse_medium(help: &ModeInfo) -> LinePart { } pub fn use_mouse_short(help: &ModeInfo) -> LinePart { - // Tip: Use the mouse to switch panes/tabs or scroll let green_color = palette_match!(help.style.colors.text_unselected.emphasis_2); strings!(&[ diff --git a/default-plugins/status-bar/src/tip/data/zellij_setup_check.rs b/default-plugins/bars/src/status_bar/tip/data/zellij_setup_check.rs similarity index 89% rename from default-plugins/status-bar/src/tip/data/zellij_setup_check.rs rename to default-plugins/bars/src/status_bar/tip/data/zellij_setup_check.rs index 6d5a29202..f32a44956 100644 --- a/default-plugins/status-bar/src/tip/data/zellij_setup_check.rs +++ b/default-plugins/bars/src/status_bar/tip/data/zellij_setup_check.rs @@ -4,7 +4,7 @@ use ansi_term::{ Style, }; -use crate::LinePart; +use crate::status_bar::LinePart; use zellij_tile::prelude::*; use zellij_tile_utils::palette_match; @@ -22,7 +22,6 @@ macro_rules! strings { } pub fn zellij_setup_check_full(help: &ModeInfo) -> LinePart { - // Tip: Having issues with Zellij? Try running "zellij setup --check" let orange_color = palette_match!(help.style.colors.text_unselected.emphasis_0); strings!(&[ @@ -36,7 +35,6 @@ pub fn zellij_setup_check_full(help: &ModeInfo) -> LinePart { } pub fn zellij_setup_check_medium(help: &ModeInfo) -> LinePart { - // Tip: Run "zellij setup --check" to find issues let orange_color = palette_match!(help.style.colors.text_unselected.emphasis_0); strings!(&[ @@ -51,7 +49,6 @@ pub fn zellij_setup_check_medium(help: &ModeInfo) -> LinePart { } pub fn zellij_setup_check_short(help: &ModeInfo) -> LinePart { - // Run "zellij setup --check" to find issues let orange_color = palette_match!(help.style.colors.text_unselected.emphasis_0); strings!(&[ diff --git a/default-plugins/status-bar/src/tip/mod.rs b/default-plugins/bars/src/status_bar/tip/mod.rs similarity index 87% rename from default-plugins/status-bar/src/tip/mod.rs rename to default-plugins/bars/src/status_bar/tip/mod.rs index 9d4fbb70a..7212f0afd 100644 --- a/default-plugins/status-bar/src/tip/mod.rs +++ b/default-plugins/bars/src/status_bar/tip/mod.rs @@ -3,7 +3,7 @@ pub mod consts; pub mod data; pub mod utils; -use crate::LinePart; +use crate::status_bar::LinePart; use zellij_tile::prelude::*; pub type TipFn = fn(&ModeInfo) -> LinePart; diff --git a/default-plugins/status-bar/src/tip/utils.rs b/default-plugins/bars/src/status_bar/tip/utils.rs similarity index 96% rename from default-plugins/status-bar/src/tip/utils.rs rename to default-plugins/bars/src/status_bar/tip/utils.rs index 96fed82b0..26b8d47fe 100644 --- a/default-plugins/status-bar/src/tip/utils.rs +++ b/default-plugins/bars/src/status_bar/tip/utils.rs @@ -35,7 +35,6 @@ pub fn get_random_tip_name() -> String { pub fn get_cached_tip_name() -> String { let mut local_cache = match LocalCache::new(PathBuf::from(DEFAULT_CACHE_FILE_PATH)) { - // TODO: it might be a good to leave an log with warn later, if possible. Err(_) => return String::from("quicknav"), Ok(cache) => cache, }; diff --git a/default-plugins/tab-bar/src/line.rs b/default-plugins/bars/src/tab_bar/line.rs similarity index 94% rename from default-plugins/tab-bar/src/line.rs rename to default-plugins/bars/src/tab_bar/line.rs index d9f7a8dac..8b111cca6 100644 --- a/default-plugins/tab-bar/src/line.rs +++ b/default-plugins/bars/src/tab_bar/line.rs @@ -2,7 +2,7 @@ use ansi_term::ANSIStrings; use std::collections::BTreeMap; use unicode_width::UnicodeWidthStr; -use crate::{LinePart, ARROW_SEPARATOR}; +use crate::tab_bar::{LinePart, ARROW_SEPARATOR}; use zellij_tile::prelude::actions::Action; use zellij_tile::prelude::*; use zellij_tile_utils::style; @@ -20,8 +20,6 @@ fn dim_style(bg: PaletteColor) -> ansi_term::Style { .dimmed() } -// move elements from before_active and after_active into tabs_to_render while they fit in cols -// adds collapsed_tabs to the left and right if there's left over tabs that don't fit fn populate_tabs_in_tab_line( tabs_before_active: &mut Vec, tabs_after_active: &mut Vec, @@ -39,7 +37,6 @@ fn populate_tabs_in_tab_line( let left_count = tabs_before_active.len(); let right_count = tabs_after_active.len(); - // left_more_tab_index is first tab to the left of the leftmost visible tab let left_more_tab_index = left_count.saturating_sub(1); let collapsed_left = left_more_message( left_count, @@ -49,7 +46,6 @@ fn populate_tabs_in_tab_line( dimmed, ); - // right_more_tab_index is the first tab to the right of the rightmost visible tab let right_more_tab_index = left_count + tabs_to_render.len(); let collapsed_right = right_more_message( right_count, @@ -62,7 +58,6 @@ fn populate_tabs_in_tab_line( let total_size = collapsed_left.len + middle_size + collapsed_right.len; if total_size > cols { - // break and dont add collapsed tabs to tabs_to_render, they will not fit break; } @@ -78,7 +73,6 @@ fn populate_tabs_in_tab_line( usize::MAX }; - // total size is shortened if the next tab to be added is the last one, as that will remove the collapsed tab let size_by_adding_left = left.saturating_add(total_size) .saturating_sub(if left_count == 1 { @@ -97,22 +91,17 @@ fn populate_tabs_in_tab_line( let left_fits = size_by_adding_left <= cols; let right_fits = size_by_adding_right <= cols; - // active tab is kept in the middle by adding to the side that - // has less width, or if the tab on the other side doesn't fit if (total_left <= total_right || !right_fits) && left_fits { - // add left tab let tab = tabs_before_active.pop().unwrap(); middle_size += tab.len; total_left += tab.len; tabs_to_render.insert(0, tab); } else if right_fits { - // add right tab let tab = tabs_after_active.remove(0); middle_size += tab.len; total_right += tab.len; tabs_to_render.push(tab); } else { - // there's either no space to add more tabs or no more tabs to add, so we're done tabs_to_render.insert(0, collapsed_left); tabs_to_render.push(collapsed_right); break; @@ -135,8 +124,6 @@ fn left_more_message( } else { " ← +many ".to_string() }; - // 238 - // chars length plus separator length on both sides let more_text_len = more_text.width() + 2 * separator.width(); let (text_color, sep_color) = ( palette.ribbon_unselected.base, @@ -174,7 +161,6 @@ fn right_more_message( } else { " +many → ".to_string() }; - // chars length plus separator length on both sides let more_text_len = more_text.width() + 2 * separator.width(); let (text_color, sep_color) = ( palette.ribbon_unselected.base, @@ -343,7 +329,6 @@ pub fn tab_line( active_pane_scroll.map(|scroll| scroll_status(scroll, palette, dimmed)); } - // Drop indicator if it would cause wrapping (active tab always rendered unconditionally) let prefix_len = get_current_title_len(&prefix); let hint_budget = cols.saturating_sub(prefix_len + active_tab.len); if let Some(hint_part) = hint.and_then(|hint| hint_line_part(hint, hint_budget, dimmed)) { @@ -574,7 +559,6 @@ pub fn style_key_with_modifier( .collect::>() .join("-"); - // Prints the keys let key = keyvec .iter() .map(|key| { @@ -586,7 +570,6 @@ pub fn style_key_with_modifier( }) .collect::>(); - // Special handling of some pre-defined keygroups let key_string = key.join(""); let key_separator = match &key_string[..] { "HJKL" => "", diff --git a/default-plugins/bars/src/tab_bar/mod.rs b/default-plugins/bars/src/tab_bar/mod.rs new file mode 100644 index 000000000..7221b59cd --- /dev/null +++ b/default-plugins/bars/src/tab_bar/mod.rs @@ -0,0 +1,427 @@ +mod line; +mod tab; + +use std::cmp::{max, min}; +use std::collections::{BTreeMap, VecDeque}; +use std::convert::TryInto; + +use tab::get_tab_to_focus; +use zellij_tile::prelude::*; + +use crate::keybinds::KeybindStore; +use crate::ClientSeed; +use line::tab_line; +use tab::tab_style; + +#[derive(Debug, Default)] +pub struct LinePart { + part: String, + len: usize, + tab_index: Option, +} + +impl LinePart { + pub fn append(&mut self, to_append: &LinePart) { + self.part.push_str(&to_append.part); + self.len += to_append.len; + } +} + +static ARROW_SEPARATOR: &str = ""; + +#[derive(Debug, Default)] +struct SlotConfig { + hide_swap_layout_indication: bool, +} + +#[derive(Debug, Default)] +struct ClientState { + tabs: Vec, + active_tab_idx: usize, + mode_info: ModeInfo, + active_pane_scroll: Option<(usize, usize)>, + hovered_tab_idx: Option, + hovered_new_tab_button: bool, + hint_text: Option>, + outstanding_hint_timeouts: usize, +} + +#[derive(Debug, Default)] +struct SlotClientState { + tab_line: Vec, + new_tab_button_range: Option<(usize, usize)>, + breadcrumb_range: Option<(usize, usize)>, +} + +#[derive(Debug, Default)] +pub struct TabBar { + slots: BTreeMap, + clients: BTreeMap, + slot_clients: BTreeMap<(SlotId, ClientId), SlotClientState>, + hint_timeout_queue: VecDeque, +} + +impl TabBar { + pub fn has_slots(&self) -> bool { + !self.slots.is_empty() + } + + pub fn slot_added(&mut self, slot: &Slot) { + let hide_swap_layout_indication = slot + .configuration + .get("hide_swap_layout_indication") + .map(|s| s == "true") + .unwrap_or(false); + self.slots.insert( + slot.id, + SlotConfig { + hide_swap_layout_indication, + }, + ); + subscribe(&[ + EventType::TabUpdate, + EventType::ModeUpdate, + EventType::Mouse, + EventType::InitialKeybinds, + EventType::ActivePaneScroll, + EventType::HintText, + EventType::Timer, + EventType::InputReceived, + ]); + } + + pub fn slot_removed(&mut self, slot_id: SlotId) { + self.slots.remove(&slot_id); + self.slot_clients.retain(|(s, _), _| *s != slot_id); + } + + pub fn client_connected(&mut self, client_id: ClientId) { + self.clients.entry(client_id).or_default(); + } + + pub fn client_disconnected(&mut self, client_id: ClientId) { + self.clients.remove(&client_id); + self.slot_clients.retain(|(_, c), _| *c != client_id); + } + + pub fn client_ids(&self) -> impl Iterator + '_ { + self.clients.keys().copied() + } + + pub fn reset(&mut self) { + self.clients.clear(); + self.slot_clients.clear(); + } + + pub fn snapshot(&self) -> BTreeMap { + self.clients + .iter() + .map(|(client_id, client)| { + ( + *client_id, + ClientSeed { + mode_info: client.mode_info.clone(), + tabs: client.tabs.clone(), + }, + ) + }) + .collect() + } + + pub fn seed(&mut self, seeds: &BTreeMap) { + for (client_id, seed) in seeds { + let client = self.clients.entry(*client_id).or_default(); + client.mode_info = seed.mode_info.clone(); + if let Some(active_tab_index) = seed.tabs.iter().position(|t| t.active) { + client.active_tab_idx = active_tab_index + 1; + client.tabs = seed.tabs.clone(); + } + } + } + + fn target_clients(&self, context: &EventContext) -> Vec { + match context.client_id { + Some(client_id) => vec![client_id], + None => self.clients.keys().copied().collect(), + } + } + + fn render_request(&self, context: &EventContext, should_render: bool) -> RenderResponse { + if !should_render { + RenderResponse::Nothing + } else if let Some(client_id) = context.client_id { + RenderResponse::Client(client_id) + } else { + RenderResponse::Slots(self.slots.keys().copied().collect()) + } + } + + pub fn update(&mut self, event: &Event, context: EventContext) -> RenderResponse { + if let Event::Timer(_) = event { + let Some(client_id) = self.hint_timeout_queue.pop_front() else { + return RenderResponse::Nothing; + }; + let Some(client) = self.clients.get_mut(&client_id) else { + return RenderResponse::Nothing; + }; + let mut should_render = false; + client.outstanding_hint_timeouts = client.outstanding_hint_timeouts.saturating_sub(1); + if client.outstanding_hint_timeouts == 0 && client.hint_text.is_some() { + client.hint_text = None; + should_render = true; + } + if client.tabs.is_empty() { + should_render = false; + } + return if should_render { + RenderResponse::Client(client_id) + } else { + RenderResponse::Nothing + }; + } + let mut should_render = false; + for client_id in self.target_clients(&context) { + if self.update_client(event, client_id, context.slot_id) { + should_render = true; + } + } + self.render_request(&context, should_render) + } + + fn update_client( + &mut self, + event: &Event, + client_id: ClientId, + slot_id: Option, + ) -> bool { + let client = self.clients.entry(client_id).or_default(); + let empty_slot_client = SlotClientState::default(); + let slot_client = slot_id + .and_then(|slot_id| self.slot_clients.get(&(slot_id, client_id))) + .unwrap_or(&empty_slot_client); + let mut should_render = false; + match event { + Event::InitialKeybinds(_) => { + should_render = true; + }, + Event::ModeUpdate(mode_info) => { + if &client.mode_info != mode_info { + should_render = true; + } + client.mode_info = mode_info.clone(); + }, + Event::TabUpdate(tabs) => { + if let Some(active_tab_index) = tabs.iter().position(|t| t.active) { + let active_tab_idx = active_tab_index + 1; + + if client.active_tab_idx != active_tab_idx || &client.tabs != tabs { + should_render = true; + } + client.active_tab_idx = active_tab_idx; + client.tabs = tabs.clone(); + } else { + eprintln!("Could not find active tab."); + } + }, + Event::ActivePaneScroll(scroll) => { + if client.active_pane_scroll != *scroll { + should_render = true; + } + client.active_pane_scroll = *scroll; + }, + Event::HintText(hint_variants) => { + if hint_variants.is_empty() { + if client.hint_text.is_some() { + client.hint_text = None; + should_render = true; + } + } else { + client.hint_text = Some(hint_variants.clone()); + client.outstanding_hint_timeouts += 1; + self.hint_timeout_queue.push_back(client_id); + set_timeout(5.0); + should_render = true; + } + }, + Event::InputReceived => { + if client.hint_text.is_some() { + client.hint_text = None; + should_render = true; + } + }, + Event::Mouse(me) => match me { + Mouse::LeftClick(_, col) => { + let col = *col; + if let Some((start, end)) = slot_client.breadcrumb_range { + if col >= start && col < end { + focus_host_session(); + return should_render; + } + } + if let Some((start, end)) = slot_client.new_tab_button_range { + if col >= start && col < end { + new_tab::<&str>(None, None); + return should_render; + } + } + let tab_to_focus = + get_tab_to_focus(&slot_client.tab_line, client.active_tab_idx, col); + if let Some(idx) = tab_to_focus { + switch_tab_to(idx.try_into().unwrap()); + } + }, + Mouse::Hover(_, col) => { + let col = *col; + let simplified_ui = client.mode_info.capabilities.arrow_fonts; + let mut new_hovered_new_tab_button = false; + let mut new_hovered_tab_idx = None; + if !simplified_ui { + if let Some((start, end)) = slot_client.new_tab_button_range { + if col >= start && col < end { + new_hovered_new_tab_button = true; + } + } + if !new_hovered_new_tab_button { + new_hovered_tab_idx = + get_tab_to_focus(&slot_client.tab_line, client.active_tab_idx, col); + } + } + if client.hovered_new_tab_button != new_hovered_new_tab_button + || client.hovered_tab_idx != new_hovered_tab_idx + { + client.hovered_new_tab_button = new_hovered_new_tab_button; + client.hovered_tab_idx = new_hovered_tab_idx; + should_render = true; + } + }, + Mouse::ScrollUp(_) => { + switch_tab_to(min(client.active_tab_idx + 1, client.tabs.len()) as u32); + }, + Mouse::ScrollDown(_) => { + switch_tab_to(max(client.active_tab_idx.saturating_sub(1), 1) as u32); + }, + _ => {}, + }, + _ => {}, + } + if client.tabs.is_empty() { + should_render = false; + } + should_render + } + + pub fn render( + &mut self, + rows: usize, + cols: usize, + slot_id: SlotId, + client_id: ClientId, + keybinds: &mut KeybindStore, + ) { + let lent = self.swap_keybinds(client_id, keybinds); + self.render_client(rows, cols, slot_id, client_id); + if lent { + self.swap_keybinds(client_id, keybinds); + } + } + + fn swap_keybinds(&mut self, client_id: ClientId, keybinds: &mut KeybindStore) -> bool { + match self.clients.get_mut(&client_id) { + Some(client) => keybinds.swap(client_id, &mut client.mode_info.keybinds), + None => false, + } + } + + fn render_client(&mut self, _rows: usize, cols: usize, slot_id: SlotId, client_id: ClientId) { + let Some(client) = self.clients.get(&client_id) else { + return; + }; + if client.tabs.is_empty() { + return; + } + let hide_swap_layout_indication = self + .slots + .get(&slot_id) + .map(|s| s.hide_swap_layout_indication) + .unwrap_or(false); + let dimmed = client.mode_info.session_ascended == Some(true) + || client.mode_info.session_dimmed == Some(true); + let mut all_tabs: Vec = vec![]; + let mut active_tab_index = 0; + let mut is_alternate_tab = false; + for t in &client.tabs { + let mut tabname = t.name.clone(); + if t.active && client.mode_info.mode == InputMode::RenameTab { + if tabname.is_empty() { + tabname = String::from("Enter name..."); + } + active_tab_index = t.position; + } else if t.active { + active_tab_index = t.position; + } + let is_hovered = client.hovered_tab_idx == Some(t.position + 1); + let tab = tab_style( + tabname, + t, + is_alternate_tab, + is_hovered, + client.mode_info.style.colors, + client.mode_info.capabilities, + dimmed, + ); + is_alternate_tab = !is_alternate_tab; + all_tabs.push(tab); + } + + let background = client.mode_info.style.colors.text_unselected.background; + + let full_pane_frames = client.mode_info.pane_frame_style == Some(PaneFrameStyle::Full); + let hint_text = if full_pane_frames { + None + } else { + client.hint_text.as_ref() + }; + let breadcrumb_ancestry: Vec = if client.mode_info.host_fullscreen == Some(true) { + client.mode_info.session_ancestry.clone() + } else { + vec![] + }; + let (line, new_tab_button_range, breadcrumb_range) = tab_line( + client.mode_info.session_name.as_deref(), + all_tabs, + active_tab_index, + cols.saturating_sub(1), + client.mode_info.style.colors, + client.mode_info.capabilities, + client.mode_info.style.hide_session_name, + client.tabs.iter().find(|t| t.active), + &client.mode_info, + hide_swap_layout_indication, + &background, + client.active_pane_scroll, + hint_text, + is_alternate_tab, + client.hovered_new_tab_button, + dimmed, + &breadcrumb_ancestry, + ); + let slot_client = self.slot_clients.entry((slot_id, client_id)).or_default(); + slot_client.tab_line = line; + slot_client.new_tab_button_range = new_tab_button_range; + slot_client.breadcrumb_range = breadcrumb_range; + + let output = slot_client + .tab_line + .iter() + .fold(String::new(), |output, part| output + &part.part); + + match background { + PaletteColor::Rgb((r, g, b)) => { + print!("{}\u{1b}[48;2;{};{};{}m\u{1b}[0K", output, r, g, b); + }, + PaletteColor::EightBit(color) => { + print!("{}\u{1b}[48;5;{}m\u{1b}[0K", output, color); + }, + } + } +} diff --git a/default-plugins/tab-bar/src/tab.rs b/default-plugins/bars/src/tab_bar/tab.rs similarity index 93% rename from default-plugins/tab-bar/src/tab.rs rename to default-plugins/bars/src/tab_bar/tab.rs index 119d4e5dd..96f99b942 100644 --- a/default-plugins/tab-bar/src/tab.rs +++ b/default-plugins/bars/src/tab_bar/tab.rs @@ -1,4 +1,4 @@ -use crate::{line::tab_separator, LinePart}; +use crate::tab_bar::{line::tab_separator, LinePart}; use ansi_term::{ANSIString, ANSIStrings}; use unicode_width::UnicodeWidthStr; use zellij_tile::prelude::*; @@ -8,7 +8,6 @@ fn cursors<'a>( focused_client_slots: &'a [usize], multiplayer_colors: MultiplayerColors, ) -> (Vec>, usize) { - // cursor section, text length let mut len = 0; let mut cursors = vec![]; for display_slot in focused_client_slots.iter() { @@ -71,14 +70,14 @@ pub fn render_tab( }; let left_separator = style!(separator_fill_color, background_color).paint(separator); let right_separator_style = style!(background_color, separator_fill_color); - let mut tab_text_len = text.width() + (separator_width * 2) + 2; // +2 for padding + let mut tab_text_len = text.width() + (separator_width * 2) + 2; let tab_styled_text = text_style.paint(format!(" {} ", text)); let right_separator = right_separator_style.paint(separator); let tab_styled_text = if !focused_clients.is_empty() { let (cursor_section, extra_length) = cursors(focused_clients, palette.multiplayer_user_colors); - tab_text_len += extra_length + 2; // 2 for cursor_beginning and cursor_end + tab_text_len += extra_length + 2; let mut s = String::new(); let cursor_beginning = text_style.paint("[").to_string(); let cursor_section = ANSIStrings(&cursor_section).to_string(); @@ -120,7 +119,6 @@ pub fn tab_style( if tab.has_bell_notification || tab.is_flashing_bell { tabname.push_str(" [!]"); } - // we only color alternate tabs differently if we can't use the arrow fonts to separate them if !capabilities.arrow_fonts { is_alternate_tab = false; } @@ -143,7 +141,6 @@ pub(crate) fn get_tab_to_focus( ) -> Option { let clicked_line_part = get_clicked_line_part(tab_line, mouse_click_col)?; let clicked_tab_idx = clicked_line_part.tab_index?; - // tabs are indexed starting from 1 so we need to add 1 let clicked_tab_idx = clicked_tab_idx + 1; if clicked_tab_idx != active_tab_idx { return Some(clicked_tab_idx); diff --git a/default-plugins/compact-bar/Cargo.toml b/default-plugins/compact-bar/Cargo.toml deleted file mode 100644 index 7e14011c0..000000000 --- a/default-plugins/compact-bar/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "compact-bar" -version = "0.1.0" -authors = ["Alexander Kenji Berthold " ] -edition.workspace = true -license.workspace = true - -[dependencies] -ansi_term = { workspace = true } -unicode-width = { workspace = true } -zellij-tile = { path = "../../zellij-tile" } -zellij-tile-utils = { path = "../../zellij-tile-utils" } diff --git a/default-plugins/compact-bar/src/main.rs b/default-plugins/compact-bar/src/main.rs deleted file mode 100644 index 4ddf5ee24..000000000 --- a/default-plugins/compact-bar/src/main.rs +++ /dev/null @@ -1,613 +0,0 @@ -mod action_types; -mod clipboard_utils; -mod keybind_utils; -mod line; -mod tab; -mod tooltip; - -use std::cmp::{max, min}; -use std::collections::BTreeMap; -use std::convert::TryInto; - -use tab::get_tab_to_focus; -use zellij_tile::prelude::*; - -use crate::clipboard_utils::{system_clipboard_error, text_copied_hint}; -use crate::line::tab_line; -use crate::tab::tab_style; -use crate::tooltip::TooltipRenderer; - -static ARROW_SEPARATOR: &str = ""; - -const CONFIG_IS_TOOLTIP: &str = "is_tooltip"; -const CONFIG_TOGGLE_TOOLTIP_KEY: &str = "tooltip"; -const MSG_TOGGLE_TOOLTIP: &str = "toggle_tooltip"; -const MSG_TOGGLE_PERSISTED_TOOLTIP: &str = "toggle_persisted_tooltip"; -const MSG_LAUNCH_TOOLTIP: &str = "launch_tooltip_if_not_launched"; - -#[derive(Debug, Default)] -pub struct LinePart { - part: String, - len: usize, - tab_index: Option, -} - -#[derive(Default)] -struct State { - // Tab state - tabs: Vec, - active_tab_idx: usize, - - // Display state - mode_info: ModeInfo, - tab_line: Vec, - breadcrumb_range: Option<(usize, usize)>, - display_area_rows: usize, - display_area_cols: usize, - - // Clipboard state - text_copy_destination: Option, - display_system_clipboard_failure: bool, - - // Plugin configuration - config: BTreeMap, - own_plugin_id: Option, - toggle_tooltip_key: Option, - - // Tooltip state - is_tooltip: bool, - tooltip_is_active: bool, - persist: bool, - is_first_run: bool, - own_tab_index: Option, - own_client_id: ClientId, - - // Keybinding cache - cached_keybinds: KeybindsVec, -} - -struct TabRenderData { - tabs: Vec, - active_tab_index: usize, - active_swap_layout_name: Option, - is_swap_layout_dirty: bool, -} - -register_plugin!(State); - -impl ZellijPlugin for State { - fn load(&mut self, configuration: BTreeMap) { - let plugin_ids = get_plugin_ids(); - self.own_plugin_id = Some(plugin_ids.plugin_id); - self.own_client_id = plugin_ids.client_id; - self.initialize_configuration(configuration); - self.setup_subscriptions(); - self.configure_keybinds(); - } - - fn update(&mut self, event: Event) -> bool { - self.is_first_run = false; - - match event { - Event::InitialKeybinds(keybinds) => { - self.cached_keybinds = keybinds; - if !self.cached_keybinds.is_empty() { - self.mode_info.keybinds = self.cached_keybinds.clone(); - } - true - }, - Event::ModeUpdate(mut mode_info) => { - if mode_info.keybinds.is_empty() && !self.cached_keybinds.is_empty() { - mode_info.keybinds = self.cached_keybinds.clone(); - } else if !mode_info.keybinds.is_empty() { - self.cached_keybinds = mode_info.keybinds.clone(); - } - self.handle_mode_update(mode_info) - }, - Event::TabUpdate(tabs) => self.handle_tab_update(tabs), - Event::PaneUpdate(pane_manifest) => self.handle_pane_update(pane_manifest), - Event::Mouse(mouse_event) => { - self.handle_mouse_event(mouse_event); - false - }, - Event::CopyToClipboard(copy_destination) => { - self.handle_clipboard_copy(copy_destination) - }, - Event::SystemClipboardFailure => self.handle_clipboard_failure(), - Event::InputReceived => self.handle_input_received(), - _ => false, - } - } - - fn pipe(&mut self, message: PipeMessage) -> bool { - if self.is_tooltip && message.is_private { - self.handle_tooltip_pipe(message); - } else if message.name == MSG_TOGGLE_TOOLTIP - && message.is_private - && self.toggle_tooltip_key.is_some() - // only launch once per plugin instance - && self.own_tab_index == Some(self.active_tab_idx.saturating_sub(1)) - // only launch once per client of plugin instance - && Some(format!("{}", self.own_client_id)) == message.payload - { - self.toggle_persisted_tooltip(self.mode_info.mode); - } - false - } - - fn render(&mut self, rows: usize, cols: usize) { - if self.is_tooltip { - self.render_tooltip(rows, cols); - } else { - self.render_tab_line(cols); - } - } -} - -impl State { - fn initialize_configuration(&mut self, configuration: BTreeMap) { - self.config = configuration.clone(); - self.is_tooltip = self.parse_bool_config(CONFIG_IS_TOOLTIP, false); - - if !self.is_tooltip { - if let Some(tooltip_toggle_key) = configuration.get(CONFIG_TOGGLE_TOOLTIP_KEY) { - self.toggle_tooltip_key = Some(tooltip_toggle_key.clone()); - } - } - - if self.is_tooltip { - self.is_first_run = true; - } - } - - fn setup_subscriptions(&self) { - set_selectable(false); - - let events = if self.is_tooltip { - vec![ - EventType::ModeUpdate, - EventType::TabUpdate, - EventType::InitialKeybinds, - ] - } else { - vec![ - EventType::TabUpdate, - EventType::PaneUpdate, - EventType::ModeUpdate, - EventType::Mouse, - EventType::CopyToClipboard, - EventType::InputReceived, - EventType::SystemClipboardFailure, - EventType::InitialKeybinds, - ] - }; - - subscribe(&events); - } - - fn configure_keybinds(&self) { - if !self.is_tooltip && self.toggle_tooltip_key.is_some() { - if let Some(toggle_key) = &self.toggle_tooltip_key { - reconfigure( - bind_toggle_key_config(toggle_key, self.own_client_id), - false, - ); - } - } - } - - fn parse_bool_config(&self, key: &str, default: bool) -> bool { - self.config - .get(key) - .and_then(|v| v.parse().ok()) - .unwrap_or(default) - } - - // Event handlers - fn handle_mode_update(&mut self, mode_info: ModeInfo) -> bool { - let should_render = self.mode_info != mode_info; - let old_mode = self.mode_info.mode; - let new_mode = mode_info.mode; - let base_mode = mode_info.base_mode.unwrap_or(InputMode::Normal); - - self.mode_info = mode_info; - - if self.is_tooltip { - self.handle_tooltip_mode_update(old_mode, new_mode, base_mode); - } else { - self.handle_main_mode_update(new_mode, base_mode); - } - - should_render - } - - fn handle_main_mode_update(&self, new_mode: InputMode, base_mode: InputMode) { - if self.toggle_tooltip_key.is_some() - && new_mode != base_mode - && !self.is_restricted_mode(new_mode) - { - self.launch_tooltip_if_not_launched(new_mode); - } - } - - fn handle_tooltip_mode_update( - &mut self, - old_mode: InputMode, - new_mode: InputMode, - base_mode: InputMode, - ) { - if !self.persist && (new_mode == base_mode || self.is_restricted_mode(new_mode)) { - close_self(); - } else if new_mode != old_mode || self.persist { - self.update_tooltip_for_mode_change(new_mode); - } - } - - fn handle_tab_update(&mut self, tabs: Vec) -> bool { - self.update_display_area(&tabs); - - if let Some(active_tab_index) = tabs.iter().position(|t| t.active) { - let active_tab_idx = active_tab_index + 1; // Convert to 1-based indexing - let should_render = self.active_tab_idx != active_tab_idx || self.tabs != tabs; - - if self.is_tooltip && self.active_tab_idx != active_tab_idx { - self.move_tooltip_to_new_tab(active_tab_idx); - } - - self.active_tab_idx = active_tab_idx; - self.tabs = tabs; - should_render - } else { - false - } - } - - fn handle_pane_update(&mut self, pane_manifest: PaneManifest) -> bool { - if self.toggle_tooltip_key.is_some() { - let previous_tooltip_state = self.tooltip_is_active; - self.tooltip_is_active = self.detect_tooltip_presence(&pane_manifest); - self.own_tab_index = self.find_own_tab_index(&pane_manifest); - previous_tooltip_state != self.tooltip_is_active - } else { - false - } - } - - fn handle_mouse_event(&mut self, mouse_event: Mouse) { - if self.is_tooltip { - return; - } - - match mouse_event { - Mouse::LeftClick(_, col) => { - if self.click_is_on_breadcrumb(col) { - focus_host_session(); - } else { - self.handle_tab_click(col); - } - }, - Mouse::ScrollUp(_) => self.scroll_tab_up(), - Mouse::ScrollDown(_) => self.scroll_tab_down(), - _ => {}, - } - } - - fn click_is_on_breadcrumb(&self, col: usize) -> bool { - matches!(self.breadcrumb_range, Some((start, end)) if col >= start && col < end) - } - - fn handle_clipboard_copy(&mut self, copy_destination: CopyDestination) -> bool { - if self.is_tooltip { - return false; - } - - let should_render = match self.text_copy_destination { - Some(current) => current != copy_destination, - None => true, - }; - - self.text_copy_destination = Some(copy_destination); - should_render - } - - fn handle_clipboard_failure(&mut self) -> bool { - if self.is_tooltip { - return false; - } - - self.display_system_clipboard_failure = true; - true - } - - fn handle_input_received(&mut self) -> bool { - if self.is_tooltip { - return false; - } - - let should_render = - self.text_copy_destination.is_some() || self.display_system_clipboard_failure; - self.clear_clipboard_state(); - should_render - } - - fn handle_tooltip_pipe(&mut self, message: PipeMessage) { - if message.name == MSG_TOGGLE_PERSISTED_TOOLTIP { - if self.is_first_run { - self.persist = true; - } else { - #[cfg(target_family = "wasm")] - close_self(); - } - } - } - - // Helper methods - fn update_display_area(&mut self, tabs: &[TabInfo]) { - for tab in tabs { - if tab.active { - self.display_area_rows = tab.display_area_rows; - self.display_area_cols = tab.display_area_columns; - break; - } - } - } - - fn detect_tooltip_presence(&self, pane_manifest: &PaneManifest) -> bool { - for (_tab_index, panes) in &pane_manifest.panes { - for pane in panes { - if pane.plugin_url == Some("zellij:compact-bar".to_owned()) - && pane.pane_x != pane.pane_content_x - { - return true; - } - } - } - false - } - - fn find_own_tab_index(&self, pane_manifest: &PaneManifest) -> Option { - for (tab_index, panes) in &pane_manifest.panes { - for pane in panes { - if pane.is_plugin && Some(pane.id) == self.own_plugin_id { - return Some(*tab_index); - } - } - } - None - } - - fn handle_tab_click(&self, col: usize) { - if let Some(tab_idx) = get_tab_to_focus(&self.tab_line, self.active_tab_idx, col) { - switch_tab_to(tab_idx.try_into().unwrap()); - } - } - - fn scroll_tab_up(&self) { - let next_tab = min(self.active_tab_idx + 1, self.tabs.len()); - switch_tab_to(next_tab as u32); - } - - fn scroll_tab_down(&self) { - let prev_tab = max(self.active_tab_idx.saturating_sub(1), 1); - switch_tab_to(prev_tab as u32); - } - - fn clear_clipboard_state(&mut self) { - self.text_copy_destination = None; - self.display_system_clipboard_failure = false; - } - - fn is_restricted_mode(&self, mode: InputMode) -> bool { - matches!( - mode, - InputMode::Locked - | InputMode::EnterSearch - | InputMode::RenameTab - | InputMode::RenamePane - | InputMode::Prompt - | InputMode::Tmux - ) - } - - // Tooltip operations - fn toggle_persisted_tooltip(&self, new_mode: InputMode) { - #[allow(unused_variables)] - let message = self - .create_tooltip_message(MSG_TOGGLE_PERSISTED_TOOLTIP, new_mode) - .with_args(self.create_persist_args()); - - #[cfg(target_family = "wasm")] - pipe_message_to_plugin(message); - } - - fn launch_tooltip_if_not_launched(&self, new_mode: InputMode) { - let message = self.create_tooltip_message(MSG_LAUNCH_TOOLTIP, new_mode); - pipe_message_to_plugin(message); - } - - fn create_tooltip_message(&self, name: &str, mode: InputMode) -> MessageToPlugin { - let mut tooltip_config = self.config.clone(); - tooltip_config.insert(CONFIG_IS_TOOLTIP.to_string(), "true".to_string()); - - MessageToPlugin::new(name) - .with_plugin_url("zellij:OWN_URL") - .with_plugin_config(tooltip_config) - .with_floating_pane_coordinates(self.calculate_tooltip_coordinates()) - .new_plugin_instance_should_have_pane_title(format!("{:?}", mode)) - } - - fn create_persist_args(&self) -> BTreeMap { - let mut args = BTreeMap::new(); - args.insert("persist".to_string(), String::new()); - args - } - - fn update_tooltip_for_mode_change(&self, new_mode: InputMode) { - if let Some(plugin_id) = self.own_plugin_id { - let coordinates = self.calculate_tooltip_coordinates(); - change_floating_panes_coordinates(vec![(PaneId::Plugin(plugin_id), coordinates)]); - rename_plugin_pane(plugin_id, format!("{:?}", new_mode)); - } - } - - fn move_tooltip_to_new_tab(&self, new_tab_index: usize) { - if let Some(plugin_id) = self.own_plugin_id { - break_panes_to_tab_with_index( - &[PaneId::Plugin(plugin_id)], - new_tab_index.saturating_sub(1), // Convert to 0-based indexing - false, - ); - } - } - - fn calculate_tooltip_coordinates(&self) -> FloatingPaneCoordinates { - let tooltip_renderer = TooltipRenderer::new(&self.mode_info); - let (tooltip_rows, tooltip_cols) = - tooltip_renderer.calculate_dimensions(self.mode_info.mode); - - let width = tooltip_cols + 4; // 2 for borders, 2 for padding - let height = tooltip_rows + 2; // 2 for borders - let x_position = 2; - let y_position = self.display_area_rows.saturating_sub(height + 2); - - FloatingPaneCoordinates::new( - Some(x_position.to_string()), - Some(y_position.to_string()), - Some(width.to_string()), - Some(height.to_string()), - Some(true), - Some(false), - ) - .unwrap_or_default() - } - - // Rendering - fn render_tooltip(&self, rows: usize, cols: usize) { - let tooltip_renderer = TooltipRenderer::new(&self.mode_info); - tooltip_renderer.render(rows, cols); - } - - fn render_tab_line(&mut self, cols: usize) { - if let Some(copy_destination) = self.text_copy_destination { - self.render_clipboard_hint(copy_destination); - } else if self.display_system_clipboard_failure { - self.render_clipboard_error(); - } else { - self.render_tabs(cols); - } - } - - fn render_clipboard_hint(&self, copy_destination: CopyDestination) { - let hint = text_copied_hint(copy_destination).part; - self.render_background_with_text(&hint); - } - - fn render_clipboard_error(&self) { - let hint = system_clipboard_error().part; - self.render_background_with_text(&hint); - } - - fn render_background_with_text(&self, text: &str) { - let background = self.mode_info.style.colors.text_unselected.background; - match background { - PaletteColor::Rgb((r, g, b)) => { - print!("{}\u{1b}[48;2;{};{};{}m\u{1b}[0K", text, r, g, b); - }, - PaletteColor::EightBit(color) => { - print!("{}\u{1b}[48;5;{}m\u{1b}[0K", text, color); - }, - } - } - - fn render_tabs(&mut self, cols: usize) { - if self.tabs.is_empty() { - return; - } - - let tab_data = self.prepare_tab_data(); - let tab_line_output = tab_line( - &self.mode_info, - tab_data, - cols, - self.toggle_tooltip_key.clone(), - self.tooltip_is_active, - ); - self.tab_line = tab_line_output.parts; - self.breadcrumb_range = tab_line_output.breadcrumb_range; - - let output = self - .tab_line - .iter() - .fold(String::new(), |acc, part| acc + &part.part); - - self.render_background_with_text(&output); - } - - fn prepare_tab_data(&self) -> TabRenderData { - let mut all_tabs = Vec::new(); - let mut active_tab_index = 0; - let mut active_swap_layout_name = None; - let mut is_swap_layout_dirty = false; - let mut is_alternate_tab = false; - let dimmed = self.mode_info.session_ascended == Some(true) - || self.mode_info.session_dimmed == Some(true); - - for tab in &self.tabs { - let tab_name = self.get_tab_display_name(tab); - - if tab.active { - active_tab_index = tab.position; - if self.mode_info.mode != InputMode::RenameTab { - is_swap_layout_dirty = tab.is_swap_layout_dirty; - active_swap_layout_name = tab.active_swap_layout_name.clone(); - } - } - - let styled_tab = tab_style( - tab_name, - tab, - is_alternate_tab, - self.mode_info.style.colors, - self.mode_info.capabilities, - dimmed, - ); - - is_alternate_tab = !is_alternate_tab; - all_tabs.push(styled_tab); - } - - TabRenderData { - tabs: all_tabs, - active_tab_index, - active_swap_layout_name, - is_swap_layout_dirty, - } - } - - fn get_tab_display_name(&self, tab: &TabInfo) -> String { - let mut tab_name = tab.name.clone(); - if tab.active && self.mode_info.mode == InputMode::RenameTab && tab_name.is_empty() { - tab_name = "Enter name...".to_string(); - } - tab_name - } -} - -fn bind_toggle_key_config(toggle_key: &str, client_id: ClientId) -> String { - format!( - r#" - keybinds {{ - shared {{ - bind "{}" {{ - MessagePlugin "compact-bar" {{ - name "toggle_tooltip" - tooltip "{}" - payload "{}" - }} - }} - }} - }} - "#, - toggle_key, toggle_key, client_id - ) -} diff --git a/default-plugins/fixture-plugin-for-tests/src/bin/fixture-shared-plugin-for-tests.rs b/default-plugins/fixture-plugin-for-tests/src/bin/fixture-shared-plugin-for-tests.rs new file mode 100644 index 000000000..70ee34d17 --- /dev/null +++ b/default-plugins/fixture-plugin-for-tests/src/bin/fixture-shared-plugin-for-tests.rs @@ -0,0 +1,128 @@ +use std::collections::{BTreeMap, BTreeSet}; +use zellij_tile::prelude::*; + +#[allow(dead_code)] +#[derive(Default)] +struct State { + load_count: usize, + instance_id: u32, + configuration: BTreeMap, + slots: BTreeMap, + removed_slots: Vec, + clients: BTreeSet, + disconnected_clients: Vec, + key_events: Vec<(Option, Option)>, + self_command_results: Vec, +} + +#[cfg(target_family = "wasm")] +register_shared_plugin!(State); + +#[allow(dead_code)] +fn render_line( + state: &State, + rows: usize, + cols: usize, + slot_id: SlotId, + client_id: ClientId, +) -> String { + let slot_ids: Vec = state.slots.keys().copied().collect(); + let clients: Vec = state.clients.iter().copied().collect(); + let label = state + .slots + .get(&slot_id) + .and_then(|slot| slot.configuration.get("label").cloned()) + .unwrap_or_default(); + format!( + "instance:{} loads:{} slot:{} label:{} client:{} rows:{} cols:{} slots:{:?} removed:{:?} clients:{:?} disconnected:{:?} keys:{:?} self_commands:{:?}", + state.instance_id, + state.load_count, + slot_id, + label, + client_id, + rows, + cols, + slot_ids, + state.removed_slots, + clients, + state.disconnected_clients, + state.key_events, + state.self_command_results, + ) +} + +#[cfg(target_family = "wasm")] +impl ZellijSharedPlugin for State { + fn load(&mut self, configuration: BTreeMap) { + self.load_count += 1; + self.configuration = configuration; + self.instance_id = get_plugin_ids().plugin_id; + subscribe(&[EventType::Key]); + } + fn slot_added(&mut self, slot: Slot) { + if slot.kind == SlotKind::Pane { + set_selectable(true); + } + self.slots.insert(slot.id, slot); + } + fn slot_removed(&mut self, slot_id: SlotId) { + self.slots.remove(&slot_id); + self.removed_slots.push(slot_id); + } + fn client_connected(&mut self, client_id: ClientId) { + self.clients.insert(client_id); + } + fn client_disconnected(&mut self, client_id: ClientId) { + self.clients.remove(&client_id); + self.disconnected_clients.push(client_id); + } + fn update(&mut self, event: Event, context: EventContext) -> RenderResponse { + match event { + Event::Key(_) => { + self.key_events.push((context.slot_id, context.client_id)); + match context.client_id { + Some(client_id) => RenderResponse::Client(client_id), + None => RenderResponse::All, + } + }, + _ => RenderResponse::Nothing, + } + } + fn pipe(&mut self, pipe_message: PipeMessage, context: EventContext) -> RenderResponse { + match pipe_message.name.as_str() { + "render_all" => RenderResponse::All, + "render_slot" => match pipe_message.payload.and_then(|p| p.parse::().ok()) { + Some(slot_id) => RenderResponse::Slots(vec![slot_id]), + None => RenderResponse::Nothing, + }, + "selectable_slot" => { + let results: Vec = self + .slots + .keys() + .copied() + .collect::>() + .into_iter() + .map(|slot_id| format!("{:?}", set_selectable_slot(slot_id, false))) + .collect(); + self.self_command_results.extend(results); + self.self_command_results + .push(format!("{:?}", set_selectable_slot(u32::MAX, false))); + RenderResponse::All + }, + "close_first_slot" => { + if let Some(slot_id) = self.slots.keys().next().copied() { + let result = close_slot(slot_id); + self.self_command_results.push(format!("{:?}", result)); + } + RenderResponse::All + }, + _ => { + let _ = context; + RenderResponse::Nothing + }, + } + } + fn render(&mut self, rows: usize, cols: usize, slot_id: SlotId, client_id: ClientId) { + print!("{}", render_line(self, rows, cols, slot_id, client_id)); + } +} diff --git a/default-plugins/link/.cargo/config.toml b/default-plugins/link/.cargo/config.toml deleted file mode 100644 index 6b509f5b7..000000000 --- a/default-plugins/link/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -target = "wasm32-wasip1" diff --git a/default-plugins/link/Cargo.toml b/default-plugins/link/Cargo.toml deleted file mode 100644 index d4676b249..000000000 --- a/default-plugins/link/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "link" -version = "0.1.0" -authors = ["Aram Drevekenin "] -edition.workspace = true -license.workspace = true - -[dependencies] -zellij-tile = { path = "../../zellij-tile" } diff --git a/default-plugins/status-bar/.cargo/config.toml b/default-plugins/status-bar/.cargo/config.toml deleted file mode 100644 index 6b509f5b7..000000000 --- a/default-plugins/status-bar/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -target = "wasm32-wasip1" diff --git a/default-plugins/status-bar/LICENSE.md b/default-plugins/status-bar/LICENSE.md deleted file mode 120000 index f0608a63a..000000000 --- a/default-plugins/status-bar/LICENSE.md +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE.md \ No newline at end of file diff --git a/default-plugins/status-bar/src/tip/consts.rs b/default-plugins/status-bar/src/tip/consts.rs deleted file mode 100644 index 1af302471..000000000 --- a/default-plugins/status-bar/src/tip/consts.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub const DEFAULT_CACHE_FILE_PATH: &str = "/tmp/status-bar-tips.cache"; -pub const MAX_CACHE_HITS: usize = 20; // this should be 10, but right now there's a bug where the plugin load function is called twice, and sot he cache is hit twice diff --git a/default-plugins/tab-bar/.cargo/config.toml b/default-plugins/tab-bar/.cargo/config.toml deleted file mode 100644 index 6b509f5b7..000000000 --- a/default-plugins/tab-bar/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -target = "wasm32-wasip1" diff --git a/default-plugins/tab-bar/Cargo.toml b/default-plugins/tab-bar/Cargo.toml deleted file mode 100644 index 2c47b939d..000000000 --- a/default-plugins/tab-bar/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "tab-bar" -version = "0.1.0" -authors = ["Jonah Caplan "] -edition.workspace = true -license.workspace = true - -[dependencies] -ansi_term = { workspace = true } -unicode-width = { workspace = true } -zellij-tile = { path = "../../zellij-tile" } -zellij-tile-utils = { path = "../../zellij-tile-utils" } \ No newline at end of file diff --git a/default-plugins/tab-bar/LICENSE.md b/default-plugins/tab-bar/LICENSE.md deleted file mode 120000 index f0608a63a..000000000 --- a/default-plugins/tab-bar/LICENSE.md +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE.md \ No newline at end of file diff --git a/default-plugins/tab-bar/src/main.rs b/default-plugins/tab-bar/src/main.rs deleted file mode 100644 index d55cd800a..000000000 --- a/default-plugins/tab-bar/src/main.rs +++ /dev/null @@ -1,280 +0,0 @@ -mod line; -mod tab; - -use std::cmp::{max, min}; -use std::collections::BTreeMap; -use std::convert::TryInto; - -use tab::get_tab_to_focus; -use zellij_tile::prelude::*; - -use crate::line::tab_line; -use crate::tab::tab_style; - -#[derive(Debug, Default)] -pub struct LinePart { - part: String, - len: usize, - tab_index: Option, -} - -impl LinePart { - pub fn append(&mut self, to_append: &LinePart) { - self.part.push_str(&to_append.part); - self.len += to_append.len; - } -} - -#[derive(Default, Debug)] -struct State { - tabs: Vec, - active_tab_idx: usize, - mode_info: ModeInfo, - tab_line: Vec, - hide_swap_layout_indication: bool, - cached_keybinds: KeybindsVec, - active_pane_scroll: Option<(usize, usize)>, - new_tab_button_range: Option<(usize, usize)>, - breadcrumb_range: Option<(usize, usize)>, - hovered_tab_idx: Option, - hovered_new_tab_button: bool, - hint_text: Option>, - outstanding_hint_timeouts: usize, -} - -static ARROW_SEPARATOR: &str = ""; - -register_plugin!(State); - -impl ZellijPlugin for State { - fn load(&mut self, configuration: BTreeMap) { - self.hide_swap_layout_indication = configuration - .get("hide_swap_layout_indication") - .map(|s| s == "true") - .unwrap_or(false); - set_selectable(false); - subscribe(&[ - EventType::TabUpdate, - EventType::ModeUpdate, - EventType::Mouse, - EventType::InitialKeybinds, - EventType::ActivePaneScroll, - EventType::HintText, - EventType::Timer, - EventType::InputReceived, - ]); - } - - fn update(&mut self, event: Event) -> bool { - let mut should_render = false; - match event { - Event::InitialKeybinds(keybinds) => { - self.cached_keybinds = keybinds; - if !self.cached_keybinds.is_empty() { - self.mode_info.keybinds = self.cached_keybinds.clone(); - } - should_render = true; - }, - Event::ModeUpdate(mut mode_info) => { - if mode_info.keybinds.is_empty() && !self.cached_keybinds.is_empty() { - mode_info.keybinds = self.cached_keybinds.clone(); - } else if !mode_info.keybinds.is_empty() { - self.cached_keybinds = mode_info.keybinds.clone(); - } - if self.mode_info != mode_info { - should_render = true; - } - self.mode_info = mode_info; - }, - Event::TabUpdate(tabs) => { - if let Some(active_tab_index) = tabs.iter().position(|t| t.active) { - // tabs are indexed starting from 1 so we need to add 1 - let active_tab_idx = active_tab_index + 1; - - if self.active_tab_idx != active_tab_idx || self.tabs != tabs { - should_render = true; - } - self.active_tab_idx = active_tab_idx; - self.tabs = tabs; - } else { - eprintln!("Could not find active tab."); - } - }, - Event::ActivePaneScroll(scroll) => { - if self.active_pane_scroll != scroll { - should_render = true; - } - self.active_pane_scroll = scroll; - }, - Event::HintText(hint_variants) => { - if hint_variants.is_empty() { - if self.hint_text.is_some() { - self.hint_text = None; - should_render = true; - } - } else { - self.hint_text = Some(hint_variants); - self.outstanding_hint_timeouts += 1; - set_timeout(5.0); - should_render = true; - } - }, - Event::Timer(_) => { - self.outstanding_hint_timeouts = self.outstanding_hint_timeouts.saturating_sub(1); - if self.outstanding_hint_timeouts == 0 && self.hint_text.is_some() { - self.hint_text = None; - should_render = true; - } - }, - Event::InputReceived => { - if self.hint_text.is_some() { - self.hint_text = None; - should_render = true; - } - }, - Event::Mouse(me) => match me { - Mouse::LeftClick(_, col) => { - if let Some((start, end)) = self.breadcrumb_range { - if col >= start && col < end { - focus_host_session(); - return should_render; - } - } - if let Some((start, end)) = self.new_tab_button_range { - if col >= start && col < end { - new_tab::<&str>(None, None); - return should_render; - } - } - let tab_to_focus = get_tab_to_focus(&self.tab_line, self.active_tab_idx, col); - if let Some(idx) = tab_to_focus { - switch_tab_to(idx.try_into().unwrap()); - } - }, - Mouse::Hover(_, col) => { - let simplified_ui = self.mode_info.capabilities.arrow_fonts; - let mut new_hovered_new_tab_button = false; - let mut new_hovered_tab_idx = None; - if !simplified_ui { - if let Some((start, end)) = self.new_tab_button_range { - if col >= start && col < end { - new_hovered_new_tab_button = true; - } - } - if !new_hovered_new_tab_button { - new_hovered_tab_idx = - get_tab_to_focus(&self.tab_line, self.active_tab_idx, col); - } - } - if self.hovered_new_tab_button != new_hovered_new_tab_button - || self.hovered_tab_idx != new_hovered_tab_idx - { - self.hovered_new_tab_button = new_hovered_new_tab_button; - self.hovered_tab_idx = new_hovered_tab_idx; - should_render = true; - } - }, - Mouse::ScrollUp(_) => { - switch_tab_to(min(self.active_tab_idx + 1, self.tabs.len()) as u32); - }, - Mouse::ScrollDown(_) => { - switch_tab_to(max(self.active_tab_idx.saturating_sub(1), 1) as u32); - }, - _ => {}, - }, - _ => { - eprintln!("Got unrecognized event: {:?}", event); - }, - } - if self.tabs.is_empty() { - // no need to render if we have no tabs, this can sometimes happen on startup before we - // get the tab update and then we definitely don't want to render - should_render = false; - } - should_render - } - - fn render(&mut self, _rows: usize, cols: usize) { - if self.tabs.is_empty() { - return; - } - let dimmed = self.mode_info.session_ascended == Some(true) - || self.mode_info.session_dimmed == Some(true); - let mut all_tabs: Vec = vec![]; - let mut active_tab_index = 0; - let mut is_alternate_tab = false; - for t in &mut self.tabs { - let mut tabname = t.name.clone(); - if t.active && self.mode_info.mode == InputMode::RenameTab { - if tabname.is_empty() { - tabname = String::from("Enter name..."); - } - active_tab_index = t.position; - } else if t.active { - active_tab_index = t.position; - } - let is_hovered = self.hovered_tab_idx == Some(t.position + 1); - let tab = tab_style( - tabname, - t, - is_alternate_tab, - is_hovered, - self.mode_info.style.colors, - self.mode_info.capabilities, - dimmed, - ); - is_alternate_tab = !is_alternate_tab; - all_tabs.push(tab); - } - - let background = self.mode_info.style.colors.text_unselected.background; - - let full_pane_frames = self.mode_info.pane_frame_style == Some(PaneFrameStyle::Full); - let hint_text = if full_pane_frames { - None - } else { - self.hint_text.as_ref() - }; - let breadcrumb_ancestry: Vec = if self.mode_info.host_fullscreen == Some(true) { - self.mode_info.session_ancestry.clone() - } else { - vec![] - }; - let (line, new_tab_button_range, breadcrumb_range) = tab_line( - self.mode_info.session_name.as_deref(), - all_tabs, - active_tab_index, - cols.saturating_sub(1), - self.mode_info.style.colors, - self.mode_info.capabilities, - self.mode_info.style.hide_session_name, - self.tabs.iter().find(|t| t.active), - &self.mode_info, - self.hide_swap_layout_indication, - &background, - self.active_pane_scroll, - hint_text, - is_alternate_tab, - self.hovered_new_tab_button, - dimmed, - &breadcrumb_ancestry, - ); - self.tab_line = line; - self.new_tab_button_range = new_tab_button_range; - self.breadcrumb_range = breadcrumb_range; - - let output = self - .tab_line - .iter() - .fold(String::new(), |output, part| output + &part.part); - - match background { - PaletteColor::Rgb((r, g, b)) => { - print!("{}\u{1b}[48;2;{};{};{}m\u{1b}[0K", output, r, g, b); - }, - PaletteColor::EightBit(color) => { - print!("{}\u{1b}[48;5;{}m\u{1b}[0K", output, color); - }, - } - } -} diff --git a/xtask/src/build.rs b/xtask/src/build.rs index 61f5edde1..4f8a2d522 100644 --- a/xtask/src/build.rs +++ b/xtask/src/build.rs @@ -168,13 +168,17 @@ pub fn ensure_plugin_assets(sh: &Shell) -> anyhow::Result<()> { Some((_, name)) => name, None => return true, }; - let asset_time = std::fs::metadata(plugin_asset_path(plugin_name)) - .and_then(|m| m.modified()) - .ok(); - match (asset_time, newest_source) { - (Some(asset_time), Some(newest_source)) => asset_time < newest_source, - _ => true, - } + std::iter::once(plugin_name) + .chain(member.extra_artifacts.iter().copied()) + .any(|artifact_name| { + let asset_time = std::fs::metadata(plugin_asset_path(artifact_name)) + .and_then(|m| m.modified()) + .ok(); + match (asset_time, newest_source) { + (Some(asset_time), Some(newest_source)) => asset_time < newest_source, + _ => true, + } + }) }); if !stale { @@ -205,6 +209,9 @@ fn build_plugins_release_into_assets( for member in plugin_members { move_plugin_to_assets(sh, plugin_name_of(member)?)?; + for artifact_name in member.extra_artifacts { + move_plugin_to_assets(sh, artifact_name)?; + } } Ok(()) } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index dae791b9a..417c1df5e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -28,6 +28,7 @@ use xshell::Shell; pub struct WorkspaceMember { crate_name: &'static str, build: bool, + extra_artifacts: &'static [&'static str], } fn workspace_members() -> &'static Vec { @@ -35,80 +36,84 @@ fn workspace_members() -> &'static Vec { WORKSPACE_MEMBERS.get_or_init(|| { vec![ WorkspaceMember { - crate_name: "default-plugins/compact-bar", - build: true, - }, - WorkspaceMember { - crate_name: "default-plugins/status-bar", + crate_name: "default-plugins/bars", build: true, + extra_artifacts: &[], }, WorkspaceMember { crate_name: "default-plugins/strider", build: true, - }, - WorkspaceMember { - crate_name: "default-plugins/tab-bar", - build: true, + extra_artifacts: &[], }, WorkspaceMember { crate_name: "default-plugins/fixture-plugin-for-tests", build: true, + extra_artifacts: &["fixture-shared-plugin-for-tests"], }, WorkspaceMember { crate_name: "default-plugins/session-manager", build: true, + extra_artifacts: &[], }, WorkspaceMember { crate_name: "default-plugins/configuration", build: true, + extra_artifacts: &[], }, WorkspaceMember { crate_name: "default-plugins/plugin-manager", build: true, + extra_artifacts: &[], }, WorkspaceMember { crate_name: "default-plugins/about", build: true, + extra_artifacts: &[], }, WorkspaceMember { crate_name: "default-plugins/multiple-select", build: true, + extra_artifacts: &[], }, WorkspaceMember { crate_name: "default-plugins/share", build: true, + extra_artifacts: &[], }, WorkspaceMember { crate_name: "default-plugins/layout-manager", build: true, - }, - WorkspaceMember { - crate_name: "default-plugins/link", - build: true, + extra_artifacts: &[], }, WorkspaceMember { crate_name: "zellij-utils", build: false, + extra_artifacts: &[], }, WorkspaceMember { crate_name: "zellij-tile-utils", build: false, + extra_artifacts: &[], }, WorkspaceMember { crate_name: "zellij-tile", build: false, + extra_artifacts: &[], }, WorkspaceMember { crate_name: "zellij-client", build: false, + extra_artifacts: &[], }, WorkspaceMember { crate_name: "zellij-server", build: false, + extra_artifacts: &[], }, WorkspaceMember { crate_name: ".", build: true, + extra_artifacts: &[], }, ] }) diff --git a/zellij-integration-tests/src/client_screen.rs b/zellij-integration-tests/src/client_screen.rs index 3b45f38d4..84cf5edb6 100644 --- a/zellij-integration-tests/src/client_screen.rs +++ b/zellij-integration-tests/src/client_screen.rs @@ -185,14 +185,19 @@ impl ClientScreen { loop { let size = *self.size.lock().unwrap(); let grid_snapshot = render_bytes(&received_bytes.bytes, size); - if predicate(&grid_snapshot) { + if !grid_snapshot.bars_partially_drawn() && predicate(&grid_snapshot) { return grid_snapshot; } let now = Instant::now(); if now >= deadline { panic!( - "timed out waiting for: {}\ncursor: {:?}\nlast rendered grid:\n{}\n=== (received {} stdout bytes, generation {}) ===\n=== zellij log tail ({}) ===\n{}", + "timed out waiting for: {}{}\ncursor: {:?}\nlast rendered grid:\n{}\n=== (received {} stdout bytes, generation {}) ===\n=== zellij log tail ({}) ===\n{}", what, + if grid_snapshot.bars_partially_drawn() { + " (the status bar drew but the tab bar row stayed blank)" + } else { + "" + }, grid_snapshot.cursor, grid_snapshot.text, received_bytes.bytes.len(), @@ -362,6 +367,12 @@ impl GridSnapshot { pub fn tab_bar_appears(&self) -> bool { self.text.contains("Tab #1") } + pub fn bars_partially_drawn(&self) -> bool { + let lines: Vec<&str> = self.text.lines().collect(); + let first_line_blank = lines.first().map_or(false, |line| line.trim().is_empty()); + let status_bar_at_bottom = lines.iter().rev().take(2).any(|line| line.contains("LOCK")); + first_line_blank && status_bar_at_bottom + } pub fn lines(&self) -> Vec { self.text.lines().map(|l| l.to_owned()).collect() } diff --git a/zellij-integration-tests/src/fake_pty.rs b/zellij-integration-tests/src/fake_pty.rs index 454899b46..1cc2de611 100644 --- a/zellij-integration-tests/src/fake_pty.rs +++ b/zellij-integration-tests/src/fake_pty.rs @@ -288,24 +288,15 @@ struct FakeAsyncReader { #[async_trait] impl AsyncReader for FakeAsyncReader { - async fn read(&mut self, buf: &mut [u8]) -> Result { + async fn read_chunk(&mut self, max: usize) -> Result, std::io::Error> { if self.pending.is_empty() { match self.output_rx.recv().await { Some(bytes) => self.pending.extend(bytes), - None => return Ok(0), + None => return Ok(Vec::new()), } } - let mut written = 0; - while written < buf.len() { - match self.pending.pop_front() { - Some(byte) => { - buf[written] = byte; - written += 1; - }, - None => break, - } - } - Ok(written) + let n = max.min(self.pending.len()); + Ok(self.pending.drain(..n).collect()) } } diff --git a/zellij-integration-tests/tests/app.rs b/zellij-integration-tests/tests/app.rs index 45f50c229..484b29cb0 100644 --- a/zellij-integration-tests/tests/app.rs +++ b/zellij-integration-tests/tests/app.rs @@ -121,6 +121,7 @@ fn use_custom_layout_with_relative_path() { grid_snapshot.tab_bar_appears() && grid_snapshot.status_bar_appears() && grid_snapshot.contains("Zellij (test") + && !grid_snapshot.contains(" BASE ") && grid_snapshot.cursor.is_some() }); assert_snapshot!(normalized(&grid_snapshot)); diff --git a/zellij-integration-tests/tests/stacked_pane_list.rs b/zellij-integration-tests/tests/stacked_pane_list.rs index 29d4c9ac8..11f395c6f 100644 --- a/zellij-integration-tests/tests/stacked_pane_list.rs +++ b/zellij-integration-tests/tests/stacked_pane_list.rs @@ -444,6 +444,7 @@ fn entries_share_a_uniform_width_sized_by_the_widest_title() { } let grid_snapshot = zellij.wait_until("mixed-width entries rendered", |grid_snapshot| { grid_snapshot.status_bar_appears() + && grid_snapshot.tab_bar_appears() && grid_snapshot.contains("member-command") && grid_snapshot.contains(&selected_entry("Pane #1")) && grid_snapshot.cursor_is_at(col(2).row(4)) diff --git a/zellij-server/src/background_jobs.rs b/zellij-server/src/background_jobs.rs index 38db3b7cf..a41a49e16 100644 --- a/zellij-server/src/background_jobs.rs +++ b/zellij-server/src/background_jobs.rs @@ -78,6 +78,7 @@ pub enum BackgroundJob { StopFlashTabBell(usize), // usize = tab_id StartNestedGuestPing(PaneId), StopNestedGuestPing(PaneId), + TrimAllocator, Exit, } @@ -111,12 +112,25 @@ impl From<&BackgroundJob> for BackgroundJobContext { BackgroundJob::StopFlashTabBell(..) => BackgroundJobContext::StopFlashTabBell, BackgroundJob::StartNestedGuestPing(..) => BackgroundJobContext::StartNestedGuestPing, BackgroundJob::StopNestedGuestPing(..) => BackgroundJobContext::StopNestedGuestPing, + BackgroundJob::TrimAllocator => BackgroundJobContext::TrimAllocator, BackgroundJob::Exit => BackgroundJobContext::Exit, } } } static LONG_FLASH_DURATION_MS: u64 = 1000; +static TRIM_ALLOCATOR_DEBOUNCE_MS: u64 = 2000; +static STARTUP_TRIM_ALLOCATOR_DELAY_MS: u64 = 10000; + +#[cfg(all(target_os = "linux", target_env = "gnu"))] +fn trim_allocator() { + unsafe { + libc::malloc_trim(0); + } +} + +#[cfg(not(all(target_os = "linux", target_env = "gnu")))] +fn trim_allocator() {} static FLASH_DURATION_MS: u64 = 400; // Doherty threshold static PLUGIN_ANIMATION_OFFSET_DURATION_MD: u64 = 500; static SESSION_METADATA_WRITE_INTERVAL_MS: u64 = 1000; @@ -171,6 +185,7 @@ pub(crate) fn background_jobs_main( let mut flashing_pane_bells: HashMap> = HashMap::new(); let mut flashing_tab_bells: HashMap> = HashMap::new(); let mut nested_guest_pings: HashMap> = HashMap::new(); + let pending_allocator_trim: Arc>> = Arc::new(Mutex::new(None)); let http_client = HttpClient::builder() // TODO: timeout? @@ -180,6 +195,11 @@ pub(crate) fn background_jobs_main( // We needn't do anything with the runtime, but it should exist at this point. let runtime = crate::global_async_runtime::get_tokio_runtime(); + runtime.spawn(async move { + tokio::time::sleep(Duration::from_millis(STARTUP_TRIM_ALLOCATOR_DELAY_MS)).await; + trim_allocator(); + }); + { let senders = bus.senders.clone(); let serialization_ms = serialization_interval.unwrap_or(DEFAULT_SERIALIZATION_INTERVAL); @@ -711,6 +731,45 @@ pub(crate) fn background_jobs_main( flag.store(false, Ordering::SeqCst); } }, + BackgroundJob::TrimAllocator => { + let should_spawn = { + let mut pending = pending_allocator_trim.lock().unwrap(); + let should_spawn = pending.is_none(); + *pending = Some(Instant::now()); + should_spawn + }; + if should_spawn { + let pending = pending_allocator_trim.clone(); + runtime.spawn(async move { + let debounce = Duration::from_millis(TRIM_ALLOCATOR_DEBOUNCE_MS); + tokio::time::sleep(debounce).await; + loop { + let remaining = { + let mut pending = pending.lock().unwrap(); + match *pending { + Some(last_request) => { + let elapsed = Instant::now().duration_since(last_request); + if elapsed >= debounce { + *pending = None; + None + } else { + Some(debounce.saturating_sub(elapsed)) + } + }, + None => return, + } + }; + match remaining { + Some(duration) => tokio::time::sleep(duration).await, + None => { + trim_allocator(); + return; + }, + } + } + }); + } + }, BackgroundJob::Exit => { for loading_plugin in loading_plugins.values() { loading_plugin.store(false, Ordering::SeqCst); diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs index da900a867..06cb7ee79 100644 --- a/zellij-server/src/lib.rs +++ b/zellij-server/src/lib.rs @@ -83,6 +83,8 @@ use zellij_utils::{ pub use zellij_utils::data::ClientId; +pub(crate) type SharedKeybinds = Arc; + const ACCEPT_ERROR_BACKOFF: std::time::Duration = std::time::Duration::from_millis(100); const PRE_HANDSHAKE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); @@ -240,14 +242,32 @@ impl SessionConfiguration { pub fn set_saved_configuration(&mut self, config: Config) { self.saved_config = config; } - pub fn set_client_runtime_configuration(&mut self, client_id: ClientId, client_config: Config) { + pub fn set_client_runtime_configuration( + &mut self, + client_id: ClientId, + mut client_config: Config, + ) { + self.share_keybinds(&mut client_config); self.runtime_config.insert(client_id, client_config); } + fn share_keybinds(&self, config: &mut Config) { + let known_configs = + || std::iter::once(&self.saved_config).chain(self.runtime_config.values()); + if known_configs().any(|known| Arc::ptr_eq(&known.keybinds, &config.keybinds)) { + return; + } + if let Some(known) = known_configs().find(|known| known.keybinds == config.keybinds) { + config.keybinds = known.keybinds.clone(); + } + } + pub fn remove_client(&mut self, client_id: &ClientId) { + self.runtime_config.remove(client_id); + } pub fn get_client_keybinds(&self, client_id: &ClientId) -> &Keybinds { self.runtime_config .get(client_id) - .map(|c| &c.keybinds) - .unwrap_or(&self.saved_config.keybinds) + .map(|c| c.keybinds.as_ref()) + .unwrap_or(self.saved_config.keybinds.as_ref()) } pub fn get_client_default_input_mode(&self, client_id: &ClientId) -> InputMode { self.runtime_config @@ -276,7 +296,8 @@ impl SessionConfiguration { &stringified_config, Some(current_client_configuration.clone()), ) { - Ok(new_config) => { + Ok(mut new_config) => { + self.share_keybinds(&mut new_config); config_changed = current_client_configuration != new_config; full_reconfigured_config = Some(new_config.clone()); self.runtime_config.insert(*client_id, new_config); @@ -300,11 +321,20 @@ impl SessionConfiguration { self.runtime_config .insert(*client_id, self.saved_config.clone()); } + let mut rebound_config = None; match self.runtime_config.get_mut(client_id) { Some(config) => { for (input_mode, key_with_modifier) in keys_to_unbind { - let keys_in_mode = config + let needs_change = config .keybinds + .0 + .get(&input_mode) + .map(|keys_in_mode| keys_in_mode.contains_key(&key_with_modifier)) + .unwrap_or(true); + if !needs_change { + continue; + } + let keys_in_mode = Arc::make_mut(&mut config.keybinds) .0 .entry(input_mode) .or_insert_with(Default::default); @@ -314,8 +344,16 @@ impl SessionConfiguration { } } for (input_mode, key_with_modifier, actions) in keys_to_rebind { - let keys_in_mode = config + let needs_change = config .keybinds + .0 + .get(&input_mode) + .map(|keys_in_mode| keys_in_mode.get(&key_with_modifier) != Some(&actions)) + .unwrap_or(true); + if !needs_change { + continue; + } + let keys_in_mode = Arc::make_mut(&mut config.keybinds) .0 .entry(input_mode) .or_insert_with(Default::default); @@ -325,7 +363,7 @@ impl SessionConfiguration { } } if config_changed { - full_reconfigured_config = Some(config.clone()); + rebound_config = Some(config.clone()); } }, None => { @@ -334,6 +372,12 @@ impl SessionConfiguration { ); }, } + if let Some(mut config) = rebound_config { + self.runtime_config.remove(client_id); + self.share_keybinds(&mut config); + self.runtime_config.insert(*client_id, config.clone()); + full_reconfigured_config = Some(config); + } (full_reconfigured_config, config_changed) } @@ -411,6 +455,7 @@ impl SessionMetaData { config_was_written_to_disk: bool, ) { let mut new_plugin_config = None; + let mut converted_keybinds: Vec<(Arc, SharedKeybinds)> = vec![]; for (client_id, new_config) in config_changes { if new_plugin_config.is_none() { new_plugin_config = Some(new_config.plugins.clone()); @@ -447,10 +492,22 @@ impl SessionMetaData { ); } let pane_frame_style = PaneFrameStyle::from_options(&new_config.options); + let shared_keybinds = match converted_keybinds + .iter() + .find(|(keybinds, _)| Arc::ptr_eq(keybinds, &new_config.keybinds)) + { + Some((_, shared_keybinds)) => shared_keybinds.clone(), + None => { + let shared_keybinds: SharedKeybinds = + Arc::new(new_config.keybinds.to_keybinds_vec()); + converted_keybinds.push((new_config.keybinds.clone(), shared_keybinds.clone())); + shared_keybinds + }, + }; self.senders .send_to_screen(ScreenInstruction::Reconfigure { client_id, - keybinds: new_config.keybinds.clone(), + keybinds: shared_keybinds.clone(), default_mode: new_config .options .default_mode @@ -515,7 +572,7 @@ impl SessionMetaData { self.senders .send_to_plugin(PluginInstruction::Reconfigure { client_id, - keybinds: Some(new_config.keybinds), + keybinds: Some(shared_keybinds), default_mode: new_config.options.default_mode, default_shell: self.default_shell.clone(), layout_dir: new_config.options.layout_dir, @@ -841,6 +898,125 @@ mod session_state_tests { assert!(picked == 1 || picked == 2); } + #[test] + fn session_configuration_remove_client_drops_runtime_config() { + let mut session_configuration = SessionConfiguration::default(); + session_configuration.set_client_runtime_configuration(1, Config::default()); + session_configuration.set_client_runtime_configuration(2, Config::default()); + session_configuration.remove_client(&1); + assert!(!session_configuration.runtime_config.contains_key(&1)); + assert!(session_configuration.runtime_config.contains_key(&2)); + } + + #[test] + fn session_configuration_shares_keybinds_until_a_client_changes_them() { + use zellij_utils::data::BareKey; + let config = Config::from_default_assets().unwrap(); + assert!(Arc::ptr_eq(&config.keybinds, &config.clone().keybinds)); + let mut session_configuration = SessionConfiguration::default(); + session_configuration.set_saved_configuration(config.clone()); + session_configuration.set_client_runtime_configuration(1, config.clone()); + session_configuration.set_client_runtime_configuration(2, config.clone()); + let keybinds_of = |session_configuration: &SessionConfiguration, client_id: ClientId| { + session_configuration.runtime_config[&client_id] + .keybinds + .clone() + }; + let saved_keybinds = session_configuration.saved_config.keybinds.clone(); + assert!(Arc::ptr_eq( + &keybinds_of(&session_configuration, 1), + &saved_keybinds + )); + assert!(Arc::ptr_eq( + &keybinds_of(&session_configuration, 2), + &saved_keybinds + )); + + let (_, changed) = + session_configuration.reconfigure_runtime_config(&1, "simplified_ui true".to_owned()); + assert!(changed); + assert!(Arc::ptr_eq( + &keybinds_of(&session_configuration, 1), + &saved_keybinds + )); + + let client_2_keybinds_before = (*keybinds_of(&session_configuration, 2)).clone(); + let new_binding = r#" + keybinds { + normal { + bind "Alt F1" { NewPane; } + } + } + "#; + let (_, changed) = + session_configuration.reconfigure_runtime_config(&1, new_binding.to_owned()); + assert!(changed); + assert!(!Arc::ptr_eq( + &keybinds_of(&session_configuration, 1), + &saved_keybinds + )); + assert!(Arc::ptr_eq( + &keybinds_of(&session_configuration, 2), + &saved_keybinds + )); + assert_eq!( + *keybinds_of(&session_configuration, 2), + client_2_keybinds_before + ); + assert_ne!( + session_configuration.get_client_keybinds(&1), + session_configuration.get_client_keybinds(&2) + ); + let alt_f1 = KeyWithModifier::new(BareKey::F(1)).with_alt_modifier(); + assert!(session_configuration + .get_client_keybinds(&1) + .get_actions_for_key_in_mode(&InputMode::Normal, &alt_f1) + .is_some()); + assert_eq!( + session_configuration + .get_client_keybinds(&2) + .get_actions_for_key_in_mode(&InputMode::Normal, &alt_f1), + config + .keybinds + .get_actions_for_key_in_mode(&InputMode::Normal, &alt_f1) + ); + + let (_, changed) = + session_configuration.reconfigure_runtime_config(&2, new_binding.to_owned()); + assert!(changed); + assert!(Arc::ptr_eq( + &keybinds_of(&session_configuration, 1), + &keybinds_of(&session_configuration, 2) + )); + + let (_, changed) = session_configuration.rebind_keys( + &2, + vec![], + vec![(InputMode::Normal, alt_f1.clone())], + ); + assert!(changed); + assert!(!Arc::ptr_eq( + &keybinds_of(&session_configuration, 1), + &keybinds_of(&session_configuration, 2) + )); + assert!(session_configuration + .get_client_keybinds(&1) + .get_actions_for_key_in_mode(&InputMode::Normal, &alt_f1) + .is_some()); + + let config_changes = session_configuration.change_saved_config(config.clone()); + assert!(config_changes.iter().any(|(client_id, _)| *client_id == 1)); + let saved_keybinds = session_configuration.saved_config.keybinds.clone(); + assert!(Arc::ptr_eq( + &keybinds_of(&session_configuration, 1), + &saved_keybinds + )); + assert!(Arc::ptr_eq( + &keybinds_of(&session_configuration, 2), + &saved_keybinds + )); + } + #[test] fn pick_forward_target_none_when_no_clients() { let s = SessionState::new(); @@ -1471,7 +1647,8 @@ pub fn start_server_impl( // Handle regular client removal remove_client!(client_id, os_input, session_state, session_data); drop(completion_tx); // prevent deadlock with route thread - if let Some(session_data) = session_data.write().unwrap().as_ref() { + if let Some(session_data) = session_data.write().unwrap().as_mut() { + session_data.session_configuration.remove_client(&client_id); let _ = session_data .senders .send_to_screen(ScreenInstruction::RemoveClient(client_id)); @@ -1529,7 +1706,8 @@ pub fn start_server_impl( } // Handle regular client removal remove_client!(client_id, os_input, session_state, session_data); - if let Some(session_data) = session_data.write().unwrap().as_ref() { + if let Some(session_data) = session_data.write().unwrap().as_mut() { + session_data.session_configuration.remove_client(&client_id); let _ = session_data .senders .send_to_screen(ScreenInstruction::RemoveClient(client_id)); @@ -1601,7 +1779,8 @@ pub fn start_server_impl( // by us having to wait for session_data to send cleanup // signals to the various threads for client_id in client_ids { - if let Some(session_data) = session_data.write().unwrap().as_ref() { + if let Some(session_data) = session_data.write().unwrap().as_mut() { + session_data.session_configuration.remove_client(&client_id); let _ = session_data .senders .send_to_screen(ScreenInstruction::RemoveClient(client_id)); @@ -1736,6 +1915,9 @@ pub fn start_server_impl( remove_client!(client_id, os_input, session_state, session_data); drop(completion_tx); // do not deadlock with route thread + if let Some(session_data) = session_data.write().unwrap().as_mut() { + session_data.session_configuration.remove_client(&client_id); + } session_data .write() .unwrap() @@ -2190,7 +2372,7 @@ fn init_session( .unwrap_or_else(|| get_default_shell()); let default_mode = config_options.default_mode.unwrap_or_default(); - let default_keybinds = config.keybinds.clone(); + let default_keybinds: SharedKeybinds = Arc::new(config.keybinds.to_keybinds_vec()); let pty_thread = thread::Builder::new() .name("pty".to_string()) @@ -2236,6 +2418,7 @@ fn init_session( let debug = cli_assets.is_debug; let layout = layout.clone(); let config = config.clone(); + let default_keybinds = default_keybinds.clone(); move || { screen_thread_main( screen_bus, @@ -2244,6 +2427,7 @@ fn init_session( config, debug, layout, + default_keybinds, ) .fatal(); } diff --git a/zellij-server/src/os_input_output.rs b/zellij-server/src/os_input_output.rs index 8e78e4eeb..3262bd35a 100644 --- a/zellij-server/src/os_input_output.rs +++ b/zellij-server/src/os_input_output.rs @@ -30,7 +30,10 @@ use std::{ io::{self, Write}, path::PathBuf, process::Command, - sync::{Arc, Mutex}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, }; pub use async_trait::async_trait; @@ -211,10 +214,58 @@ fn build_command( // this client and we'll stop sending messages to it. // If the client ever becomes responsive again, we'll send one final "Buffer full" message so it // knows what happened. +const CLIENT_BUFFER_LIMIT: usize = 5000; + +#[derive(Clone)] +struct ClientBuffer { + sender: channels::Sender, + queued: Arc, + limit: usize, +} + +struct ClientBufferReceiver { + receiver: channels::Receiver, + queued: Arc, +} + +fn client_buffer(limit: usize) -> (ClientBuffer, ClientBufferReceiver) { + let (sender, receiver) = channels::unbounded(); + let queued = Arc::new(AtomicUsize::new(0)); + ( + ClientBuffer { + sender, + queued: queued.clone(), + limit, + }, + ClientBufferReceiver { receiver, queued }, + ) +} + +impl ClientBuffer { + fn try_send(&self, msg: ServerToClientMsg) -> Result<(), TrySendError> { + if self.queued.fetch_add(1, Ordering::AcqRel) >= self.limit { + self.queued.fetch_sub(1, Ordering::AcqRel); + return Err(TrySendError::Full(msg)); + } + self.sender.send(msg).map_err(|err| { + self.queued.fetch_sub(1, Ordering::AcqRel); + TrySendError::Disconnected(err.0) + }) + } +} + +impl ClientBufferReceiver { + fn recv(&self) -> Option { + let msg = self.receiver.recv().ok()?; + self.queued.fetch_sub(1, Ordering::AcqRel); + Some(msg) + } +} + #[derive(Clone)] struct ClientSender { client_id: ClientId, - client_buffer_sender: channels::Sender, + client_buffer_sender: ClientBuffer, } impl ClientSender { @@ -229,10 +280,10 @@ impl ClientSender { // We, the zellij maintainers, have decided against an unbounded // queue for the time being because we want to prevent e.g. the whole session being killed // (by OOM-killers or some other mechanism) just because a single client doesn't respond. - let (client_buffer_sender, client_buffer_receiver) = channels::bounded(5000); + let (client_buffer_sender, client_buffer_receiver) = client_buffer(CLIENT_BUFFER_LIMIT); std::thread::spawn(move || { let err_context = || format!("failed to send message to client {client_id}"); - for msg in client_buffer_receiver.iter() { + while let Some(msg) = client_buffer_receiver.recv() { sender .send_server_msg(msg) .with_context(err_context) @@ -284,13 +335,13 @@ pub(crate) struct NullAsyncReader; // used. See https://smallcultfollowing.com/babysteps/blog/2019/10/26/async-fn-in-traits-are-hard/ #[async_trait] pub trait AsyncReader: Send + Sync { - async fn read(&mut self, buf: &mut [u8]) -> Result; + async fn read_chunk(&mut self, max: usize) -> Result, io::Error>; } #[async_trait] impl AsyncReader for NullAsyncReader { - async fn read(&mut self, _buf: &mut [u8]) -> Result { - Ok(0) // EOF + async fn read_chunk(&mut self, _max: usize) -> Result, io::Error> { + Ok(Vec::new()) } } diff --git a/zellij-server/src/os_input_output_unix.rs b/zellij-server/src/os_input_output_unix.rs index bfebd7b59..57e529187 100644 --- a/zellij-server/src/os_input_output_unix.rs +++ b/zellij-server/src/os_input_output_unix.rs @@ -83,18 +83,19 @@ impl RawFdAsyncReader { #[async_trait] impl AsyncReader for RawFdAsyncReader { - async fn read(&mut self, buf: &mut [u8]) -> Result { + async fn read_chunk(&mut self, max: usize) -> Result, io::Error> { let async_fd = self.get_async_fd()?; loop { let mut guard = async_fd.readable().await?; match guard.try_io(|inner| { + let mut buf = vec![0u8; max]; let fd = inner.get_ref().as_raw_fd(); let ret = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; if ret < 0 { Err(io::Error::last_os_error()) } else { - Ok(ret as usize) + Ok(buf[..ret as usize].to_vec()) } }) { Ok(result) => return result, @@ -469,7 +470,43 @@ mod tests { use super::*; use nix::fcntl::{fcntl, FcntlArg, OFlag}; use nix::sys::termios; - use std::io::Read; + use std::io::{Read, Write}; + + #[test] + fn raw_fd_reader_returns_exact_bytes_in_order() { + let mut fds = [0 as libc::c_int; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + let mut writer = unsafe { File::from_raw_fd(fds[1]) }; + let mut reader = RawFdAsyncReader::new(fds[0]).expect("reader"); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + writer.write_all(b"hello").unwrap(); + let chunk = reader.read_chunk(65536).await.unwrap(); + assert_eq!(chunk, b"hello"); + assert_eq!(chunk.capacity(), chunk.len()); + + writer.write_all(b"abc").unwrap(); + writer.write_all(b"def").unwrap(); + writer.write_all(b"ghij").unwrap(); + let mut collected = Vec::new(); + while collected.len() < 10 { + let chunk = reader.read_chunk(4).await.unwrap(); + assert!(!chunk.is_empty() && chunk.len() <= 4); + assert_eq!(chunk.capacity(), chunk.len()); + collected.extend_from_slice(&chunk); + } + assert_eq!(collected, b"abcdefghij"); + + drop(writer); + let eof = reader.read_chunk(65536).await.unwrap(); + assert!(eof.is_empty()); + }); + } /// Verify that `try_write_to_fd` writes as many bytes as the kernel will /// accept in one pass and returns a partial count (not an error) when the diff --git a/zellij-server/src/os_input_output_windows.rs b/zellij-server/src/os_input_output_windows.rs index 068750a4f..0e0759e94 100644 --- a/zellij-server/src/os_input_output_windows.rs +++ b/zellij-server/src/os_input_output_windows.rs @@ -13,7 +13,6 @@ use std::{ }, }; -use tokio::io::AsyncReadExt; use tokio::net::windows::named_pipe::NamedPipeServer; use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, S_OK}; @@ -98,16 +97,30 @@ impl ConPtyAsyncReader { #[async_trait] impl AsyncReader for ConPtyAsyncReader { - async fn read(&mut self, buf: &mut [u8]) -> Result { + async fn read_chunk(&mut self, max: usize) -> Result, io::Error> { + let pipe = self.pipe()?; + loop { + pipe.readable().await?; + let mut buf = vec![0u8; max]; + match pipe.try_read(&mut buf) { + Ok(n) => return Ok(buf[..n].to_vec()), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue, + Err(e) => return Err(e), + } + } + } +} + +impl ConPtyAsyncReader { + fn pipe(&mut self) -> Result<&mut NamedPipeServer, io::Error> { if let Some(handle) = self.pending.take() { let pipe = unsafe { NamedPipeServer::from_raw_handle(handle.into_raw_handle()) }?; self.pipe = Some(pipe); } - let pipe = self + Ok(self .pipe .as_mut() - .expect("ConPtyAsyncReader used after init"); - pipe.read(buf).await + .expect("ConPtyAsyncReader used after init")) } } diff --git a/zellij-server/src/panes/floating_panes/mod.rs b/zellij-server/src/panes/floating_panes/mod.rs index bff23a035..228157cf4 100644 --- a/zellij-server/src/panes/floating_panes/mod.rs +++ b/zellij-server/src/panes/floating_panes/mod.rs @@ -24,7 +24,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::rc::Rc; use std::time::Instant; use zellij_utils::{ - data::{ModeInfo, Style, Styling}, + data::{InputMode, ModeInfo, Style, Styling}, errors::prelude::*, input::command::RunCommand, input::layout::{FloatingPaneLayout, Run, RunPluginOrAlias}, @@ -44,7 +44,7 @@ pub struct FloatingPanes { client_display_slots: Rc>>, mode_info: Rc>>, character_cell_size: Rc>>, - default_mode_info: ModeInfo, + default_mode: InputMode, style: Style, session_is_mirrored: bool, desired_pane_positions: HashMap, // this represents the positions of panes the user moved with intention, rather than by resizing the terminal window @@ -77,7 +77,7 @@ impl FloatingPanes { fullscreen_covers_ui: Rc>, pane_frame_style: PaneFrameStyle, session_is_mirrored: bool, - default_mode_info: ModeInfo, + default_mode: InputMode, style: Style, os_input: Box, senders: ThreadSenders, @@ -92,7 +92,7 @@ impl FloatingPanes { mode_info, character_cell_size, session_is_mirrored, - default_mode_info, + default_mode, style, desired_pane_positions: HashMap::new(), z_indices: vec![], @@ -608,8 +608,8 @@ impl FloatingPanes { .mode_info .borrow() .get(client_id) - .unwrap_or(&self.default_mode_info) - .mode; + .map(|mode_info| mode_info.mode) + .unwrap_or(self.default_mode); let is_floating = true; let skip_frame = no_ui_fullscreen || (pane_is_regular_fullscreen diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs index ef33211ca..0c021d4e8 100644 --- a/zellij-server/src/panes/grid.rs +++ b/zellij-server/src/panes/grid.rs @@ -763,6 +763,40 @@ fn highlight_match<'t>(captures: ®ex::Captures<'t>) -> Option( + regex: &'r regex::Regex, + text: &'t str, +) -> impl Iterator> + 'r +where + 't: 'r, +{ + let mut pos = 0; + std::iter::from_fn(move || { + while pos <= text.len() { + let captures = regex.captures_at(text, pos)?; + let mat = highlight_match(&captures); + let end = match (mat, captures.get(0)) { + (Some(m), _) => m.end(), + (None, Some(whole)) => whole.end(), + (None, None) => return None, + }; + pos = if end > pos { + end + } else { + text[pos..] + .chars() + .next() + .map(|c| pos + c.len_utf8()) + .unwrap_or(text.len() + 1) + }; + if mat.is_some() { + return mat; + } + } + None + }) +} + /// Check whether a (row, col) position falls within a display span. /// The span is inclusive at start and exclusive at end. fn position_in_span( @@ -2944,10 +2978,7 @@ impl Grid { )> = None; for (plugin_id, pattern_map) in &self.plugin_highlights { for (pattern, compiled) in pattern_map { - for captures in compiled.regex.captures_iter(&logical_text) { - let Some(mat) = highlight_match(&captures) else { - continue; - }; + for mat in highlight_matches(&compiled.regex, &logical_text) { if let Some((_sel, start_row, start_col, end_row, end_col)) = match_to_selection(&mat, &boundaries, &self.viewport) { @@ -3045,10 +3076,7 @@ impl Grid { if !compiled.on_hover || compiled.tooltip_text.is_none() { continue; } - for captures in compiled.regex.captures_iter(&logical_text) { - let Some(mat) = highlight_match(&captures) else { - continue; - }; + for mat in highlight_matches(&compiled.regex, &logical_text) { if let Some((_sel, start_row, start_col, end_row, end_col)) = match_to_selection(&mat, &boundaries, &self.viewport) { @@ -3104,10 +3132,7 @@ impl Grid { if !compiled.on_hover || !compiled.has_visual_effect() { continue; } - for captures in compiled.regex.captures_iter(&logical_text) { - let Some(mat) = highlight_match(&captures) else { - continue; - }; + for mat in highlight_matches(&compiled.regex, &logical_text) { if let Some((sel, start_row, start_col, end_row, end_col)) = match_to_selection(&mat, &boundaries, &self.viewport) { @@ -3141,10 +3166,7 @@ impl Grid { if compiled.on_hover || !compiled.has_visual_effect() { continue; } - for captures in compiled.regex.captures_iter(&logical_text) { - let Some(mat) = highlight_match(&captures) else { - continue; - }; + for mat in highlight_matches(&compiled.regex, &logical_text) { if let Some((sel, _, _, _, _)) = match_to_selection(&mat, &boundaries, &self.viewport) { diff --git a/zellij-server/src/panes/tiled_panes/mod.rs b/zellij-server/src/panes/tiled_panes/mod.rs index a186d9773..17be24d47 100644 --- a/zellij-server/src/panes/tiled_panes/mod.rs +++ b/zellij-server/src/panes/tiled_panes/mod.rs @@ -29,8 +29,8 @@ use crate::{ use stacked_panes::StackedPanes; use zellij_utils::{ data::{ - BorderStyle, BorderStyleOverride, Direction, LineStyle, ModeInfo, PaneInfo, Resize, - ResizeStrategy, Style, Styling, + BorderStyle, BorderStyleOverride, Direction, InputMode, LineStyle, ModeInfo, PaneInfo, + Resize, ResizeStrategy, Style, Styling, }, errors::prelude::*, input::{ @@ -78,7 +78,7 @@ pub struct TiledPanes { character_cell_size: Rc>>, stacked_resize: Rc>, reserved_top_rows: Rc>>, - default_mode_info: ModeInfo, + default_mode: InputMode, style: Style, session_is_mirrored: bool, active_panes: ActivePanes, @@ -109,7 +109,7 @@ impl TiledPanes { fullscreen_covers_ui: Rc>, session_is_mirrored: bool, pane_frame_style: PaneFrameStyle, - default_mode_info: ModeInfo, + default_mode: InputMode, style: Style, os_api: Box, senders: ThreadSenders, @@ -125,7 +125,7 @@ impl TiledPanes { character_cell_size, stacked_resize, reserved_top_rows, - default_mode_info, + default_mode, style, session_is_mirrored, active_panes: ActivePanes::new(&os_api), @@ -1294,8 +1294,8 @@ impl TiledPanes { .mode_info .borrow() .get(client_id) - .unwrap_or(&self.default_mode_info) - .mode; + .map(|mode_info| mode_info.mode) + .unwrap_or(self.default_mode); let err_context = || format!("failed to render tiled panes for client {client_id}"); if let PaneId::Plugin(..) = kind { diff --git a/zellij-server/src/panes/unit/grid_tests.rs b/zellij-server/src/panes/unit/grid_tests.rs index ae41b3c13..6981a9f3f 100644 --- a/zellij-server/src/panes/unit/grid_tests.rs +++ b/zellij-server/src/panes/unit/grid_tests.rs @@ -5092,6 +5092,69 @@ fn plugin_highlight_at_wrapped_line() { assert_eq!(matched_string, "jklm"); } +const COMBINED_ENTRIES_PATTERN: &str = r"(?:^|\s)((?:abc\.rs|b\.rs)(?:/[A-Za-z0-9_./\-+@%,#=~!\$\{\}\[\]]+)?(?::\d+(?::\d+)?)?)(?::|\s|$)"; + +#[test] +fn combined_pattern_highlights_adjacent_entries_separated_by_single_space() { + let mut grid = create_grid_with_content("abc.rs b.rs\n"); + let highlights = vec![create_highlight( + COMBINED_ENTRIES_PATTERN, + false, + false, + false, + true, + HighlightLayer::Hint, + )]; + grid.set_plugin_regex_highlights(1, highlights, &Style::default()); + + let first = grid.plugin_highlight_at(&Position::new(0, 0)).unwrap(); + assert_eq!(first.2, "abc.rs"); + let second = grid.plugin_highlight_at(&Position::new(0, 8)).unwrap(); + assert_eq!(second.2, "b.rs"); + assert!(grid.plugin_highlight_at(&Position::new(0, 6)).is_none()); + assert_eq!(grid.compute_plugin_highlight_selections().len(), 2); +} + +#[test] +fn combined_pattern_highlights_entries_on_wrapped_line() { + let sixel_image_store = Rc::new(RefCell::new(SixelImageStore::default())); + let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new())); + let mut grid = Grid::new( + 5, + 10, + Rc::new(RefCell::new(Palette::default())), + terminal_emulator_color_codes, + Rc::new(RefCell::new(LinkHandler::new())), + Rc::new(RefCell::new(None)), + sixel_image_store, + Rc::new(RefCell::new(KittyImageStore::default())), + Style::default(), + false, + true, + true, + true, + false, + ); + let mut vte_parser = vte::Parser::new(); + vte_parser.advance(&mut grid, "12345 abc.rs b.rs".as_bytes()); + + let highlights = vec![create_highlight( + COMBINED_ENTRIES_PATTERN, + false, + false, + false, + true, + HighlightLayer::Hint, + )]; + grid.set_plugin_regex_highlights(1, highlights, &Style::default()); + + let wrapped = grid.plugin_highlight_at(&Position::new(1, 0)).unwrap(); + assert_eq!(wrapped.2, "abc.rs"); + let after_wrap = grid.plugin_highlight_at(&Position::new(1, 4)).unwrap(); + assert_eq!(after_wrap.2, "b.rs"); + assert_eq!(grid.compute_plugin_highlight_selections().len(), 2); +} + #[test] fn hover_position_triggers_on_hover_highlight() { let mut grid = create_grid_with_content("hello link_text bar\n"); diff --git a/zellij-server/src/plugins/mod.rs b/zellij-server/src/plugins/mod.rs index a476380b8..9e012d778 100644 --- a/zellij-server/src/plugins/mod.rs +++ b/zellij-server/src/plugins/mod.rs @@ -3,6 +3,7 @@ mod pipes; mod plugin_loader; mod plugin_map; mod plugin_worker; +mod shared; mod wasm_bridge; mod watch_filesystem; mod zellij_exports; @@ -21,7 +22,7 @@ use crate::panes::PaneId; use crate::route::NotificationEnd; use crate::screen::ScreenInstruction; use crate::session_layout_metadata::SessionLayoutMetadata; -use crate::{pty::PtyInstruction, thread_bus::Bus, ClientId, ServerInstruction}; +use crate::{pty::PtyInstruction, thread_bus::Bus, ClientId, ServerInstruction, SharedKeybinds}; use zellij_utils::data::PaneRenderReport; use zellij_utils::input::layout::TabLayoutInfo; @@ -38,7 +39,6 @@ use zellij_utils::{ input::{ actions::Action, command::TerminalAction, - keybinds::Keybinds, layout::{FloatingPaneLayout, Layout, Run, RunPlugin, RunPluginOrAlias, TiledPaneLayout}, plugins::PluginAliases, }, @@ -89,6 +89,7 @@ pub enum PluginInstruction { AddClient(ClientId), RemoveClient(ClientId), UpdatePluginTabIndices(Vec<(PluginId, usize)>), + UpdateClientVisiblePlugins(HashMap>), NewTab( Option, Option, @@ -185,7 +186,7 @@ pub enum PluginInstruction { UnblockCliPipes(Vec), Reconfigure { client_id: ClientId, - keybinds: Option, + keybinds: Option, default_mode: Option, default_shell: Option, layout_dir: Option, @@ -238,6 +239,9 @@ impl From<&PluginInstruction> for PluginContext { PluginInstruction::AddClient(_) => PluginContext::AddClient, PluginInstruction::RemoveClient(_) => PluginContext::RemoveClient, PluginInstruction::UpdatePluginTabIndices(..) => PluginContext::UpdatePluginTabIndices, + PluginInstruction::UpdateClientVisiblePlugins(..) => { + PluginContext::UpdateClientVisiblePlugins + }, PluginInstruction::NewTab(..) => PluginContext::NewTab, PluginInstruction::OverrideLayout(..) => PluginContext::OverrideLayout, PluginInstruction::ApplyCachedEvents { .. } => PluginContext::ApplyCachedEvents, @@ -304,7 +308,7 @@ pub(crate) fn plugin_thread_main( default_shell: Option, plugin_aliases: PluginAliases, default_mode: InputMode, - default_keybinds: Keybinds, + default_keybinds: SharedKeybinds, background_plugins: HashSet, // the client id that started the session, // we need it here because the thread's own list of connected clients might not yet be updated @@ -515,6 +519,9 @@ pub(crate) fn plugin_thread_main( PluginInstruction::UpdatePluginTabIndices(tab_indices) => { wasm_bridge.update_plugin_tab_indices(tab_indices); }, + PluginInstruction::UpdateClientVisiblePlugins(visible_plugins) => { + wasm_bridge.update_client_visible_plugins(visible_plugins); + }, PluginInstruction::NewTab( cwd, terminal_action, @@ -1353,6 +1360,13 @@ fn pipe_to_all_plugins( PipeMessage::new(pipe_source.clone(), name, payload, &args, is_private), )); } + for instance_id in wasm_bridge.shared_instance_ids() { + pipe_messages.push(( + Some(instance_id), + None, + PipeMessage::new(pipe_source.clone(), name, payload, &args, is_private), + )); + } } fn pipe_to_specific_plugins( @@ -1433,13 +1447,15 @@ fn load_background_plugin( let run_plugin = run_plugin_or_alias.get_run_plugin(); let size = Size::default(); let skip_cache = false; - match wasm_bridge.load_plugin( + let is_background = true; + match wasm_bridge.load_plugin_with_kind( &run_plugin, None, size, cwd.clone(), skip_cache, Some(client_id), + is_background, ) { Ok((plugin_id, client_id)) => { let should_float = None; @@ -1475,3 +1491,7 @@ const EXIT_TIMEOUT: Duration = Duration::from_secs(3); #[path = "./unit/plugin_tests.rs"] #[cfg(test)] mod plugin_tests; + +#[path = "./unit/shared_plugin_tests.rs"] +#[cfg(test)] +mod shared_plugin_tests; diff --git a/zellij-server/src/plugins/plugin_loader.rs b/zellij-server/src/plugins/plugin_loader.rs index 8406ee606..af3c61192 100644 --- a/zellij-server/src/plugins/plugin_loader.rs +++ b/zellij-server/src/plugins/plugin_loader.rs @@ -1,5 +1,6 @@ use crate::plugins::plugin_map::{ - PluginEnv, PluginMap, PluginMetadata, RunningPlugin, VecDequeInputStream, WriteOutputStream, + PluginEnv, PluginMap, PluginMetadata, RunningPlugin, SharedEnv, VecDequeInputStream, + WriteOutputStream, }; use crate::plugins::plugin_worker::{plugin_worker, RunningWorker}; use crate::plugins::wasm_bridge::{LoadingContext, PluginCache}; @@ -20,13 +21,13 @@ use wasmi_wasi::WasiCtx; use crate::{ logging_pipe::LoggingPipe, thread_bus::ThreadSenders, - ui::loading_indication::LoadingIndication, ClientId, + ui::loading_indication::LoadingIndication, ClientId, SharedKeybinds, }; use zellij_utils::plugin_api::action::ProtobufPluginConfiguration; use zellij_utils::{ consts::ZELLIJ_TMP_DIR, data::InputMode, errors::prelude::*, input::command::TerminalAction, - input::keybinds::Keybinds, input::plugins::PluginConfig, pane_size::Size, + input::plugins::PluginConfig, pane_size::Size, }; /// Open a directory as a `File` handle for WASI pre-opening. @@ -73,7 +74,7 @@ pub struct PluginLoader<'a> { default_shell: Option, layout_dir: Option, default_mode: InputMode, - keybinds: Keybinds, + keybinds: SharedKeybinds, plugin_dir: PathBuf, size: Size, loading_indication: LoadingIndication, @@ -140,10 +141,21 @@ impl<'a> PluginLoader<'a> { .or_else(|_e| self.interpret_module())? }; let (store, instance) = self.create_plugin_environment(module)?; - self.load_plugin_instance(store, &instance)?; + self.load_plugin_instance(store, &instance, false)?; self.clone_instance_for_other_clients()?; Ok(()) } + pub fn start_shared_plugin(&mut self) -> Result>> { + let module = if self.skip_cache { + self.interpret_module()? + } else { + self.load_module_from_memory() + .or_else(|_e| self.interpret_module())? + }; + let (mut store, instance) = self.create_plugin_environment(module)?; + store.data_mut().shared = Some(SharedEnv::default()); + self.load_plugin_instance(store, &instance, true) + } fn record_plugin_metadata(&mut self) { self.plugin_map.insert_metadata( self.plugin_id, @@ -183,7 +195,8 @@ impl<'a> PluginLoader<'a> { &mut self, mut store: Store, instance: &Instance, - ) -> Result<()> { + shared: bool, + ) -> Result>> { let err_context = || format!("failed to load plugin from instance {instance:#?}"); let main_user_instance = instance.clone(); let start_function = instance @@ -220,13 +233,18 @@ impl<'a> PluginLoader<'a> { self.size.rows, self.size.cols, ))); - self.plugin_map.insert( - self.plugin_id, - self.client_id, - plugin.clone(), - subscriptions, - workers, - ); + if shared { + self.plugin_map + .insert_shared(self.plugin_id, (plugin.clone(), subscriptions, workers)); + } else { + self.plugin_map.insert( + self.plugin_id, + self.client_id, + plugin.clone(), + subscriptions, + workers, + ); + } start_function .call(&mut plugin.lock().unwrap().store, ()) @@ -245,7 +263,7 @@ impl<'a> PluginLoader<'a> { .call(&mut plugin.lock().unwrap().store, ()) .with_context(err_context)?; - Ok(()) + Ok(plugin) } pub fn create_plugin_environment( &self, @@ -294,6 +312,7 @@ impl<'a> PluginLoader<'a> { stdin_pipe, stdout_pipe, store_limits: create_optimized_store_limits(), + shared: None, }; let mut store = Store::new(&self.engine, plugin_env); @@ -405,6 +424,7 @@ impl<'a> PluginLoader<'a> { stdin_pipe, stdout_pipe, store_limits: create_optimized_store_limits(), + shared: None, }; let mut store = Store::new(&self.engine, plugin_env); diff --git a/zellij-server/src/plugins/plugin_map.rs b/zellij-server/src/plugins/plugin_map.rs index 14558a188..716478260 100644 --- a/zellij-server/src/plugins/plugin_map.rs +++ b/zellij-server/src/plugins/plugin_map.rs @@ -2,21 +2,20 @@ use crate::plugins::plugin_worker::MessageToWorker; use crate::plugins::PluginId; use std::io::Write; use std::{ - collections::{BTreeMap, HashMap, HashSet, VecDeque}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}, path::PathBuf, sync::{Arc, Mutex}, }; use wasmi::{Instance, Store, StoreLimits}; use wasmi_wasi::WasiCtx; -use crate::{thread_bus::ThreadSenders, ClientId}; +use crate::{thread_bus::ThreadSenders, ClientId, SharedKeybinds}; use tokio::sync::mpsc::UnboundedSender; use zellij_utils::{ - data::EventType, data::InputMode, + data::{Event, EventType, RenderResponse, SlotKind}, input::command::TerminalAction, - input::keybinds::Keybinds, input::layout::{PluginUserConfiguration, RunPlugin, RunPluginLocation}, input::plugins::PluginConfig, }; @@ -49,6 +48,7 @@ pub struct PluginMetadata { pub struct PluginMap { plugin_assets: HashMap, plugin_metadata: HashMap, + shared_assets: HashMap, } impl PluginMap { @@ -77,6 +77,25 @@ impl PluginMap { } removed } + pub fn insert_shared(&mut self, instance_id: PluginId, assets: PluginAssets) { + self.shared_assets.insert(instance_id, assets); + } + pub fn remove_shared(&mut self, instance_id: PluginId) -> Option { + self.shared_assets.remove(&instance_id) + } + pub fn shared_running_plugin( + &self, + instance_id: PluginId, + ) -> Option<(Arc>, Arc>)> { + self.shared_assets + .get(&instance_id) + .map(|(running_plugin, subscriptions, _)| { + (running_plugin.clone(), subscriptions.clone()) + }) + } + pub fn remove_metadata(&mut self, plugin_id: PluginId) { + self.plugin_metadata.remove(&plugin_id); + } pub fn insert_metadata(&mut self, plugin_id: PluginId, metadata: PluginMetadata) { self.plugin_metadata.insert(plugin_id, metadata); } @@ -168,6 +187,9 @@ impl PluginMap { client_id: ClientId, worker_name: &str, ) -> Option> { + if let Some((_, _, workers)) = self.shared_assets.get(&plugin_id) { + return workers.get(&format!("{}_worker", worker_name)).cloned(); + } self.plugin_assets .iter() .find(|((p_id, c_id), _)| p_id == &plugin_id && c_id == &client_id) @@ -311,9 +333,80 @@ pub struct PluginEnv { pub subscriptions: Arc>, pub stdin_pipe: Arc>>, pub stdout_pipe: Arc>>, - pub keybinds: Keybinds, + pub keybinds: SharedKeybinds, pub intercepting_key_presses: bool, pub store_limits: StoreLimits, + pub shared: Option, +} + +#[derive(Debug, Clone)] +pub struct SharedSlot { + pub kind: SlotKind, + pub configuration: BTreeMap, + pub rows: usize, + pub columns: usize, +} + +#[derive(Debug, Clone, Default)] +pub struct SharedEnv { + pub current_slot: Option, + pub current_client: Option, + pub self_commands_allowed: bool, + pub slots: BTreeMap, + pub clients: BTreeSet, + pub visible_slots: HashMap>, + pub last_events: HashMap<(EventType, Option), (Event, Option)>, +} + +impl SharedEnv { + pub fn first_pane_slot(&self) -> Option { + self.slots + .iter() + .find(|(_, slot)| slot.kind == SlotKind::Pane) + .map(|(id, _)| *id) + } + pub fn pane_slot_ids(&self) -> Vec { + self.slots + .iter() + .filter(|(_, slot)| slot.kind == SlotKind::Pane) + .map(|(id, _)| *id) + .collect() + } + pub fn client_can_see(&self, client_id: ClientId, slot_id: PluginId) -> bool { + match self.visible_slots.get(&client_id) { + Some(visible) => visible.contains(&slot_id), + None => true, + } + } + pub fn renderable_slot(&self, slot_id: PluginId) -> Option<(usize, usize)> { + self.slots.get(&slot_id).and_then(|slot| { + if slot.kind == SlotKind::Pane && slot.rows > 0 && slot.columns > 0 { + Some((slot.rows, slot.columns)) + } else { + None + } + }) + } + pub fn render_targets(&self, render: &RenderResponse) -> Vec<(PluginId, ClientId)> { + let mut targets = vec![]; + for client_id in self.clients.iter() { + for slot_id in self.slots.keys() { + let wanted = match render { + RenderResponse::Nothing => false, + RenderResponse::All => true, + RenderResponse::Client(c) => c == client_id, + RenderResponse::Slots(slot_ids) => slot_ids.contains(slot_id), + }; + if wanted + && self.renderable_slot(*slot_id).is_some() + && self.client_can_see(*client_id, *slot_id) + { + targets.push((*slot_id, *client_id)); + } + } + } + targets + } } #[derive(Clone)] @@ -412,7 +505,7 @@ impl RunningPlugin { false } } - pub fn update_keybinds(&mut self, keybinds: Keybinds) { + pub fn update_keybinds(&mut self, keybinds: SharedKeybinds) { self.store.data_mut().keybinds = keybinds; } pub fn update_default_mode(&mut self, default_mode: InputMode) { diff --git a/zellij-server/src/plugins/shared.rs b/zellij-server/src/plugins/shared.rs new file mode 100644 index 000000000..9742369d4 --- /dev/null +++ b/zellij-server/src/plugins/shared.rs @@ -0,0 +1,913 @@ +use super::PluginId; +use crate::background_jobs::BackgroundJob; +use crate::plugins::pipes::{pipes_to_block_or_unblock, PipeStateChange}; +use crate::plugins::plugin_loader::PluginLoader; +use crate::plugins::plugin_map::{PluginMap, RunningPlugin, SharedSlot, Subscriptions}; +use crate::plugins::plugin_worker::MessageToWorker; +use crate::plugins::wasm_bridge::{ + check_event_permission, handle_plugin_crash, handle_plugin_loading_failure, + handle_plugin_successful_loading, LoadingContext, PluginCache, PluginRenderAsset, +}; +use crate::plugins::zellij_exports::{wasi_read_bytes, wasi_read_string, wasi_write_object}; +use crate::plugins::PluginInstruction; +use crate::screen::ScreenInstruction; +use crate::ui::loading_indication::LoadingIndication; +use crate::{thread_bus::ThreadSenders, ClientId, SharedKeybinds}; +use prost::Message; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; +use wasmi::Engine; +use zellij_utils::data::KeybindsVec; +use zellij_utils::data::{ + Event, EventContext, EventType, PermissionStatus, PipeMessage, PipeSource, RenderResponse, Slot, +}; +use zellij_utils::errors::prelude::*; +use zellij_utils::input::command::TerminalAction; +use zellij_utils::input::layout::RunPluginLocation; +use zellij_utils::plugin_api::event::{event_to_protobuf_with_keybinds, ProtobufEvent}; +use zellij_utils::plugin_api::pipe_message::ProtobufPipeMessage; +use zellij_utils::plugin_api::shared_plugin::{ + ProtobufEventContext, ProtobufRenderRequest, ProtobufSlot, +}; + +pub const SHARED_PIPE_CLIENT: ClientId = ClientId::MAX; +pub const SHARED_MARKER_EXPORT: &str = "zellij_shared_plugin"; + +pub type SharedKey = (RunPluginLocation, Option); + +pub type SharedEventBatchEntry = (Event, EventContext, Option); + +fn read_leb_u32(bytes: &[u8], position: &mut usize) -> Option { + let mut result: u32 = 0; + let mut shift = 0; + loop { + let byte = *bytes.get(*position)?; + *position += 1; + result |= ((byte & 0x7f) as u32).checked_shl(shift)?; + if byte & 0x80 == 0 { + return Some(result); + } + shift += 7; + if shift > 28 { + return None; + } + } +} + +pub fn wasm_module_exports_function(bytes: &[u8], name: &str) -> bool { + if bytes.len() < 8 || &bytes[0..4] != b"\0asm" { + return false; + } + let mut position = 8; + while position < bytes.len() { + let section_id = bytes[position]; + position += 1; + let Some(section_size) = read_leb_u32(bytes, &mut position) else { + return false; + }; + let section_end = position + section_size as usize; + if section_end > bytes.len() { + return false; + } + if section_id == 7 { + let mut cursor = position; + let Some(count) = read_leb_u32(bytes, &mut cursor) else { + return false; + }; + for _ in 0..count { + let Some(name_len) = read_leb_u32(bytes, &mut cursor) else { + return false; + }; + let name_end = cursor + name_len as usize; + if name_end > section_end { + return false; + } + let export_name = &bytes[cursor..name_end]; + cursor = name_end; + let Some(kind) = bytes.get(cursor).copied() else { + return false; + }; + cursor += 1; + if read_leb_u32(bytes, &mut cursor).is_none() { + return false; + } + if kind == 0 && export_name == name.as_bytes() { + return true; + } + } + return false; + } + position = section_end; + } + false +} + +fn set_call_context( + running_plugin: &mut RunningPlugin, + slot_id: Option, + client_id: Option, + self_commands_allowed: bool, +) { + if let Some(shared) = running_plugin.store.data_mut().shared.as_mut() { + shared.current_slot = slot_id; + shared.current_client = client_id; + shared.self_commands_allowed = self_commands_allowed; + } +} + +fn clear_call_context(running_plugin: &mut RunningPlugin) { + set_call_context(running_plugin, None, None, false); +} + +fn call_with_i32(running_plugin: &mut RunningPlugin, name: &str, value: i32) -> Result<()> { + let instance = running_plugin.instance.clone(); + instance + .get_typed_func::(&mut running_plugin.store, name) + .and_then(|function| function.call(&mut running_plugin.store, value)) + .map_err(|e| anyhow!(e)) + .with_context(|| format!("failed to call {} on shared plugin", name)) +} + +fn call_without_args(running_plugin: &mut RunningPlugin, name: &str) -> Result<()> { + let instance = running_plugin.instance.clone(); + instance + .get_typed_func::<(), ()>(&mut running_plugin.store, name) + .and_then(|function| function.call(&mut running_plugin.store, ())) + .map_err(|e| anyhow!(e)) + .with_context(|| format!("failed to call {} on shared plugin", name)) +} + +fn call_returning_i32(running_plugin: &mut RunningPlugin, name: &str) -> Result { + let instance = running_plugin.instance.clone(); + instance + .get_typed_func::<(), i32>(&mut running_plugin.store, name) + .and_then(|function| function.call(&mut running_plugin.store, ())) + .map_err(|e| anyhow!(e)) + .with_context(|| format!("failed to call {} on shared plugin", name)) +} + +pub fn pane_slot_ids(running_plugin: &RunningPlugin) -> Vec { + running_plugin + .store + .data() + .shared + .as_ref() + .map(|shared| shared.pane_slot_ids()) + .unwrap_or_default() +} + +pub fn call_client_connected( + running_plugin: &mut RunningPlugin, + client_id: ClientId, +) -> Result<()> { + let is_new = running_plugin + .store + .data_mut() + .shared + .as_mut() + .map(|shared| shared.clients.insert(client_id)) + .unwrap_or(false); + if !is_new { + return Ok(()); + } + set_call_context(running_plugin, None, Some(client_id), false); + let result = call_with_i32(running_plugin, "client_connected", client_id as i32); + clear_call_context(running_plugin); + result +} + +pub fn call_client_disconnected( + running_plugin: &mut RunningPlugin, + client_id: ClientId, +) -> Result<()> { + let was_connected = running_plugin + .store + .data_mut() + .shared + .as_mut() + .map(|shared| { + shared.visible_slots.remove(&client_id); + shared + .last_events + .retain(|(_, event_client), _| *event_client != Some(client_id)); + shared.clients.remove(&client_id) + }) + .unwrap_or(false); + if !was_connected { + return Ok(()); + } + call_with_i32(running_plugin, "client_disconnected", client_id as i32) +} + +pub fn call_slot_added( + running_plugin: &mut RunningPlugin, + slot_id: PluginId, + slot: SharedSlot, +) -> Result<()> { + if let Some(shared) = running_plugin.store.data_mut().shared.as_mut() { + shared.slots.insert(slot_id, slot.clone()); + } + let protobuf_slot: ProtobufSlot = Slot { + id: slot_id, + kind: slot.kind, + configuration: slot.configuration, + } + .into(); + wasi_write_object(running_plugin.store.data(), &protobuf_slot.encode_to_vec())?; + set_call_context(running_plugin, Some(slot_id), None, true); + let result = call_without_args(running_plugin, "slot_added"); + clear_call_context(running_plugin); + result +} + +pub fn call_slot_removed(running_plugin: &mut RunningPlugin, slot_id: PluginId) -> Result { + let slots_left = match running_plugin.store.data_mut().shared.as_mut() { + Some(shared) => { + if shared.slots.remove(&slot_id).is_none() { + return Ok(!shared.slots.is_empty()); + } + for visible in shared.visible_slots.values_mut() { + visible.remove(&slot_id); + } + !shared.slots.is_empty() + }, + None => false, + }; + call_with_i32(running_plugin, "slot_removed", slot_id as i32)?; + Ok(slots_left) +} + +fn read_render_request(running_plugin: &RunningPlugin) -> RenderResponse { + match wasi_read_bytes(running_plugin.store.data()) + .and_then(|bytes| ProtobufRenderRequest::decode(bytes.as_slice()).map_err(|e| anyhow!(e))) + { + Ok(request) => RenderResponse::from(request), + Err(e) => { + log::error!("Failed to read render request from shared plugin: {:?}", e); + RenderResponse::All + }, + } +} + +fn render_from_return_value(running_plugin: &RunningPlugin, value: i32) -> RenderResponse { + match value { + 0 => RenderResponse::Nothing, + 1 => RenderResponse::All, + _ => read_render_request(running_plugin), + } +} + +pub fn call_update( + running_plugin: &mut RunningPlugin, + event: &Event, + keybinds: Option<&KeybindsVec>, + context: EventContext, +) -> Result { + let protobuf_context: ProtobufEventContext = context.into(); + let protobuf_event: ProtobufEvent = event_to_protobuf_with_keybinds(event.clone(), keybinds) + .map_err(|e| anyhow!("Failed to convert to protobuf: {:?}", e))?; + wasi_write_object( + running_plugin.store.data(), + &protobuf_context.encode_to_vec(), + )?; + wasi_write_object(running_plugin.store.data(), &protobuf_event.encode_to_vec())?; + set_call_context(running_plugin, context.slot_id, context.client_id, false); + let result = call_returning_i32(running_plugin, "shared_update"); + clear_call_context(running_plugin); + Ok(render_from_return_value(running_plugin, result?)) +} + +pub fn call_pipe( + running_plugin: &mut RunningPlugin, + pipe_message: &PipeMessage, + context: EventContext, +) -> Result { + let protobuf_context: ProtobufEventContext = context.into(); + let protobuf_pipe_message: ProtobufPipeMessage = pipe_message + .clone() + .try_into() + .map_err(|e| anyhow!("Failed to convert to protobuf: {:?}", e))?; + wasi_write_object( + running_plugin.store.data(), + &protobuf_context.encode_to_vec(), + )?; + wasi_write_object( + running_plugin.store.data(), + &protobuf_pipe_message.encode_to_vec(), + )?; + set_call_context(running_plugin, context.slot_id, context.client_id, false); + let result = call_returning_i32(running_plugin, "shared_pipe"); + clear_call_context(running_plugin); + Ok(render_from_return_value(running_plugin, result?)) +} + +pub fn render_targets( + running_plugin: &mut RunningPlugin, + targets: &[(PluginId, ClientId)], +) -> Result> { + let mut assets = vec![]; + for (slot_id, client_id) in targets { + let size = running_plugin + .store + .data() + .shared + .as_ref() + .and_then(|shared| shared.renderable_slot(*slot_id)); + let Some((rows, columns)) = size else { + continue; + }; + set_call_context(running_plugin, Some(*slot_id), Some(*client_id), true); + let instance = running_plugin.instance.clone(); + let result = instance + .get_typed_func::<(i32, i32, i32, i32), ()>(&mut running_plugin.store, "shared_render") + .and_then(|render| { + render.call( + &mut running_plugin.store, + ( + rows as i32, + columns as i32, + *slot_id as i32, + *client_id as i32, + ), + ) + }) + .map_err(|e| anyhow!(e)); + clear_call_context(running_plugin); + result.with_context(|| format!("failed to render shared plugin slot {}", slot_id))?; + let rendered = wasi_read_string(running_plugin.store.data())?; + assets.push(PluginRenderAsset::new( + *slot_id, + *client_id, + rendered.into_bytes(), + )); + } + Ok(assets) +} + +fn targets_for( + running_plugin: &RunningPlugin, + render: &RenderResponse, +) -> Vec<(PluginId, ClientId)> { + running_plugin + .store + .data() + .shared + .as_ref() + .map(|shared| shared.render_targets(render)) + .unwrap_or_default() +} + +fn render_and_send( + senders: &ThreadSenders, + running_plugin: &mut RunningPlugin, + targets: Vec<(PluginId, ClientId)>, +) -> Result<()> { + if targets.is_empty() { + return Ok(()); + } + let assets = render_targets(running_plugin, &targets)?; + if !assets.is_empty() { + let _ = senders.send_to_screen(ScreenInstruction::PluginBytes(assets)); + } + Ok(()) +} + +fn render_request( + senders: &ThreadSenders, + running_plugin: &mut RunningPlugin, + render: RenderResponse, +) -> Result<()> { + let targets = targets_for(running_plugin, &render); + render_and_send(senders, running_plugin, targets) +} + +fn report_error(senders: &ThreadSenders, pane_slots: Vec, error: anyhow::Error) { + log::error!("{:?}", error); + let message = format!("{:?}", error).replace("\n", "\n\r"); + for slot_id in pane_slots { + handle_plugin_crash(slot_id, message.clone(), senders.clone()); + } +} + +fn send_pipe_changes( + senders: &ThreadSenders, + running_plugin: &mut RunningPlugin, + instance_id: PluginId, + current_pipe: Option<&PipeSource>, +) { + let changes = pipes_to_block_or_unblock(running_plugin, current_pipe); + if !changes.is_empty() { + let asset = + PluginRenderAsset::new(instance_id, SHARED_PIPE_CLIENT, vec![]).with_pipes(changes); + let _ = senders.send_to_plugin(PluginInstruction::UnblockCliPipes(vec![asset])); + } +} + +fn release_pipe(senders: &ThreadSenders, instance_id: PluginId, pipe_message: &PipeMessage) { + if let PipeSource::Cli(pipe_id) = &pipe_message.source { + let mut changes = HashMap::new(); + changes.insert(pipe_id.to_owned(), PipeStateChange::NoChange); + let asset = + PluginRenderAsset::new(instance_id, SHARED_PIPE_CLIENT, vec![]).with_pipes(changes); + let _ = senders.send_to_plugin(PluginInstruction::UnblockCliPipes(vec![asset])); + } +} + +fn lookup( + plugin_map: &Arc>, + instance_id: PluginId, +) -> Option<(Arc>, Arc>)> { + plugin_map + .lock() + .unwrap() + .shared_running_plugin(instance_id) +} + +fn is_deduplicated_event(event_type: EventType) -> bool { + matches!( + event_type, + EventType::ModeUpdate | EventType::TabUpdate | EventType::PaneUpdate + ) +} + +pub fn apply_events_job( + senders: ThreadSenders, + plugin_map: Arc>, + instance_id: PluginId, + events: Vec, +) { + let Some((running_plugin, subscriptions)) = lookup(&plugin_map, instance_id) else { + return; + }; + let subscriptions = subscriptions.lock().unwrap().clone(); + let mut running_plugin = running_plugin.lock().unwrap(); + let mut render = RenderResponse::Nothing; + let strip_keybinds = subscriptions.contains(&EventType::InitialKeybinds); + for (mut event, context, mut keybinds) in events { + let Ok(event_type) = EventType::from_str(&event.to_string()) else { + continue; + }; + if let Event::ModeUpdate(mode_info) = &mut event { + mode_info.keybinds = vec![]; + } + if strip_keybinds { + keybinds = None; + } + if !subscriptions.contains(&event_type) && event_type != EventType::PermissionRequestResult + { + continue; + } + if is_deduplicated_event(event_type) { + if let Some(shared) = running_plugin.store.data_mut().shared.as_mut() { + let key = (event_type, context.client_id); + let already_delivered = shared + .last_events + .get(&key) + .map(|(last_event, last_keybinds)| { + last_event == &event && last_keybinds == &keybinds + }) + .unwrap_or(false); + if already_delivered { + continue; + } + shared + .last_events + .insert(key, (event.clone(), keybinds.clone())); + } + } + match check_event_permission(running_plugin.store.data(), &event) { + (PermissionStatus::Granted, _) => {}, + (PermissionStatus::Denied, permission) => { + log::error!( + "Shared plugin '{}' permission '{}' is not allowed - Event '{:?}' denied", + instance_id, + permission + .map(|p| p.to_string()) + .unwrap_or("UNKNOWN".to_owned()), + event_type + ); + continue; + }, + } + match call_update(&mut running_plugin, &event, keybinds.as_deref(), context) { + Ok(requested) => { + render = render.merge(requested); + if event_type == EventType::PermissionRequestResult { + render = render.merge(RenderResponse::All); + } + }, + Err(e) => { + let pane_slots = pane_slot_ids(&running_plugin); + report_error(&senders, pane_slots, e); + return; + }, + } + } + send_pipe_changes(&senders, &mut running_plugin, instance_id, None); + if let Err(e) = render_request(&senders, &mut running_plugin, render) { + let pane_slots = pane_slot_ids(&running_plugin); + report_error(&senders, pane_slots, e); + } +} + +pub fn apply_pipes_job( + senders: ThreadSenders, + plugin_map: Arc>, + instance_id: PluginId, + messages: Vec<(PipeMessage, EventContext)>, +) { + let Some((running_plugin, _subscriptions)) = lookup(&plugin_map, instance_id) else { + for (pipe_message, _) in &messages { + release_pipe(&senders, instance_id, pipe_message); + } + return; + }; + let mut running_plugin = running_plugin.lock().unwrap(); + let mut render = RenderResponse::Nothing; + let mut messages = messages.into_iter(); + while let Some((pipe_message, context)) = messages.next() { + match call_pipe(&mut running_plugin, &pipe_message, context) { + Ok(requested) => { + render = render.merge(requested); + send_pipe_changes( + &senders, + &mut running_plugin, + instance_id, + Some(&pipe_message.source), + ); + }, + Err(e) => { + release_pipe(&senders, instance_id, &pipe_message); + for (remaining, _) in messages { + release_pipe(&senders, instance_id, &remaining); + } + let pane_slots = pane_slot_ids(&running_plugin); + report_error(&senders, pane_slots, e); + return; + }, + } + } + if let Err(e) = render_request(&senders, &mut running_plugin, render) { + let pane_slots = pane_slot_ids(&running_plugin); + report_error(&senders, pane_slots, e); + } +} + +pub fn add_slot_job( + senders: ThreadSenders, + plugin_map: Arc>, + instance_id: PluginId, + slot_id: PluginId, + slot: SharedSlot, + slot_added: std::sync::mpsc::Sender<()>, +) { + let Some((running_plugin, _subscriptions)) = lookup(&plugin_map, instance_id) else { + let mut loading_indication = LoadingIndication::new(String::new()); + handle_plugin_loading_failure( + &senders, + slot_id, + &mut loading_indication, + "Shared plugin instance failed to load", + None, + ); + return; + }; + let mut running_plugin = running_plugin.lock().unwrap(); + let added = call_slot_added(&mut running_plugin, slot_id, slot); + let _ = slot_added.send(()); + let result = added.and_then(|_| { + render_request( + &senders, + &mut running_plugin, + RenderResponse::Slots(vec![slot_id]), + ) + }); + match result { + Ok(()) => { + let plugin_list = plugin_map.lock().unwrap().list_plugins(); + handle_plugin_successful_loading(&senders, slot_id, plugin_list); + }, + Err(e) => { + let _ = + senders.send_to_background_jobs(BackgroundJob::StopPluginLoadingAnimation(slot_id)); + report_error(&senders, vec![slot_id], e); + }, + } +} + +pub fn remove_slot_job( + senders: ThreadSenders, + plugin_map: Arc>, + instance_id: PluginId, + slot_id: PluginId, + tear_down: bool, +) { + if let Some((running_plugin, _subscriptions)) = lookup(&plugin_map, instance_id) { + let mut running_plugin = running_plugin.lock().unwrap(); + if let Err(e) = call_slot_removed(&mut running_plugin, slot_id) { + log::error!( + "Failed to remove slot {} from shared plugin: {:?}", + slot_id, + e + ); + } + } + if tear_down { + tear_down_instance(&senders, &plugin_map, instance_id); + } +} + +fn tear_down_instance( + senders: &ThreadSenders, + plugin_map: &Arc>, + instance_id: PluginId, +) { + let removed = plugin_map.lock().unwrap().remove_shared(instance_id); + let Some((running_plugin, subscriptions, workers)) = removed else { + return; + }; + for (_worker_name, worker_sender) in workers { + drop(worker_sender.send(MessageToWorker::Exit)); + } + let needs_before_close = subscriptions + .lock() + .unwrap() + .contains(&EventType::BeforeClose); + let mut running_plugin = running_plugin.lock().unwrap(); + if needs_before_close { + if let Err(e) = call_update( + &mut running_plugin, + &Event::BeforeClose, + None, + EventContext::default(), + ) { + log::error!("Failed to send BeforeClose to shared plugin: {:?}", e); + } + send_pipe_changes(senders, &mut running_plugin, instance_id, None); + } + if running_plugin.intercepting_key_presses() { + let clients: Vec = running_plugin + .store + .data() + .shared + .as_ref() + .map(|shared| shared.clients.iter().copied().collect()) + .unwrap_or_default(); + for client_id in clients { + let _ = senders.send_to_screen(ScreenInstruction::ClearKeyPressesIntercepts(client_id)); + } + } + let data_dir = running_plugin.store.data().plugin_own_data_dir.clone(); + if let Err(e) = std::fs::remove_dir_all(&data_dir) { + log::error!("Failed to remove data dir for shared plugin: {:?}", e); + } + let _ = senders.send_to_screen(ScreenInstruction::ClearAllPluginHighlights(instance_id)); +} + +pub fn client_job( + senders: ThreadSenders, + plugin_map: Arc>, + instance_id: PluginId, + client_id: ClientId, + connected: bool, +) { + let Some((running_plugin, _subscriptions)) = lookup(&plugin_map, instance_id) else { + return; + }; + let mut running_plugin = running_plugin.lock().unwrap(); + let result = if connected { + call_client_connected(&mut running_plugin, client_id).and_then(|_| { + render_request( + &senders, + &mut running_plugin, + RenderResponse::Client(client_id), + ) + }) + } else { + call_client_disconnected(&mut running_plugin, client_id) + }; + if let Err(e) = result { + let pane_slots = pane_slot_ids(&running_plugin); + report_error(&senders, pane_slots, e); + } +} + +pub fn resize_job( + senders: ThreadSenders, + plugin_map: Arc>, + instance_id: PluginId, + slot_id: PluginId, + rows: usize, + columns: usize, +) { + let Some((running_plugin, _subscriptions)) = lookup(&plugin_map, instance_id) else { + return; + }; + let mut running_plugin = running_plugin.lock().unwrap(); + let changed = match running_plugin.store.data_mut().shared.as_mut() { + Some(shared) => match shared.slots.get_mut(&slot_id) { + Some(slot) => { + let changed = slot.rows != rows || slot.columns != columns; + slot.rows = rows; + slot.columns = columns; + changed + }, + None => false, + }, + None => false, + }; + if changed { + if let Err(e) = render_request( + &senders, + &mut running_plugin, + RenderResponse::Slots(vec![slot_id]), + ) { + let pane_slots = pane_slot_ids(&running_plugin); + report_error(&senders, pane_slots, e); + } + } +} + +pub fn visibility_job( + senders: ThreadSenders, + plugin_map: Arc>, + instance_id: PluginId, + visible_slots: HashMap>, +) { + let Some((running_plugin, _subscriptions)) = lookup(&plugin_map, instance_id) else { + return; + }; + let mut running_plugin = running_plugin.lock().unwrap(); + let before: HashSet<(PluginId, ClientId)> = targets_for(&running_plugin, &RenderResponse::All) + .into_iter() + .collect(); + if let Some(shared) = running_plugin.store.data_mut().shared.as_mut() { + shared.visible_slots = visible_slots; + } + let newly_visible: Vec<(PluginId, ClientId)> = + targets_for(&running_plugin, &RenderResponse::All) + .into_iter() + .filter(|target| !before.contains(target)) + .collect(); + if let Err(e) = render_and_send(&senders, &mut running_plugin, newly_visible) { + let pane_slots = pane_slot_ids(&running_plugin); + report_error(&senders, pane_slots, e); + } +} + +pub fn host_settings_job( + plugin_map: Arc>, + instance_id: PluginId, + default_shell: Option, + layout_dir: Option, +) { + let Some((running_plugin, _subscriptions)) = lookup(&plugin_map, instance_id) else { + return; + }; + let mut running_plugin = running_plugin.lock().unwrap(); + running_plugin.update_default_shell(default_shell); + running_plugin.update_layout_dir(layout_dir); +} + +pub fn start_instance_job( + senders: ThreadSenders, + plugin_map: Arc>, + connected_clients: Arc>>, + plugin_cache: PluginCache, + engine: Engine, + loading_context: LoadingContext, + skip_cache: bool, + instance_id: PluginId, + initial_slots: BTreeMap, +) { + let mut initial_slots = initial_slots; + let previous = plugin_map.lock().unwrap().remove_shared(instance_id); + let previous_visible_slots = match previous { + Some((running_plugin, _subscriptions, workers)) => { + for (_worker_name, worker_sender) in workers { + drop(worker_sender.send(MessageToWorker::Exit)); + } + let running_plugin = running_plugin.lock().unwrap(); + match running_plugin.store.data().shared.as_ref() { + Some(shared) => { + for (slot_id, slot) in shared.slots.iter() { + initial_slots + .entry(*slot_id) + .or_insert_with(|| slot.clone()); + } + shared.visible_slots.clone() + }, + None => HashMap::new(), + } + }, + None => HashMap::new(), + }; + let started = { + let mut plugin_map = plugin_map.lock().unwrap(); + PluginLoader::new( + skip_cache, + loading_context, + senders.clone(), + engine, + plugin_cache, + &mut plugin_map, + connected_clients.clone(), + ) + .start_shared_plugin() + }; + let running_plugin = match started { + Ok(running_plugin) => running_plugin, + Err(e) => { + for slot_id in initial_slots.keys() { + let mut loading_indication = LoadingIndication::new(String::new()); + handle_plugin_loading_failure( + &senders, + *slot_id, + &mut loading_indication, + format!("{:?}", e), + None, + ); + } + return; + }, + }; + let mut running_plugin = running_plugin.lock().unwrap(); + if let Some(shared) = running_plugin.store.data_mut().shared.as_mut() { + shared.visible_slots = previous_visible_slots; + } + let clients: Vec = connected_clients.lock().unwrap().iter().copied().collect(); + let mut result: Result<()> = Ok(()); + for client_id in clients { + if result.is_ok() { + result = call_client_connected(&mut running_plugin, client_id); + } + } + for (slot_id, slot) in initial_slots.iter() { + if result.is_ok() { + result = call_slot_added(&mut running_plugin, *slot_id, slot.clone()); + } + } + if result.is_ok() { + result = render_request(&senders, &mut running_plugin, RenderResponse::All); + } + match result { + Ok(()) => { + let plugin_list = plugin_map.lock().unwrap().list_plugins(); + for slot_id in initial_slots.keys() { + handle_plugin_successful_loading(&senders, *slot_id, plugin_list.clone()); + } + }, + Err(e) => { + for slot_id in initial_slots.keys() { + let _ = senders + .send_to_background_jobs(BackgroundJob::StopPluginLoadingAnimation(*slot_id)); + } + let pane_slots = pane_slot_ids(&running_plugin); + report_error(&senders, pane_slots, e); + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn module_with_exports(names: &[&str]) -> Vec { + let mut section = vec![names.len() as u8]; + for name in names { + section.push(name.len() as u8); + section.extend_from_slice(name.as_bytes()); + section.push(0); + section.push(0); + } + let mut bytes = b"\0asm\x01\0\0\0".to_vec(); + bytes.push(1); + bytes.push(0); + bytes.push(7); + bytes.push(section.len() as u8); + bytes.extend(section); + bytes + } + + #[test] + fn detects_shared_marker_export() { + let bytes = module_with_exports(&["load", SHARED_MARKER_EXPORT]); + assert!(wasm_module_exports_function(&bytes, SHARED_MARKER_EXPORT)); + } + + #[test] + fn legacy_module_is_not_shared() { + let bytes = module_with_exports(&["load", "update", "render"]); + assert!(!wasm_module_exports_function(&bytes, SHARED_MARKER_EXPORT)); + } + + #[test] + fn garbage_is_not_shared() { + assert!(!wasm_module_exports_function( + b"not a wasm module", + SHARED_MARKER_EXPORT + )); + } +} diff --git a/zellij-server/src/plugins/unit/plugin_tests.rs b/zellij-server/src/plugins/unit/plugin_tests.rs index 0ac60f7bf..42ab891ef 100644 --- a/zellij-server/src/plugins/unit/plugin_tests.rs +++ b/zellij-server/src/plugins/unit/plugin_tests.rs @@ -292,7 +292,7 @@ macro_rules! grant_permissions_and_log_actions_in_thread_struct_variant { }; } -fn create_plugin_thread( +pub(super) fn create_plugin_thread( zellij_cwd: Option, session_env_vars: Option>, ) -> ( @@ -371,7 +371,7 @@ fn create_plugin_thread( default_shell_action, plugin_aliases, InputMode::Normal, - Keybinds::default(), + Default::default(), Default::default(), initiating_client_id, ) @@ -459,7 +459,7 @@ fn create_plugin_thread_with_server_receiver( default_shell_action, PluginAliases::default(), InputMode::Normal, - Keybinds::default(), + Default::default(), Default::default(), initiating_client_id, ) @@ -555,7 +555,7 @@ fn create_plugin_thread_with_pty_receiver( default_shell_action, PluginAliases::default(), InputMode::Normal, - Keybinds::default(), + Default::default(), Default::default(), initiating_client_id, ) @@ -644,7 +644,7 @@ fn create_plugin_thread_with_background_jobs_receiver( default_shell_action, PluginAliases::default(), InputMode::Normal, - Keybinds::default(), + Default::default(), Default::default(), initiating_client_id, ) @@ -13018,7 +13018,7 @@ pub fn mode_update_payload_is_lightweight_for_opted_in_plugins() { let _ = plugin_thread_sender.send(PluginInstruction::Reconfigure { client_id, - keybinds: Some(test_keybinds), + keybinds: Some(std::sync::Arc::new(test_keybinds.to_keybinds_vec())), default_mode: None, default_shell: None, layout_dir: None, @@ -13172,7 +13172,7 @@ pub fn reconfiguration_resends_keybinds_to_opted_in_plugins() { // Send Reconfigure let _ = plugin_thread_sender.send(PluginInstruction::Reconfigure { client_id, - keybinds: Some(new_keybinds), + keybinds: Some(std::sync::Arc::new(new_keybinds.to_keybinds_vec())), default_mode: None, default_shell: None, layout_dir: None, diff --git a/zellij-server/src/plugins/unit/shared_plugin_tests.rs b/zellij-server/src/plugins/unit/shared_plugin_tests.rs new file mode 100644 index 000000000..7ec3958fc --- /dev/null +++ b/zellij-server/src/plugins/unit/shared_plugin_tests.rs @@ -0,0 +1,504 @@ +use super::plugin_tests::create_plugin_thread; +use crate::plugins::{PluginId, PluginInstruction}; +use crate::screen::ScreenInstruction; +use crate::ClientId; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use tempfile::tempdir; +use zellij_utils::channels::{Receiver, SenderWithContext}; +use zellij_utils::data::{BareKey, Event, KeyWithModifier, PermissionStatus}; +use zellij_utils::errors::ErrorContext; +use zellij_utils::input::layout::{ + PluginUserConfiguration, RunPlugin, RunPluginLocation, RunPluginOrAlias, +}; +use zellij_utils::pane_size::Size; + +type Renders = HashMap<(PluginId, ClientId), String>; + +fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(format!( + "{}/../target/e2e-data/plugins/{}.wasm", + std::env::var_os("CARGO_MANIFEST_DIR") + .unwrap() + .to_string_lossy(), + name + )) +} + +fn shared_fixture(configuration: &[(&str, &str)]) -> RunPluginOrAlias { + let configuration: BTreeMap = configuration + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(fixture_path("fixture-shared-plugin-for-tests")), + configuration: PluginUserConfiguration::new(configuration), + ..Default::default() + }) +} + +fn legacy_fixture() -> RunPluginOrAlias { + RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(fixture_path("fixture-plugin-for-tests")), + configuration: Default::default(), + ..Default::default() + }) +} + +fn load( + plugin_thread_sender: &SenderWithContext, + run_plugin: RunPluginOrAlias, + tab_index: usize, + client_id: ClientId, +) { + let _ = plugin_thread_sender.send(PluginInstruction::Load( + Some(false), + false, + false, + None, + run_plugin, + Some(tab_index), + None, + client_id, + Size { cols: 80, rows: 5 }, + None, + None, + false, + None, + None, + None, + )); +} + +fn key_event() -> Event { + Event::Key(KeyWithModifier::new(BareKey::Char('!'))) +} + +struct ScreenLog { + receiver: Receiver<(ScreenInstruction, ErrorContext)>, + plugin_thread_sender: SenderWithContext, + cache_path: PathBuf, + renders: Renders, + render_log: Vec<(PluginId, ClientId, String)>, +} + +impl ScreenLog { + fn new( + receiver: Receiver<(ScreenInstruction, ErrorContext)>, + plugin_thread_sender: SenderWithContext, + cache_path: PathBuf, + ) -> Self { + ScreenLog { + receiver, + plugin_thread_sender, + cache_path, + renders: HashMap::new(), + render_log: vec![], + } + } + fn receive_one(&mut self, timeout: Duration) { + let Ok((instruction, _)) = self.receiver.recv_timeout(timeout) else { + return; + }; + match instruction { + ScreenInstruction::PluginBytes(assets) => { + for asset in assets { + if asset.bytes.is_empty() { + continue; + } + let rendered = String::from_utf8_lossy(&asset.bytes).to_string(); + self.render_log + .push((asset.plugin_id, asset.client_id, rendered.clone())); + self.renders + .insert((asset.plugin_id, asset.client_id), rendered); + } + }, + ScreenInstruction::RequestPluginPermissions(plugin_id, plugin_permission) => { + let _ = self + .plugin_thread_sender + .send(PluginInstruction::PermissionRequestResult( + plugin_id, + None, + plugin_permission.permissions, + PermissionStatus::Granted, + Some(self.cache_path.clone()), + )); + }, + _ => {}, + } + } + fn wait_until(&mut self, description: &str, condition: impl Fn(&Renders) -> bool) { + let deadline = Instant::now() + Duration::from_secs(30); + while !condition(&self.renders) { + if Instant::now() > deadline { + panic!( + "timed out waiting for: {}, renders: {:#?}", + description, self.renders + ); + } + self.receive_one(Duration::from_millis(100)); + } + } + fn settle(&mut self, duration: Duration) { + let deadline = Instant::now() + duration; + while Instant::now() < deadline { + self.receive_one(Duration::from_millis(50)); + } + } +} + +fn field<'a>(rendered: &'a str, name: &str) -> &'a str { + let start = rendered + .find(&format!("{}:", name)) + .map(|i| i + name.len() + 1) + .unwrap_or(rendered.len()); + let rest = &rendered[start..]; + let end = if rest.starts_with('[') { + rest.find(']').map(|i| i + 1).unwrap_or(rest.len()) + } else { + rest.find(' ').unwrap_or(rest.len()) + }; + &rest[..end] +} + +fn slots_of_client(renders: &Renders, client_id: ClientId) -> Vec { + let mut slots: Vec = renders + .keys() + .filter(|(_, c)| *c == client_id) + .map(|(slot_id, _)| *slot_id) + .collect(); + slots.sort(); + slots +} + +fn start() -> ( + SenderWithContext, + ScreenLog, + Box, + tempfile::TempDir, +) { + let temp_folder = tempdir().unwrap(); + let cache_path = PathBuf::from(temp_folder.path()).join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = create_plugin_thread(None, None); + let screen_log = ScreenLog::new(screen_receiver, plugin_thread_sender.clone(), cache_path); + (plugin_thread_sender, screen_log, teardown, temp_folder) +} + +#[test] +#[ignore] +pub fn shared_plugin_one_instance_many_slots() { + let (sender, mut screen, teardown, _temp_folder) = start(); + let client_id = 1; + let _ = sender.send(PluginInstruction::AddClient(client_id)); + load(&sender, shared_fixture(&[("label", "a")]), 0, client_id); + load(&sender, shared_fixture(&[("label", "b")]), 1, client_id); + load(&sender, shared_fixture(&[("label", "c")]), 2, client_id); + screen.wait_until("three slots rendered", |renders| { + slots_of_client(renders, client_id).len() == 3 + }); + let first_slot = slots_of_client(&screen.renders, client_id)[0]; + let _ = sender.send(PluginInstruction::Update(vec![( + Some(first_slot), + Some(client_id), + key_event(), + )])); + screen.wait_until("three slots rendered with all slots known", |renders| { + let slots = slots_of_client(renders, client_id); + slots.len() == 3 + && slots.iter().all(|slot_id| { + field(&renders[&(*slot_id, client_id)], "slots") == format!("{:?}", slots) + }) + }); + teardown(); + let slots = slots_of_client(&screen.renders, client_id); + let instances: HashSet<&str> = slots + .iter() + .map(|slot_id| field(&screen.renders[&(*slot_id, client_id)], "instance")) + .collect(); + assert_eq!(instances.len(), 1, "all slots are served by one instance"); + for slot_id in &slots { + let rendered = &screen.renders[&(*slot_id, client_id)]; + assert_eq!( + field(rendered, "loads"), + "1", + "load runs once: {}", + rendered + ); + assert_eq!(field(rendered, "slot"), slot_id.to_string()); + } + let names: HashSet<&str> = slots + .iter() + .map(|slot_id| field(&screen.renders[&(*slot_id, client_id)], "label")) + .collect(); + assert_eq!( + names, + HashSet::from(["a", "b", "c"]), + "each slot keeps its own configuration" + ); +} + +#[test] +#[ignore] +pub fn shared_plugin_instance_key_creates_separate_instances() { + let (sender, mut screen, teardown, _temp_folder) = start(); + let client_id = 1; + let _ = sender.send(PluginInstruction::AddClient(client_id)); + load( + &sender, + shared_fixture(&[("instance", "one"), ("label", "a")]), + 0, + client_id, + ); + load( + &sender, + shared_fixture(&[("instance", "two"), ("label", "b")]), + 0, + client_id, + ); + load( + &sender, + shared_fixture(&[("instance", "one"), ("label", "c")]), + 1, + client_id, + ); + screen.wait_until("three slots rendered", |renders| { + slots_of_client(renders, client_id).len() == 3 + }); + for slot_id in slots_of_client(&screen.renders, client_id) { + let _ = sender.send(PluginInstruction::Update(vec![( + Some(slot_id), + Some(client_id), + key_event(), + )])); + } + screen.wait_until( + "instance one has two slots and instance two has one", + |renders| { + let slots = slots_of_client(renders, client_id); + slots.len() == 3 && { + let mut slot_list_lengths: Vec = slots + .iter() + .map(|slot_id| { + field(&renders[&(*slot_id, client_id)], "slots") + .split(',') + .count() + }) + .collect(); + slot_list_lengths.sort(); + slot_list_lengths == vec![1, 2, 2] + } + }, + ); + teardown(); + let by_name: HashMap<&str, &str> = screen + .renders + .values() + .map(|rendered| (field(rendered, "label"), field(rendered, "instance"))) + .collect(); + assert_eq!( + by_name["a"], by_name["c"], + "same instance name shares an instance" + ); + assert_ne!( + by_name["a"], by_name["b"], + "different instance names do not share" + ); +} + +#[test] +#[ignore] +pub fn shared_plugin_renders_per_client() { + let (sender, mut screen, teardown, _temp_folder) = start(); + let _ = sender.send(PluginInstruction::AddClient(1)); + let _ = sender.send(PluginInstruction::AddClient(2)); + load(&sender, shared_fixture(&[("label", "a")]), 0, 1); + screen.wait_until("the slot rendered for both clients", |renders| { + slots_of_client(renders, 1).len() == 1 && slots_of_client(renders, 2).len() == 1 + }); + let slot_id = slots_of_client(&screen.renders, 1)[0]; + assert_eq!(field(&screen.renders[&(slot_id, 1)], "client"), "1"); + assert_eq!(field(&screen.renders[&(slot_id, 2)], "client"), "2"); + let renders_before_key = screen.render_log.len(); + let _ = sender.send(PluginInstruction::Update(vec![( + Some(slot_id), + Some(2), + key_event(), + )])); + screen.wait_until("client 2 re-rendered after its key", |renders| { + field(&renders[&(slot_id, 2)], "keys") == format!("[(Some({}), Some(2))]", slot_id) + }); + screen.settle(Duration::from_millis(300)); + teardown(); + let clients_rendered_after_key: HashSet = screen.render_log[renders_before_key..] + .iter() + .map(|(_, client_id, _)| *client_id) + .collect(); + assert_eq!( + clients_rendered_after_key, + HashSet::from([2]), + "RenderResponse::Client only re-renders the named client" + ); +} + +#[test] +#[ignore] +pub fn shared_plugin_slot_removed_when_pane_closes() { + let (sender, mut screen, teardown, _temp_folder) = start(); + let client_id = 1; + let _ = sender.send(PluginInstruction::AddClient(client_id)); + load(&sender, shared_fixture(&[("label", "a")]), 0, client_id); + load(&sender, shared_fixture(&[("label", "b")]), 1, client_id); + screen.wait_until("both slots rendered", |renders| { + slots_of_client(renders, client_id).len() == 2 + }); + let slots = slots_of_client(&screen.renders, client_id); + let (closed_slot, remaining_slot) = (slots[0], slots[1]); + let _ = sender.send(PluginInstruction::Unload(closed_slot)); + let renders_before_key = screen.render_log.len(); + let _ = sender.send(PluginInstruction::Update(vec![( + Some(remaining_slot), + Some(client_id), + key_event(), + )])); + screen.wait_until( + "remaining slot knows the other slot was removed", + |renders| { + let rendered = &renders[&(remaining_slot, client_id)]; + field(rendered, "removed") == format!("[{}]", closed_slot) + && field(rendered, "slots") == format!("[{}]", remaining_slot) + }, + ); + screen.settle(Duration::from_millis(300)); + teardown(); + assert!( + screen.render_log[renders_before_key..] + .iter() + .all(|(slot_id, _, _)| *slot_id != closed_slot), + "a removed slot is not rendered" + ); + assert_eq!( + field(&screen.renders[&(remaining_slot, client_id)], "loads"), + "1" + ); +} + +#[test] +#[ignore] +pub fn shared_plugin_client_connect_and_disconnect() { + let (sender, mut screen, teardown, _temp_folder) = start(); + let _ = sender.send(PluginInstruction::AddClient(1)); + load(&sender, shared_fixture(&[("label", "a")]), 0, 1); + screen.wait_until("slot rendered for client 1", |renders| { + slots_of_client(renders, 1).len() == 1 + }); + let slot_id = slots_of_client(&screen.renders, 1)[0]; + assert_eq!(field(&screen.renders[&(slot_id, 1)], "clients"), "[1]"); + let _ = sender.send(PluginInstruction::AddClient(2)); + screen.wait_until("new client is rendered without a new instance", |renders| { + renders + .get(&(slot_id, 2)) + .map(|rendered| field(rendered, "clients") == "[1, 2]") + .unwrap_or(false) + }); + let _ = sender.send(PluginInstruction::RemoveClient(2)); + let _ = sender.send(PluginInstruction::Update(vec![( + Some(slot_id), + Some(1), + key_event(), + )])); + screen.wait_until("client 2 is gone", |renders| { + let rendered = &renders[&(slot_id, 1)]; + field(rendered, "clients") == "[1]" && field(rendered, "disconnected") == "[2]" + }); + teardown(); + let instances: HashSet<&str> = screen + .renders + .values() + .map(|rendered| field(rendered, "instance")) + .collect(); + assert_eq!(instances.len(), 1); + assert!(screen + .renders + .values() + .all(|rendered| field(rendered, "loads") == "1")); +} + +#[test] +#[ignore] +pub fn shared_plugin_slot_commands_are_checked() { + let (sender, mut screen, teardown, _temp_folder) = start(); + let client_id = 1; + let _ = sender.send(PluginInstruction::AddClient(client_id)); + load(&sender, shared_fixture(&[("label", "a")]), 0, client_id); + screen.wait_until("slot rendered", |renders| { + slots_of_client(renders, client_id).len() == 1 + }); + let slot_id = slots_of_client(&screen.renders, client_id)[0]; + let _ = sender.send(PluginInstruction::KeybindPipe { + name: "selectable_slot".to_owned(), + payload: None, + plugin: None, + args: None, + configuration: None, + floating: None, + pane_id_to_replace: None, + pane_title: None, + cwd: None, + skip_cache: false, + cli_client_id: client_id, + plugin_and_client_id: Some((slot_id, client_id)), + notification_end: None, + }); + screen.wait_until("slot command results rendered", |renders| { + field(&renders[&(slot_id, client_id)], "self_commands").contains("Err") + }); + teardown(); + let rendered = &screen.renders[&(slot_id, client_id)]; + let results = field(rendered, "self_commands"); + assert!( + results.starts_with("[\"Ok(())\""), + "own slot is accepted: {}", + results + ); + assert!( + results.contains("does not belong"), + "foreign slot is rejected: {}", + results + ); +} + +#[test] +#[ignore] +pub fn legacy_plugin_keeps_one_instance_per_client() { + let (sender, mut screen, teardown, _temp_folder) = start(); + let _ = sender.send(PluginInstruction::AddClient(1)); + let _ = sender.send(PluginInstruction::AddClient(2)); + load(&sender, legacy_fixture(), 0, 1); + let _ = sender.send(PluginInstruction::Update(vec![( + None, + None, + Event::InputReceived, + )])); + screen.wait_until("legacy plugin rendered for both clients", |renders| { + slots_of_client(renders, 1).len() == 1 && slots_of_client(renders, 2).len() == 1 + }); + let plugin_id = slots_of_client(&screen.renders, 1)[0]; + let _ = sender.send(PluginInstruction::Update(vec![( + Some(plugin_id), + Some(1), + key_event(), + )])); + screen.wait_until("client 1 instance received the key", |renders| { + renders[&(plugin_id, 1)].contains("Key(") + }); + screen.settle(Duration::from_millis(300)); + teardown(); + assert!( + !screen.renders[&(plugin_id, 2)].contains("Key("), + "the other client's instance did not receive the key" + ); + assert!(screen.renders[&(plugin_id, 1)].starts_with("Rows: ")); +} diff --git a/zellij-server/src/plugins/wasm_bridge.rs b/zellij-server/src/plugins/wasm_bridge.rs index 22b7bcf33..daf6f13af 100644 --- a/zellij-server/src/plugins/wasm_bridge.rs +++ b/zellij-server/src/plugins/wasm_bridge.rs @@ -5,17 +5,25 @@ use crate::plugins::pipes::{ }; use crate::plugins::plugin_loader::PluginLoader; use crate::plugins::plugin_map::{ - AtomicEvent, PluginEnv, PluginMap, RemovedPluginAssets, RunningPlugin, Subscriptions, + AtomicEvent, PluginEnv, PluginMap, PluginMetadata, RemovedPluginAssets, RunningPlugin, + SharedSlot, Subscriptions, +}; +use crate::plugins::shared::{ + add_slot_job, apply_events_job, apply_pipes_job, client_job, host_settings_job, + remove_slot_job, resize_job, start_instance_job, visibility_job, wasm_module_exports_function, + SharedKey, SHARED_MARKER_EXPORT, SHARED_PIPE_CLIENT, }; use crate::plugins::plugin_worker::MessageToWorker; +use crate::plugins::shared::SharedEventBatchEntry; use crate::plugins::watch_filesystem::watch_filesystem; use crate::plugins::zellij_exports::{wasi_read_string, wasi_write_object}; +use crate::SharedKeybinds; use highway::{HighwayHash, PortableHash}; use log::info; use notify_debouncer_full::{notify::RecommendedWatcher, Debouncer, RecommendedCache}; use std::{ - collections::{BTreeMap, HashMap, HashSet}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, path::PathBuf, str::FromStr, sync::{Arc, Mutex}, @@ -30,9 +38,8 @@ use zellij_utils::data::{ PipeSource, }; use zellij_utils::downloader::Downloader; -use zellij_utils::input::keybinds::Keybinds; use zellij_utils::input::permission::PermissionCache; -use zellij_utils::plugin_api::event::ProtobufEvent; +use zellij_utils::plugin_api::event::{event_to_protobuf_with_keybinds, ProtobufEvent}; use prost::Message; @@ -43,12 +50,12 @@ use crate::{ ServerInstruction, }; use zellij_utils::{ - data::{Event, EventType}, + data::{Event, EventContext, EventType, SlotKind}, errors::prelude::*, input::{ command::TerminalAction, layout::{PluginUserConfiguration, RunPlugin, RunPluginLocation, RunPluginOrAlias}, - plugins::{PluginAliases, PluginConfig}, + plugins::{canonical_run_plugin, PluginAliases, PluginConfig, SHARED_INSTANCE_KEY}, }, pane_size::Size, }; @@ -109,7 +116,7 @@ pub struct LoadingContext { pub default_shell: Option, pub layout_dir: Option, pub default_mode: InputMode, - pub keybinds: Keybinds, + pub keybinds: SharedKeybinds, pub plugin_dir: PathBuf, pub size: Size, } @@ -170,6 +177,36 @@ impl LoadingContext { pub type PluginCache = Arc>>; +const SLOT_ADDED_WAIT_MS: u64 = 200; + +struct SharedInstanceInfo { + key: SharedKey, + loading_context: LoadingContext, + slots: BTreeSet, + background_slots: BTreeSet, +} + +fn shared_event_context( + event: &Event, + slot_id: Option, + client_id: Option, +) -> EventContext { + let slot_id = match event { + Event::Key(..) + | Event::Mouse(..) + | Event::PastedText(..) + | Event::Visible(..) + | Event::PermissionRequestResult(..) + | Event::PluginConfigurationChanged(..) => slot_id, + _ => None, + }; + let client_id = match event { + Event::PaneUpdate(..) | Event::CwdChanged(..) | Event::Timer(..) => None, + _ => client_id, + }; + EventContext { slot_id, client_id } +} + pub struct WasmBridge { connected_clients: Arc>>, senders: ThreadSenders, @@ -198,13 +235,17 @@ pub struct WasmBridge { available_layouts: Vec, available_layout_errors: Vec, default_mode: InputMode, - default_keybinds: Keybinds, - keybinds: HashMap, + default_keybinds: SharedKeybinds, + keybinds: HashMap, base_modes: HashMap, downloader: Downloader, previous_pane_render_report: Option, last_host_terminal_theme_mode: Option, pub last_session_save_time: Arc>>, // milliseconds since UNIX epoch + shared_instances: HashMap, + shared_keys: HashMap, + shared_slot_owner: HashMap, + shared_module_kinds: HashMap, } impl WasmBridge { @@ -220,7 +261,7 @@ impl WasmBridge { available_layouts: Vec, available_layout_errors: Vec, default_mode: InputMode, - default_keybinds: Keybinds, + default_keybinds: SharedKeybinds, ) -> Self { let plugin_map = Arc::new(Mutex::new(PluginMap::default())); let connected_clients: Arc>> = Arc::new(Mutex::new(vec![])); @@ -268,6 +309,10 @@ impl WasmBridge { previous_pane_render_report: None, last_host_terminal_theme_mode: None, last_session_save_time: Arc::new(Mutex::new(None)), + shared_instances: HashMap::new(), + shared_keys: HashMap::new(), + shared_slot_owner: HashMap::new(), + shared_module_kinds: HashMap::new(), } } pub fn load_plugin( @@ -278,6 +323,18 @@ impl WasmBridge { cwd: Option, skip_cache: bool, client_id: Option, + ) -> Result<(PluginId, ClientId)> { + self.load_plugin_with_kind(run, tab_index, size, cwd, skip_cache, client_id, false) + } + pub fn load_plugin_with_kind( + &mut self, + run: &Option, + tab_index: Option, + size: Size, + cwd: Option, + skip_cache: bool, + client_id: Option, + is_background: bool, ) -> Result<(PluginId, ClientId)> { let _err_context = move || format!("failed to load plugin"); @@ -309,6 +366,24 @@ impl WasmBridge { "Plugins must have a client id, none was provided and none are connected" })?; + if let Some(run) = run { + let run = canonical_run_plugin(run); + if let Some(plugin_config) = PluginConfig::from_run_plugin(&run) { + if self.module_is_shared(&plugin_config, skip_cache) { + return self.load_shared_slot( + run, + plugin_config, + tab_index, + size, + cwd, + skip_cache, + client_id, + is_background, + ); + } + } + } + let plugin_id = self.next_plugin_id; match run { @@ -497,6 +572,10 @@ impl WasmBridge { } pub fn unload_plugin(&mut self, pid: PluginId) -> Result<()> { info!("Bye from plugin {}", &pid); + if self.shared_slot_owner.contains_key(&pid) { + self.unload_shared_slot(pid); + return Ok(()); + } // Remove from plugin_map on main thread let plugins_to_cleanup: Vec<_> = { @@ -586,6 +665,10 @@ impl WasmBridge { Ok(()) } pub fn reload_plugin_with_id(&mut self, plugin_id: u32) -> Result<()> { + if let Some(instance_id) = self.shared_instance_of(plugin_id) { + self.reload_shared_instance(instance_id); + return Ok(()); + } let Some(run_plugin) = self.run_plugin_of_plugin_id(plugin_id).map(|r| r.clone()) else { log::error!("Failed to find plugin with id: {}", plugin_id); return Ok(()); @@ -673,6 +756,20 @@ impl WasmBridge { Ok(()) } pub fn reload_plugin(&mut self, run_plugin: &RunPlugin) -> Result<()> { + let canonical = canonical_run_plugin(run_plugin); + if let Some(plugin_config) = PluginConfig::from_run_plugin(&canonical) { + if self.module_is_shared(&plugin_config, false) { + let instance_ids = self.shared_instances_matching(&canonical); + if instance_ids.is_empty() { + return Err(ZellijError::PluginDoesNotExist) + .with_context(|| format!("failed to reload {}", canonical.location)); + } + for instance_id in instance_ids { + self.reload_shared_instance(instance_id); + } + return Ok(()); + } + } if self.plugin_is_currently_being_loaded(&run_plugin.location) { self.pending_plugin_reloads.insert(run_plugin.clone()); return Ok(()); @@ -695,7 +792,11 @@ impl WasmBridge { self.connected_clients.lock().unwrap().push(client_id); plugin_map.plugin_ids().into_iter().collect() }; + self.add_client_to_shared_instances(client_id); for plugin_id in new_plugins { + if self.shared_slot_owner.contains_key(&plugin_id) { + continue; + } if self .plugin_map .lock() @@ -801,6 +902,17 @@ impl WasmBridge { .lock() .unwrap() .set_size(pid, new_rows, new_columns); + if let Some(instance_id) = self.shared_slot_owner.get(&pid).copied() { + let _s = shutdown_sender.clone(); + self.plugin_executor.execute_for_plugin( + instance_id, + move |senders, plugin_map, _connected_clients, _plugin_cache, _engine| { + let _s = _s; + resize_job(senders, plugin_map, instance_id, pid, new_rows, new_columns); + }, + ); + return Ok(()); + } let plugins_to_resize: Vec<(PluginId, ClientId, Arc>)> = self .plugin_map @@ -898,6 +1010,7 @@ impl WasmBridge { } } } + updates = self.dispatch_shared_updates(updates, shutdown_sender.clone()); let plugins_to_update: Vec<( PluginId, ClientId, @@ -992,6 +1105,22 @@ impl WasmBridge { Ok(()) } pub fn get_plugin_cwd(&self, plugin_id: PluginId, client_id: ClientId) -> Option { + if let Some(instance_id) = self.shared_instance_of(plugin_id) { + return self + .plugin_map + .lock() + .unwrap() + .shared_running_plugin(instance_id) + .map(|(running_plugin, _)| { + running_plugin + .lock() + .unwrap() + .store + .data() + .plugin_cwd + .clone() + }); + } self.plugin_map .lock() .unwrap() @@ -1018,6 +1147,37 @@ impl WasmBridge { plugin_id_to_update: PluginId, client_id_to_update: ClientId, ) -> Result<()> { + if let Some(instance_id) = self.shared_instance_of(plugin_id_to_update) { + self.plugin_executor.execute_for_plugin( + instance_id, + move |senders, plugin_map, _connected_clients, _plugin_cache, _engine| { + let running_plugin = plugin_map + .lock() + .unwrap() + .shared_running_plugin(instance_id) + .map(|(running_plugin, _)| running_plugin); + let Some(running_plugin) = running_plugin else { + return; + }; + let event = match change_host_dir_of_running_plugin( + &mut running_plugin.lock().unwrap(), + &new_host_dir, + ) { + Ok(()) => Event::HostFolderChanged(new_host_dir.clone()), + Err(e) => { + log::error!("Failed to change host folder: {}", e); + Event::FailedToChangeHostFolder(Some(e.to_string())) + }, + }; + let _ = senders.send_to_plugin(PluginInstruction::Update(vec![( + Some(instance_id), + Some(client_id_to_update), + event, + )])); + }, + ); + return Ok(()); + } let plugins_to_change: Vec<( PluginId, ClientId, @@ -1123,6 +1283,8 @@ impl WasmBridge { shutdown_sender: Sender<()>, mut notification_end: Option, ) -> Result<()> { + let messages = + self.dispatch_shared_pipes(messages, shutdown_sender.clone(), &mut notification_end); let plugins_to_update: Vec<( PluginId, ClientId, @@ -1265,6 +1427,9 @@ impl WasmBridge { .lock() .unwrap() .retain(|c| c != &client_id); + self.keybinds.remove(&client_id); + self.base_modes.remove(&client_id); + self.remove_client_from_shared_instances(client_id); // Remove client from cached pane render report if let Some(ref mut prev_report) = self.previous_pane_render_report { @@ -1413,6 +1578,10 @@ impl WasmBridge { client_id: ClientId, events: HashSet, ) { + if self.shared_instances.contains_key(&plugin_id) { + self.notify_shared_background_subscriptions(plugin_id); + return; + } let is_background = { let plugin_map = self.plugin_map.lock().unwrap(); plugin_map.contains(plugin_id, client_id) @@ -1431,13 +1600,24 @@ impl WasmBridge { } pub fn send_initial_keybinds_to_plugin(&self, plugin_id: PluginId, client_id: ClientId) { + if self.shared_instances.contains_key(&plugin_id) { + let clients: Vec = self.connected_clients.lock().unwrap().clone(); + for client_id in clients { + self.send_keybinds_payload_to_plugin( + plugin_id, + client_id, + self.keybinds_of_client(client_id).as_ref().clone(), + ); + } + return; + } let keybinds = { let mut plugin_map = self.plugin_map.lock().unwrap(); plugin_map .running_plugins_and_subscriptions() .iter() .find(|(pid, cid, _, _)| *pid == plugin_id && *cid == client_id) - .map(|(_, _, rp, _)| rp.lock().unwrap().store.data().keybinds.to_keybinds_vec()) + .map(|(_, _, rp, _)| rp.lock().unwrap().store.data().keybinds.as_ref().clone()) }; if let Some(keybinds) = keybinds { self.send_keybinds_payload_to_plugin(plugin_id, client_id, keybinds); @@ -1499,7 +1679,7 @@ impl WasmBridge { pub fn reconfigure( &mut self, client_id: ClientId, - keybinds: Option, + keybinds: Option, default_mode: Option, default_shell: Option, layout_dir: Option, @@ -1527,6 +1707,26 @@ impl WasmBridge { } self.default_shell = default_shell.clone(); self.layout_dir = layout_dir.clone(); + let shared_instance_ids: Vec = self.shared_instances.keys().copied().collect(); + for instance_id in shared_instance_ids { + let default_shell = default_shell.clone(); + let layout_dir = layout_dir.clone(); + self.plugin_executor.execute_for_plugin( + instance_id, + move |_senders, plugin_map, _connected_clients, _plugin_cache, _engine| { + host_settings_job(plugin_map, instance_id, default_shell, layout_dir); + }, + ); + if let Some(keybinds) = keybinds.as_ref() { + if self.shared_instance_subscribes_to(instance_id, EventType::InitialKeybinds) { + self.send_keybinds_payload_to_plugin( + instance_id, + client_id, + keybinds.as_ref().clone(), + ); + } + } + } // Collect plugins subscribed to InitialKeybinds for post-reconfigure notification let plugins_subscribed_to_initial_keybinds: Vec = if keybinds.is_some() { self.plugin_map @@ -1564,7 +1764,7 @@ impl WasmBridge { } // Send InitialKeybinds to subscribed plugins after reconfiguration if let Some(keybinds) = keybinds.as_ref() { - let keybinds_payload = keybinds.to_keybinds_vec(); + let keybinds_payload = keybinds.as_ref().clone(); for plugin_id in plugins_subscribed_to_initial_keybinds { self.send_keybinds_payload_to_plugin( plugin_id, @@ -1863,12 +2063,22 @@ impl WasmBridge { ) -> Result<()> { let err_context = || format!("Failed to write plugin permission {plugin_id}"); - let running_plugin = self - .plugin_map - .lock() - .unwrap() - .get_running_plugin(plugin_id, client_id) - .ok_or_else(|| anyhow!("Failed to get running plugin"))?; + let shared_running_plugin = self.shared_instance_of(plugin_id).and_then(|instance_id| { + self.plugin_map + .lock() + .unwrap() + .shared_running_plugin(instance_id) + .map(|(running_plugin, _)| running_plugin) + }); + let running_plugin = match shared_running_plugin { + Some(running_plugin) => running_plugin, + None => self + .plugin_map + .lock() + .unwrap() + .get_running_plugin(plugin_id, client_id) + .ok_or_else(|| anyhow!("Failed to get running plugin"))?, + }; let mut running_plugin = running_plugin.lock().unwrap(); @@ -1896,6 +2106,9 @@ impl WasmBridge { permission_cache.write_to_file().with_context(err_context) } pub fn cache_plugin_events(&mut self, plugin_id: PluginId) { + if self.shared_instance_of(plugin_id).is_some() { + return; + } self.plugin_ids_waiting_for_permission_request .insert(plugin_id); self.cached_events_for_pending_plugins @@ -1919,7 +2132,14 @@ impl WasmBridge { floating_pane_coordinates: Option, should_focus: bool, ) -> Vec<(PluginId, Option)> { - let run_plugin = run_plugin_or_alias.get_run_plugin(); + let run_plugin = run_plugin_or_alias + .get_run_plugin() + .map(|run_plugin| canonical_run_plugin(&run_plugin)); + if let Some(run_plugin) = run_plugin.as_ref() { + if let Some(targets) = self.existing_shared_targets(run_plugin, skip_cache) { + return targets; + } + } match run_plugin { Some(run_plugin) => { let all_plugin_ids = self.all_plugin_and_client_ids_for_plugin_location( @@ -1942,6 +2162,7 @@ impl WasmBridge { cli_client_id, ) { Ok((plugin_id, client_id)) => { + let is_shared = self.shared_slot_owner.contains_key(&plugin_id); let start_suppressed = false; drop(self.senders.send_to_screen(ScreenInstruction::AddPlugin( Some(should_float), @@ -1959,7 +2180,11 @@ impl WasmBridge { Some(client_id), None, ))); - vec![(plugin_id, Some(client_id))] + if is_shared { + vec![(plugin_id, None)] + } else { + vec![(plugin_id, Some(client_id))] + } }, Err(e) => { log::error!("Failed to load plugin: {e}"); @@ -2064,6 +2289,598 @@ impl WasmBridge { ), )])); } + pub fn shared_instance_ids(&self) -> Vec { + self.shared_instances.keys().copied().collect() + } + fn shared_instance_of(&self, plugin_id: PluginId) -> Option { + if let Some(instance_id) = self.shared_slot_owner.get(&plugin_id) { + Some(*instance_id) + } else if self.shared_instances.contains_key(&plugin_id) { + Some(plugin_id) + } else { + None + } + } + fn keybinds_of_client(&self, client_id: ClientId) -> &SharedKeybinds { + self.keybinds + .get(&client_id) + .unwrap_or(&self.default_keybinds) + } + fn shared_instance_subscribes_to(&self, instance_id: PluginId, event_type: EventType) -> bool { + self.plugin_map + .lock() + .unwrap() + .shared_running_plugin(instance_id) + .map(|(_, subscriptions)| subscriptions.lock().unwrap().contains(&event_type)) + .unwrap_or(false) + } + fn module_is_shared(&mut self, plugin_config: &PluginConfig, skip_cache: bool) -> bool { + if matches!(plugin_config.location, RunPluginLocation::Remote(_)) { + return false; + } + let cache_key = format!( + "{}|{}", + plugin_config.location, + plugin_config.path.display() + ); + if !skip_cache { + if let Some(is_shared) = self.shared_module_kinds.get(&cache_key) { + return *is_shared; + } + } + let is_shared = plugin_config + .resolve_wasm_bytes(&self.plugin_dir) + .map(|bytes| wasm_module_exports_function(&bytes, SHARED_MARKER_EXPORT)) + .unwrap_or(false); + self.shared_module_kinds.insert(cache_key, is_shared); + is_shared + } + fn shared_key_of(run_plugin: &RunPlugin) -> SharedKey { + ( + run_plugin.location.clone(), + run_plugin + .configuration + .inner() + .get(SHARED_INSTANCE_KEY) + .cloned(), + ) + } + fn load_shared_slot( + &mut self, + run: RunPlugin, + plugin_config: PluginConfig, + tab_index: Option, + size: Size, + cwd: Option, + skip_cache: bool, + client_id: ClientId, + is_background: bool, + ) -> Result<(PluginId, ClientId)> { + let slot_id = self.next_plugin_id; + self.next_plugin_id += 1; + let key = Self::shared_key_of(&run); + let kind = if is_background { + SlotKind::Background + } else { + SlotKind::Pane + }; + let plugin_cwd = cwd.unwrap_or_else(|| self.zellij_cwd.clone()); + self.plugin_map.lock().unwrap().insert_metadata( + slot_id, + PluginMetadata { + plugin_config: plugin_config.clone(), + tab_index, + rows: size.rows, + columns: size.cols, + cwd: plugin_cwd.clone(), + is_background, + }, + ); + let slot = SharedSlot { + kind, + configuration: run.configuration.inner().clone(), + rows: size.rows, + columns: size.cols, + }; + let _ = self + .senders + .send_to_background_jobs(BackgroundJob::AnimatePluginLoading(slot_id)); + let instance_id = match self.shared_keys.get(&key).copied() { + Some(instance_id) => { + self.register_shared_slot(instance_id, slot_id, kind); + let instance_is_running = self + .plugin_map + .lock() + .unwrap() + .shared_running_plugin(instance_id) + .is_some(); + let (slot_added_sender, slot_added_receiver) = std::sync::mpsc::channel(); + self.plugin_executor.execute_for_plugin( + instance_id, + move |senders, plugin_map, _connected_clients, _plugin_cache, _engine| { + add_slot_job( + senders, + plugin_map, + instance_id, + slot_id, + slot, + slot_added_sender, + ); + }, + ); + if instance_is_running { + let _ = slot_added_receiver + .recv_timeout(std::time::Duration::from_millis(SLOT_ADDED_WAIT_MS)); + } + instance_id + }, + None => { + let instance_id = self.next_plugin_id; + self.next_plugin_id += 1; + let mut loading_context = LoadingContext::new( + &self, + Some(plugin_cwd), + plugin_config, + instance_id, + client_id, + None, + size, + ); + loading_context.plugin_own_data_dir = ZELLIJ_SESSION_CACHE_DIR + .join(make_plugin_url_path_safe( + Url::from(&loading_context.plugin_config.location).to_string(), + )) + .join(format!("{}", instance_id)); + self.shared_instances.insert( + instance_id, + SharedInstanceInfo { + key: key.clone(), + loading_context: loading_context.clone(), + slots: BTreeSet::new(), + background_slots: BTreeSet::new(), + }, + ); + self.shared_keys.insert(key, instance_id); + self.register_shared_slot(instance_id, slot_id, kind); + let mut initial_slots = BTreeMap::new(); + initial_slots.insert(slot_id, slot); + self.plugin_executor.execute_plugin_load( + instance_id, + move |senders, plugin_map, connected_clients, plugin_cache, engine| { + start_instance_job( + senders, + plugin_map, + connected_clients, + plugin_cache, + engine, + loading_context, + skip_cache, + instance_id, + initial_slots, + ); + }, + ); + instance_id + }, + }; + if is_background { + self.notify_shared_background_subscriptions(instance_id); + } + Ok((slot_id, client_id)) + } + fn register_shared_slot(&mut self, instance_id: PluginId, slot_id: PluginId, kind: SlotKind) { + self.shared_slot_owner.insert(slot_id, instance_id); + if let Some(info) = self.shared_instances.get_mut(&instance_id) { + info.slots.insert(slot_id); + if kind == SlotKind::Background { + info.background_slots.insert(slot_id); + } + } + } + fn unload_shared_slot(&mut self, slot_id: PluginId) { + let Some(instance_id) = self.shared_slot_owner.remove(&slot_id) else { + return; + }; + self.plugin_map.lock().unwrap().remove_metadata(slot_id); + let connected_clients: Vec = self.connected_clients.lock().unwrap().clone(); + let tear_down = match self.shared_instances.get_mut(&instance_id) { + Some(info) => { + info.slots.remove(&slot_id); + if info.background_slots.remove(&slot_id) { + for client_id in &connected_clients { + let _ = self.senders.send_to_screen( + ScreenInstruction::UpdateBackgroundPluginSubscriptions( + slot_id, + *client_id, + HashSet::new(), + ), + ); + } + } + info.slots.is_empty() + }, + None => true, + }; + if tear_down { + if let Some(info) = self.shared_instances.remove(&instance_id) { + self.shared_keys.remove(&info.key); + } + self.plugin_executor.execute_plugin_unload( + instance_id, + move |senders, plugin_map, _connected_clients, _plugin_cache, _engine| { + remove_slot_job(senders, plugin_map, instance_id, slot_id, true); + }, + ); + let mut pipes_to_unblock = self.pending_pipes.unload_plugin(&instance_id); + for pipe_name in pipes_to_unblock.drain(..) { + let _ = self + .senders + .send_to_server(ServerInstruction::UnblockCliPipeInput(pipe_name)); + } + } else { + self.plugin_executor.execute_for_plugin( + instance_id, + move |senders, plugin_map, _connected_clients, _plugin_cache, _engine| { + remove_slot_job(senders, plugin_map, instance_id, slot_id, false); + }, + ); + } + self.cached_plugin_map.clear(); + let plugin_list = self.plugin_map.lock().unwrap().list_plugins(); + let _ = self + .senders + .send_to_background_jobs(BackgroundJob::ReportPluginList(plugin_list)); + } + fn shared_instances_matching(&self, run_plugin: &RunPlugin) -> Vec { + let Some(instance_id) = self.shared_keys.get(&Self::shared_key_of(run_plugin)) else { + return vec![]; + }; + let Some(info) = self.shared_instances.get(instance_id) else { + return vec![]; + }; + let plugin_map = self.plugin_map.lock().unwrap(); + let matches = info.slots.iter().any(|slot_id| { + plugin_map + .metadata(*slot_id) + .map(|metadata| { + metadata.plugin_config.initial_userspace_configuration + == run_plugin.configuration + }) + .unwrap_or(false) + }); + if matches { + vec![*instance_id] + } else { + vec![] + } + } + fn reload_shared_instance(&mut self, instance_id: PluginId) { + let Some(info) = self.shared_instances.get(&instance_id) else { + return; + }; + let slot_ids: Vec = info.slots.iter().copied().collect(); + let mut loading_context = info.loading_context.clone(); + if let Some(first_client_id) = self.get_first_client_id() { + loading_context.client_id = first_client_id; + } + let loading_indication = + LoadingIndication::new(loading_context.plugin_config.location.to_string()); + self.start_plugin_loading_indication(&slot_ids, &loading_indication); + self.plugin_executor.execute_for_plugin( + instance_id, + move |senders, plugin_map, connected_clients, plugin_cache, engine| { + start_instance_job( + senders, + plugin_map, + connected_clients, + plugin_cache, + engine, + loading_context, + true, + instance_id, + BTreeMap::new(), + ); + }, + ); + } + fn existing_shared_targets( + &mut self, + run_plugin: &RunPlugin, + skip_cache: bool, + ) -> Option)>> { + let plugin_config = PluginConfig::from_run_plugin(run_plugin)?; + if !self.module_is_shared(&plugin_config, skip_cache) { + return None; + } + let instance_id = *self.shared_keys.get(&Self::shared_key_of(run_plugin))?; + let info = self.shared_instances.get(&instance_id)?; + let matching_slots: Vec = { + let plugin_map = self.plugin_map.lock().unwrap(); + info.slots + .iter() + .copied() + .filter(|slot_id| { + plugin_map + .metadata(*slot_id) + .map(|metadata| { + metadata.plugin_config.initial_userspace_configuration + == run_plugin.configuration + }) + .unwrap_or(false) + }) + .collect() + }; + match matching_slots.as_slice() { + [] => None, + [slot_id] => Some(vec![(*slot_id, None)]), + _ => Some(vec![(instance_id, None)]), + } + } + fn notify_shared_background_subscriptions(&self, instance_id: PluginId) { + let Some(info) = self.shared_instances.get(&instance_id) else { + return; + }; + if info.background_slots.is_empty() { + return; + } + let subscriptions = self + .plugin_map + .lock() + .unwrap() + .shared_running_plugin(instance_id) + .map(|(_, subscriptions)| subscriptions.lock().unwrap().clone()); + let Some(subscriptions) = subscriptions else { + return; + }; + let connected_clients: Vec = self.connected_clients.lock().unwrap().clone(); + for slot_id in &info.background_slots { + for client_id in &connected_clients { + let _ = self.senders.send_to_screen( + ScreenInstruction::UpdateBackgroundPluginSubscriptions( + *slot_id, + *client_id, + subscriptions.clone(), + ), + ); + } + } + } + fn add_client_to_shared_instances(&mut self, client_id: ClientId) { + let instance_ids: Vec = self.shared_instances.keys().copied().collect(); + for instance_id in instance_ids { + self.plugin_executor.execute_for_plugin( + instance_id, + move |senders, plugin_map, _connected_clients, _plugin_cache, _engine| { + client_job(senders, plugin_map, instance_id, client_id, true); + }, + ); + if self.shared_instance_subscribes_to(instance_id, EventType::InitialKeybinds) { + self.send_keybinds_payload_to_plugin( + instance_id, + client_id, + self.keybinds_of_client(client_id).as_ref().clone(), + ); + } + self.notify_shared_background_subscriptions(instance_id); + } + } + fn remove_client_from_shared_instances(&mut self, client_id: ClientId) { + let instance_ids: Vec = self.shared_instances.keys().copied().collect(); + for instance_id in instance_ids { + self.plugin_executor.execute_for_plugin( + instance_id, + move |senders, plugin_map, _connected_clients, _plugin_cache, _engine| { + client_job(senders, plugin_map, instance_id, client_id, false); + }, + ); + if let Some(info) = self.shared_instances.get(&instance_id) { + for slot_id in &info.background_slots { + let _ = self.senders.send_to_screen( + ScreenInstruction::UpdateBackgroundPluginSubscriptions( + *slot_id, + client_id, + HashSet::new(), + ), + ); + } + } + } + } + pub fn update_client_visible_plugins( + &mut self, + visible_plugins: HashMap>, + ) { + for (instance_id, info) in self.shared_instances.iter() { + let instance_id = *instance_id; + let visible_slots: HashMap> = visible_plugins + .iter() + .map(|(client_id, plugin_ids)| { + ( + *client_id, + plugin_ids + .iter() + .copied() + .filter(|plugin_id| info.slots.contains(plugin_id)) + .collect(), + ) + }) + .collect(); + self.plugin_executor.execute_for_plugin( + instance_id, + move |senders, plugin_map, _connected_clients, _plugin_cache, _engine| { + visibility_job(senders, plugin_map, instance_id, visible_slots); + }, + ); + } + } + fn prepare_shared_event( + &self, + instance_id: PluginId, + event: &Event, + client_id: Option, + ) -> (Event, Option) { + match event { + Event::ModeUpdate(mode_info) => { + let mut mode_info = mode_info.clone(); + mode_info.keybinds = vec![]; + if mode_info.base_mode.is_none() { + mode_info.base_mode = Some( + client_id + .and_then(|client_id| self.base_modes.get(&client_id).copied()) + .unwrap_or(self.default_mode), + ); + } + let keybinds = if self + .shared_instance_subscribes_to(instance_id, EventType::InitialKeybinds) + { + None + } else { + Some(match client_id { + Some(client_id) => self.keybinds_of_client(client_id).clone(), + None => self.default_keybinds.clone(), + }) + }; + (Event::ModeUpdate(mode_info), keybinds) + }, + event => (event.clone(), None), + } + } + fn push_shared_event( + &self, + batches: &mut BTreeMap>, + instance_id: PluginId, + slot_id: Option, + client_id: Option, + event: &Event, + ) { + let context = shared_event_context(event, slot_id, client_id); + let (event, keybinds) = self.prepare_shared_event(instance_id, event, client_id); + let batch = batches.entry(instance_id).or_default(); + if batch + .iter() + .any(|(existing, existing_context, existing_keybinds)| { + existing_context == &context && existing == &event && existing_keybinds == &keybinds + }) + { + return; + } + batch.push((event, context, keybinds)); + } + fn dispatch_shared_updates( + &mut self, + updates: Vec<(Option, Option, Event)>, + shutdown_sender: Sender<()>, + ) -> Vec<(Option, Option, Event)> { + if self.shared_instances.is_empty() { + return updates; + } + let mut batches: BTreeMap> = BTreeMap::new(); + let mut legacy_updates = vec![]; + let all_instance_ids: Vec = self.shared_instances.keys().copied().collect(); + for (plugin_id, client_id, event) in updates { + match plugin_id { + Some(plugin_id) => match self.shared_instance_of(plugin_id) { + Some(instance_id) => { + let slot_id = if plugin_id != instance_id { + Some(plugin_id) + } else { + None + }; + self.push_shared_event( + &mut batches, + instance_id, + slot_id, + client_id, + &event, + ); + }, + None => legacy_updates.push((Some(plugin_id), client_id, event)), + }, + None => { + for instance_id in &all_instance_ids { + self.push_shared_event(&mut batches, *instance_id, None, client_id, &event); + } + legacy_updates.push((None, client_id, event)); + }, + } + } + for (instance_id, events) in batches { + let _s = shutdown_sender.clone(); + self.plugin_executor.execute_for_plugin( + instance_id, + move |senders, plugin_map, _connected_clients, _plugin_cache, _engine| { + let _s = _s; + apply_events_job(senders, plugin_map, instance_id, events); + }, + ); + } + legacy_updates + } + fn dispatch_shared_pipes( + &mut self, + messages: Vec<(Option, Option, PipeMessage)>, + shutdown_sender: Sender<()>, + notification_end: &mut Option, + ) -> Vec<(Option, Option, PipeMessage)> { + if self.shared_instances.is_empty() { + return messages; + } + let mut batches: BTreeMap> = BTreeMap::new(); + let mut legacy_messages = vec![]; + let all_instance_ids: Vec = self.shared_instances.keys().copied().collect(); + for (plugin_id, client_id, pipe_message) in messages { + let targets: Vec<(PluginId, Option)> = match plugin_id { + Some(plugin_id) => match self.shared_instance_of(plugin_id) { + Some(instance_id) => { + let slot_id = if plugin_id != instance_id { + Some(plugin_id) + } else { + None + }; + vec![(instance_id, slot_id)] + }, + None => { + legacy_messages.push((Some(plugin_id), client_id, pipe_message)); + continue; + }, + }, + None => { + legacy_messages.push((None, client_id, pipe_message.clone())); + all_instance_ids.iter().map(|id| (*id, None)).collect() + }, + }; + for (instance_id, slot_id) in targets { + let context = EventContext { slot_id, client_id }; + let batch = batches.entry(instance_id).or_default(); + if batch.iter().any(|(existing, existing_context)| { + existing == &pipe_message && existing_context == &context + }) { + continue; + } + if let PipeSource::Cli(pipe_id) = &pipe_message.source { + self.pending_pipes.mark_being_processed( + pipe_id, + &instance_id, + &SHARED_PIPE_CLIENT, + ); + } + batch.push((pipe_message.clone(), context)); + } + } + for (instance_id, messages) in batches { + let _s = shutdown_sender.clone(); + let notification_end = notification_end.take(); + self.plugin_executor.execute_for_plugin( + instance_id, + move |senders, plugin_map, _connected_clients, _plugin_cache, _engine| { + let _s = _s; + apply_pipes_job(senders, plugin_map, instance_id, messages); + drop(notification_end); + }, + ); + } + legacy_messages + } pub fn detect_and_notify_plugin_config_changes( &mut self, new_plugins: &PluginAliases, @@ -2071,6 +2888,31 @@ impl WasmBridge { ) -> Result<()> { let err_context = || "failed to detect plugin config changes"; + let shared_slots: Vec = self.shared_slot_owner.keys().copied().collect(); + for slot_id in shared_slots { + let Some(run_plugin) = self.run_plugin_of_plugin_id(slot_id) else { + continue; + }; + let candidates: Vec = new_plugins + .aliases + .values() + .map(|run_plugin| canonical_run_plugin(run_plugin)) + .filter(|candidate| candidate.location == run_plugin.location) + .collect(); + if candidates + .iter() + .any(|candidate| candidate.configuration == run_plugin.configuration) + { + continue; + } + if let [candidate] = candidates.as_slice() { + let event = + Event::PluginConfigurationChanged(candidate.configuration.inner().clone()); + self.update_plugins(vec![(Some(slot_id), None, event)], shutdown_send.clone()) + .with_context(err_context)?; + } + } + // Get all running plugins let running_plugins = self.plugin_map.lock().unwrap().running_plugins(); @@ -2105,7 +2947,7 @@ impl WasmBridge { } } -fn handle_plugin_successful_loading( +pub fn handle_plugin_successful_loading( senders: &ThreadSenders, plugin_id: PluginId, plugin_list: BTreeMap, @@ -2116,7 +2958,7 @@ fn handle_plugin_successful_loading( let _ = senders.send_to_plugin(PluginInstruction::RequestStateUpdateForPlugin(plugin_id)); } -fn handle_plugin_loading_failure( +pub fn handle_plugin_loading_failure( senders: &ThreadSenders, plugin_id: PluginId, loading_indication: &mut LoadingIndication, @@ -2140,7 +2982,7 @@ fn handle_plugin_loading_failure( } // TODO: move to permissions? -fn check_event_permission( +pub fn check_event_permission( plugin_env: &PluginEnv, event: &Event, ) -> (PermissionStatus, Option) { @@ -2206,19 +3048,18 @@ pub fn apply_event_to_plugin( match check_event_permission(running_plugin.store.data(), event) { (PermissionStatus::Granted, _) => { let mut event = event.clone(); + let mut keybinds = None; if let Event::ModeUpdate(mode_info) = &mut event { + mode_info.keybinds = vec![]; if mode_info.base_mode.is_none() { mode_info.base_mode = Some(running_plugin.store.data().default_mode); } - if plugin_subscriptions.contains(&EventType::InitialKeybinds) { - // Plugin caches keybindings via InitialKeybinds — send lightweight ModeUpdate - mode_info.keybinds = vec![]; - } else { - // Legacy plugin — send full keybindings as before - mode_info.keybinds = running_plugin.store.data().keybinds.to_keybinds_vec(); + if !plugin_subscriptions.contains(&EventType::InitialKeybinds) { + keybinds = Some(running_plugin.store.data().keybinds.clone()); } } - let protobuf_event: Result = event.clone().try_into(); + let is_permission_request_result = matches!(event, Event::PermissionRequestResult(..)); + let protobuf_event = event_to_protobuf_with_keybinds(event, keybinds.as_deref()); match protobuf_event { Ok(protobuf_event) => { let update = instance @@ -2230,7 +3071,7 @@ pub fn apply_event_to_plugin( .call(&mut running_plugin.store, ()) .with_context(err_context)?; let mut should_render = should_render == 1; - if let Event::PermissionRequestResult(..) = event { + if is_permission_request_result { // we always render in this case, otherwise the request permission screen stays on // screen should_render = true; @@ -2332,3 +3173,26 @@ pub fn apply_before_close_event_to_plugin( .context("failed to unblock input pipe"); Ok(()) } + +fn change_host_dir_of_running_plugin( + running_plugin: &mut RunningPlugin, + new_host_dir: &PathBuf, +) -> Result<()> { + if !new_host_dir.try_exists().unwrap_or(false) { + return Err(anyhow!("Folder {} does not exist", new_host_dir.display())); + } + let plugin_env = running_plugin.store.data_mut(); + let wasi_ctx = PluginLoader::create_wasi_ctx( + new_host_dir, + &plugin_env.plugin_own_data_dir, + &plugin_env.plugin_own_cache_dir, + &ZELLIJ_TMP_DIR, + &plugin_env.plugin.location.to_string(), + plugin_env.plugin_id, + plugin_env.stdin_pipe.clone(), + plugin_env.stdout_pipe.clone(), + )?; + drop(std::mem::replace(&mut plugin_env.wasi_ctx, wasi_ctx)); + plugin_env.plugin_cwd = new_host_dir.clone(); + Ok(()) +} diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs index 46e30f380..c77243783 100644 --- a/zellij-server/src/plugins/zellij_exports.rs +++ b/zellij-server/src/plugins/zellij_exports.rs @@ -3,8 +3,10 @@ use crate::background_jobs::BackgroundJob; use crate::global_async_runtime::get_tokio_runtime; use crate::plugins::plugin_map::PluginEnv; use crate::plugins::wasm_bridge::handle_plugin_crash; +use crate::plugins::PluginId; use crate::pty::{ClientTabIndexOrPaneId, PtyInstruction}; use crate::route::{route_action, wait_for_action_completion, NotificationEnd}; +use crate::ClientId; use crate::ServerInstruction; use log::warn; use serde::Serialize; @@ -108,7 +110,7 @@ use zellij_utils::{ ProtobufOpenTerminalPaneInPlaceOfPaneIdResponse, ProtobufOpenTerminalResponse, ProtobufParseLayoutResponse, ProtobufPluginCommand, ProtobufRenameLayoutResponse, ProtobufSaveLayoutResponse, ProtobufSaveSessionResponse, - ProtobufShowFloatingPanesResponse, + ProtobufShowFloatingPanesResponse, ProtobufSlotCommandResponse, }, plugin_ids::{ProtobufPluginIds, ProtobufZellijVersion}, }, @@ -124,9 +126,9 @@ macro_rules! apply_action { ($action:ident, $error_message:ident, $env: ident) => { match route_action( $action, - $env.client_id, + acting_client(&$env), None, - Some(PaneId::Plugin($env.plugin_id)), + source_pane(&$env), $env.senders.clone(), $env.default_shell.clone(), None, @@ -159,6 +161,154 @@ fn translate_plugin_path(env: &PluginEnv, path: PathBuf) -> PathBuf { } } +fn acting_client(env: &PluginEnv) -> ClientId { + env.shared + .as_ref() + .and_then(|shared| shared.current_client) + .unwrap_or(env.client_id) +} + +fn self_pane_id(env: &PluginEnv) -> PluginId { + env.shared + .as_ref() + .and_then(|shared| shared.current_slot) + .unwrap_or(env.plugin_id) +} + +fn source_pane(env: &PluginEnv) -> Option { + match env.shared.as_ref() { + Some(shared) => shared.current_slot.map(PaneId::Plugin), + None => Some(PaneId::Plugin(env.plugin_id)), + } +} + +fn permission_pane_id(env: &PluginEnv) -> PluginId { + match env.shared.as_ref() { + Some(shared) => shared + .current_slot + .or_else(|| shared.first_pane_slot()) + .unwrap_or(env.plugin_id), + None => env.plugin_id, + } +} + +fn crash_pane_ids(env: &PluginEnv) -> Vec { + match env.shared.as_ref() { + Some(shared) => shared.pane_slot_ids(), + None => vec![env.plugin_id], + } +} + +fn is_self_command(command: &PluginCommand) -> bool { + matches!( + command, + PluginCommand::SetSelectable(..) + | PluginCommand::ShowCursor(..) + | PluginCommand::HideSelf + | PluginCommand::ShowSelf(..) + | PluginCommand::CloseSelf + | PluginCommand::SetSelfMouseSelectionSupport(..) + ) || self_command_has_response(command) +} + +fn self_command_has_response(command: &PluginCommand) -> bool { + matches!( + command, + PluginCommand::OpenFileNearPlugin(..) + | PluginCommand::OpenFileFloatingNearPlugin(..) + | PluginCommand::OpenFileInPlaceOfPlugin(..) + | PluginCommand::OpenTerminalNearPlugin(..) + | PluginCommand::OpenTerminalFloatingNearPlugin(..) + | PluginCommand::OpenTerminalInPlaceOfPlugin(..) + | PluginCommand::OpenCommandPaneNearPlugin(..) + | PluginCommand::OpenCommandPaneFloatingNearPlugin(..) + | PluginCommand::OpenCommandPaneInPlaceOfPlugin(..) + ) +} + +fn self_command_is_forbidden(env: &PluginEnv, command: &PluginCommand) -> bool { + match env.shared.as_ref() { + Some(shared) => { + is_self_command(command) + && !(shared.self_commands_allowed && shared.current_slot.is_some()) + }, + None => false, + } +} + +fn slot_command_target(env: &PluginEnv, slot_id: PluginId) -> std::result::Result<(), String> { + match env.shared.as_ref() { + Some(shared) if shared.slots.contains_key(&slot_id) => Ok(()), + Some(_) => Err(format!("Slot {} does not belong to this plugin", slot_id)), + None if slot_id == env.plugin_id => Ok(()), + None => Err(format!( + "Slot {} does not belong to this plugin, slot commands are meant for shared plugins", + slot_id + )), + } +} + +fn write_slot_command_response(env: &PluginEnv, result: std::result::Result<(), String>) { + let response = ProtobufSlotCommandResponse::from(result); + wasi_write_object(env, &response.encode_to_vec()) + .with_context(|| format!("failed to write slot command response")) + .non_fatal(); +} + +fn set_selectable_slot(env: &PluginEnv, slot_id: PluginId, selectable: bool) { + let result = slot_command_target(env, slot_id).and_then(|_| { + env.senders + .send_to_screen(ScreenInstruction::SetSelectable( + PaneId::Plugin(slot_id), + selectable, + )) + .map_err(|e| e.to_string()) + }); + write_slot_command_response(env, result); +} + +fn hide_slot(env: &PluginEnv, slot_id: PluginId) { + let result = slot_command_target(env, slot_id).and_then(|_| { + env.senders + .send_to_screen(ScreenInstruction::SuppressPane( + PaneId::Plugin(slot_id), + acting_client(env), + )) + .map_err(|e| e.to_string()) + }); + write_slot_command_response(env, result); +} + +fn show_slot(env: &PluginEnv, slot_id: PluginId, should_float_if_hidden: bool) { + let result = slot_command_target(env, slot_id).map(|_| { + let action = Action::FocusPluginPaneWithId { + pane_id: slot_id, + should_float_if_hidden, + should_be_in_place_if_hidden: false, + }; + let error_msg = || format!("Failed to show slot"); + apply_action!(action, error_msg, env); + }); + write_slot_command_response(env, result); +} + +fn close_slot(env: &PluginEnv, slot_id: PluginId) { + let result = slot_command_target(env, slot_id).and_then(|_| { + env.senders + .send_to_screen(ScreenInstruction::ClosePane( + PaneId::Plugin(slot_id), + None, + None, + None, + )) + .map_err(|e| e.to_string())?; + env.senders + .send_to_plugin(PluginInstruction::Unload(slot_id)) + .map_err(|e| e.to_string()) + }); + write_slot_command_response(env, result); +} + pub fn zellij_exports(linker: &mut Linker) { linker .func_wrap("zellij", "host_run_plugin_command", host_run_plugin_command) @@ -175,8 +325,25 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) { let command: PluginCommand = command .try_into() .map_err(|e| anyhow!("failed to convert serialized command: {}", e))?; + if self_command_is_forbidden(&env, &command) { + if self_command_has_response(&command) { + wasi_write_object(env, &Vec::::new())?; + } + return Err(anyhow!( + "'self' commands of shared plugins are only available inside render and slot_added, use the explicit slot form instead: {:?}", + CommandType::from_str(&command.to_string()).ok() + )); + } match check_command_permission(&env, &command) { (PermissionStatus::Granted, _) => match command { + PluginCommand::SetSelectableSlot(slot_id, selectable) => { + set_selectable_slot(env, slot_id, selectable) + }, + PluginCommand::HideSlot(slot_id) => hide_slot(env, slot_id), + PluginCommand::ShowSlot(slot_id, should_float_if_hidden) => { + show_slot(env, slot_id, should_float_if_hidden) + }, + PluginCommand::CloseSlot(slot_id) => close_slot(env, slot_id), PluginCommand::Subscribe(event_list) => subscribe(env, event_list)?, PluginCommand::Unsubscribe(event_list) => unsubscribe(env, event_list)?, PluginCommand::SetSelectable(selectable) => set_selectable(env, selectable), @@ -843,7 +1010,7 @@ fn subscribe(env: &PluginEnv, event_list: HashSet) -> Result<()> { env.senders .send_to_plugin(PluginInstruction::PluginSubscribedToEvents( env.plugin_id, - env.client_id, + acting_client(env), event_list, )) } @@ -895,7 +1062,7 @@ fn unsubscribe(env: &PluginEnv, event_list: HashSet) -> Result<()> { fn set_selectable(env: &PluginEnv, selectable: bool) { env.senders .send_to_screen(ScreenInstruction::SetSelectable( - PaneId::Plugin(env.plugin_id), + PaneId::Plugin(self_pane_id(env)), selectable, )) .with_context(|| { @@ -911,8 +1078,8 @@ fn set_selectable(env: &PluginEnv, selectable: bool) { fn show_cursor(env: &PluginEnv, cursor_position: Option<(usize, usize)>) { env.senders .send_to_screen(ScreenInstruction::ShowPluginCursor( - env.plugin_id, - env.client_id, + self_pane_id(env), + acting_client(env), cursor_position, )) .with_context(|| { @@ -938,7 +1105,7 @@ fn request_permission(env: &PluginEnv, permissions: Vec) -> Resu .senders .send_to_plugin(PluginInstruction::PermissionRequestResult( env.plugin_id, - Some(env.client_id), + Some(acting_client(env)), permissions.to_vec(), PermissionStatus::Granted, None, @@ -955,7 +1122,7 @@ fn request_permission(env: &PluginEnv, permissions: Vec) -> Resu env.senders .send_to_screen(ScreenInstruction::RequestPluginPermissions( - env.plugin_id, + permission_pane_id(env), PluginPermission::new(env.plugin.location.to_string(), permissions), )) } @@ -965,7 +1132,7 @@ fn get_plugin_ids(env: &PluginEnv) { plugin_id: env.plugin_id, zellij_pid: process::id(), initial_cwd: env.plugin_cwd.clone(), - client_id: env.client_id, + client_id: acting_client(env), }; ProtobufPluginIds::try_from(ids) .map_err(|e| anyhow!("Failed to serialized plugin ids: {}", e)) @@ -1091,7 +1258,7 @@ fn open_command_pane_in_new_tab( hold_on_start, originating_plugin: Some(OriginatingPlugin::new( env.plugin_id, - env.client_id, + acting_client(env), context, )), use_terminal_title: false, @@ -1277,7 +1444,7 @@ fn get_focused_pane_info(env: &PluginEnv) { let err_context = || { format!( "failed to get focused pane info for client {:?} from plugin {}", - env.client_id, + acting_client(env), env.name() ) }; @@ -1288,7 +1455,7 @@ fn get_focused_pane_info(env: &PluginEnv) { // Send request to screen thread env.senders .send_to_screen(ScreenInstruction::GetFocusedPaneInfo { - client_id: env.client_id, + client_id: acting_client(env), response_channel: response_sender, }) .with_context(err_context) @@ -1301,7 +1468,7 @@ fn get_focused_pane_info(env: &PluginEnv) { log::error!( "GetFocusedPaneInfo timed out for plugin {} for client {:?}", env.plugin_id, - env.client_id + acting_client(env) ); GetFocusedPaneInfoResponse::Err("Timeout retrieving focused pane info".to_string()) }, @@ -1446,7 +1613,7 @@ fn open_file(env: &PluginEnv, file_to_open: FileToOpen, context: BTreeMap Result<()> env.senders .send_to_plugin(PluginInstruction::PostMessagesToPluginWorker( env.plugin_id, - env.client_id, + acting_client(env), worker_name, vec![(plugin_message.name, plugin_message.payload)], )) @@ -2669,7 +2836,7 @@ fn post_message_to_plugin(env: &PluginEnv, plugin_message: PluginMessage) -> Res env.senders .send_to_plugin(PluginInstruction::PostMessageToPlugin( env.plugin_id, - env.client_id, + acting_client(env), plugin_message.name, plugin_message.payload, )) @@ -2678,21 +2845,21 @@ fn post_message_to_plugin(env: &PluginEnv, plugin_message: PluginMessage) -> Res fn hide_self(env: &PluginEnv) -> Result<()> { env.senders .send_to_screen(ScreenInstruction::SuppressPane( - PaneId::Plugin(env.plugin_id), - env.client_id, + PaneId::Plugin(self_pane_id(env)), + acting_client(env), )) .with_context(|| format!("failed to hide self")) } fn hide_pane_with_id(env: &PluginEnv, pane_id: PaneId) -> Result<()> { env.senders - .send_to_screen(ScreenInstruction::SuppressPane(pane_id, env.client_id)) + .send_to_screen(ScreenInstruction::SuppressPane(pane_id, acting_client(env))) .with_context(|| format!("failed to hide self")) } fn show_self(env: &PluginEnv, should_float_if_hidden: bool) { let action = Action::FocusPluginPaneWithId { - pane_id: env.plugin_id, + pane_id: self_pane_id(env), should_float_if_hidden, should_be_in_place_if_hidden: false, }; @@ -2713,7 +2880,7 @@ fn show_pane_with_id( pane_id, should_float_if_hidden, false, - env.client_id, + acting_client(env), None, )); } else { @@ -2729,7 +2896,7 @@ fn show_pane_with_id( fn close_self(env: &PluginEnv) { env.senders .send_to_screen(ScreenInstruction::ClosePane( - PaneId::Plugin(env.plugin_id), + PaneId::Plugin(self_pane_id(env)), None, None, None, @@ -2737,14 +2904,14 @@ fn close_self(env: &PluginEnv) { .with_context(|| format!("failed to close self")) .non_fatal(); env.senders - .send_to_plugin(PluginInstruction::Unload(env.plugin_id)) + .send_to_plugin(PluginInstruction::Unload(self_pane_id(env))) .with_context(|| format!("failed to close self")) .non_fatal(); } fn reconfigure(env: &PluginEnv, new_config: String, write_config_to_disk: bool) -> Result<()> { let err_context = || "Failed to reconfigure"; - let client_id = env.client_id; + let client_id = acting_client(env); env.senders .send_to_server(ServerInstruction::Reconfigure { client_id, @@ -2762,7 +2929,7 @@ fn rebind_keys( write_config_to_disk: bool, ) -> Result<()> { let err_context = || "Failed to rebind_keys"; - let client_id = env.client_id; + let client_id = acting_client(env); env.senders .send_to_server(ServerInstruction::RebindKeys { client_id, @@ -3038,7 +3205,7 @@ fn switch_session( { log::error!("Session names cannot contain \'/\'"); } else { - let client_id = env.client_id; + let client_id = acting_client(env); let tab_position = tab_position.map(|p| p + 1); // ¯\_()_/¯ let cwd = cwd .map(|c| translate_plugin_path(env, c)) @@ -3219,7 +3386,10 @@ fn toggle_focus_no_ui_fullscreen(env: &PluginEnv) { fn focus_host_session(env: &PluginEnv) { env.senders - .send_to_screen(ScreenInstruction::FocusHostSession(env.client_id, None)) + .send_to_screen(ScreenInstruction::FocusHostSession( + acting_client(env), + None, + )) .with_context(|| format!("failed to focus host session from plugin {}", env.name())) .non_fatal(); } @@ -3513,7 +3683,9 @@ fn rename_session(env: &PluginEnv, new_session_name: String) { fn disconnect_other_clients(env: &PluginEnv) { let _ = env .senders - .send_to_server(ServerInstruction::DisconnectAllClientsExcept(env.client_id)) + .send_to_server(ServerInstruction::DisconnectAllClientsExcept( + acting_client(env), + )) .context("failed to send disconnect other clients instruction"); } @@ -3733,7 +3905,7 @@ fn save_session(env: &PluginEnv) { let (completion_tx, completion_rx) = oneshot::channel(); let send_result = env.senders.send_to_screen(ScreenInstruction::SaveSession( - env.client_id, + acting_client(env), Some(NotificationEnd::new(completion_tx)), )); @@ -3761,7 +3933,7 @@ fn show_floating_panes(env: &PluginEnv, tab_id: Option) { let send_result = env .senders .send_to_screen(ScreenInstruction::ShowFloatingPanes { - client_id: env.client_id, + client_id: acting_client(env), tab_id, completion: Some(NotificationEnd::new(completion_tx)), }); @@ -3792,7 +3964,7 @@ fn hide_floating_panes(env: &PluginEnv, tab_id: Option) { let send_result = env .senders .send_to_screen(ScreenInstruction::HideFloatingPanes { - client_id: env.client_id, + client_id: acting_client(env), tab_id, completion: Some(NotificationEnd::new(completion_tx)), }); @@ -4008,7 +4180,7 @@ fn list_clients(env: &PluginEnv) { let _ = env.senders.to_screen.as_ref().map(|sender| { sender.send(ScreenInstruction::ListClientsToPlugin( env.plugin_id, - env.client_id, + acting_client(env), )) }); } @@ -4018,7 +4190,7 @@ fn change_host_folder(env: &PluginEnv, new_host_folder: PathBuf) { sender.send(PluginInstruction::ChangePluginHostDir( new_host_folder, env.plugin_id, - env.client_id, + acting_client(env), )) }); } @@ -4033,9 +4205,11 @@ fn set_floating_pane_pinned(env: &PluginEnv, pane_id: PaneId, should_be_pinned: } fn stack_panes(env: &PluginEnv, pane_ids: Vec) { - let _ = - env.senders - .send_to_screen(ScreenInstruction::StackPanes(pane_ids, env.client_id, None)); + let _ = env.senders.send_to_screen(ScreenInstruction::StackPanes( + pane_ids, + acting_client(env), + None, + )); } fn change_floating_panes_coordinates( @@ -4108,7 +4282,7 @@ fn scan_host_folder(env: &PluginEnv, folder_to_scan: PathBuf) { Ok(reading_folder) => { let send_plugin_instructions = env.senders.to_plugin.clone(); let update_target = Some(env.plugin_id); - let client_id = env.client_id; + let client_id = acting_client(env); thread::spawn({ move || { let mut paths_in_folder = vec![]; @@ -4158,7 +4332,7 @@ fn list_windows_volumes(_env: &PluginEnv) { fn set_soft_keyboard(env: &PluginEnv, on: bool) { env.senders .send_to_screen(ScreenInstruction::SetSoftKeyboard { - client_id: env.client_id, + client_id: acting_client(env), on, }) .with_context(|| { @@ -4174,7 +4348,7 @@ fn set_soft_keyboard(env: &PluginEnv, on: bool) { fn list_windows_volumes(env: &PluginEnv) { let send_plugin_instructions = env.senders.to_plugin.clone(); let update_target = Some(env.plugin_id); - let client_id = env.client_id; + let client_id = acting_client(env); thread::spawn(move || { let mut entries = enumerate_drives(); entries.extend(enumerate_wsl_distributions()); @@ -4280,7 +4454,7 @@ fn get_pane_scrollback(env: &PluginEnv, pane_id: PaneId, get_full_scrollback: bo env.senders .send_to_screen(ScreenInstruction::GetPaneScrollback { pane_id, - client_id: env.client_id, + client_id: acting_client(env), get_full_scrollback, response_channel: response_sender, }) @@ -4829,7 +5003,7 @@ fn try_edit_layout( ) .with_originating_plugin(OriginatingPlugin::new( env.plugin_id, - env.client_id, + acting_client(env), context, )), direction: None, @@ -4845,9 +5019,9 @@ fn try_edit_layout( // Route the action - this is fallible route_action( action, - env.client_id, + acting_client(env), None, - Some(PaneId::Plugin(env.plugin_id)), + source_pane(env), env.senders.clone(), env.default_shell.clone(), None, @@ -4995,7 +5169,7 @@ fn break_panes_to_new_tab( default_shell, new_tab_name, should_change_focus_to_new_tab, - client_id: env.client_id, + client_id: acting_client(env), completion_tx, }); @@ -5024,7 +5198,7 @@ fn break_panes_to_tab_with_index( .send_to_screen(ScreenInstruction::BreakPanesToTabWithIndex { pane_ids, tab_index, - client_id: env.client_id, + client_id: acting_client(env), should_change_focus_to_new_tab, completion_tx, }); @@ -5044,7 +5218,7 @@ fn switch_tab_to_id(env: &PluginEnv, tab_id: u64) { env.senders .send_to_screen(ScreenInstruction::GoToTabWithId( tab_id, - Some(env.client_id), + Some(acting_client(env)), None, )) .with_context(|| { @@ -5062,7 +5236,7 @@ fn go_to_tab_with_id(env: &PluginEnv, tab_id: u64) { env.senders .send_to_screen(ScreenInstruction::GoToTabWithId( tab_id, - Some(env.client_id), + Some(acting_client(env)), None, )) .with_context(|| format!("failed to go to tab {} from plugin {}", tab_id, env.name())) @@ -5104,7 +5278,7 @@ fn break_panes_to_tab_with_id( pane_ids, tab_id, should_change_focus_to_target_tab, - client_id: env.client_id, + client_id: acting_client(env), completion_tx, }); @@ -5143,7 +5317,7 @@ fn load_new_plugin( .senders .send_to_plugin(PluginInstruction::LoadBackgroundPlugin( run_plugin_or_alias, - env.client_id, + acting_client(env), )); }, Err(e) => { @@ -5156,7 +5330,7 @@ fn load_new_plugin( let pane_title = None; let tab_index = None; let pane_id_to_replace = None; - let client_id = env.client_id; + let client_id = acting_client(env); let size = Default::default(); let cwd = Some(env.plugin_cwd.clone()); let skip_cache = skip_plugin_cache; @@ -5190,7 +5364,7 @@ fn load_new_plugin( fn start_web_server(env: &PluginEnv) { let _ = env .senders - .send_to_server(ServerInstruction::StartWebServer(env.client_id)); + .send_to_server(ServerInstruction::StartWebServer(acting_client(env))); } fn stop_web_server(_env: &PluginEnv) { @@ -5209,13 +5383,15 @@ fn query_web_server_status(env: &PluginEnv) { fn share_current_session(env: &PluginEnv) { let _ = env .senders - .send_to_server(ServerInstruction::ShareCurrentSession(env.client_id)); + .send_to_server(ServerInstruction::ShareCurrentSession(acting_client(env))); } fn stop_sharing_current_session(env: &PluginEnv) { let _ = env .senders - .send_to_server(ServerInstruction::StopSharingCurrentSession(env.client_id)); + .send_to_server(ServerInstruction::StopSharingCurrentSession(acting_client( + env, + ))); } fn group_and_ungroup_panes( @@ -5230,7 +5406,7 @@ fn group_and_ungroup_panes( panes_to_group, panes_to_ungroup, for_all_clients, - env.client_id, + acting_client(env), )); } @@ -5244,7 +5420,7 @@ fn highlight_and_unhighlight_panes( .send_to_screen(ScreenInstruction::HighlightAndUnhighlightPanes( panes_to_highlight, panes_to_unhighlight, - env.client_id, + acting_client(env), )); } @@ -5266,7 +5442,7 @@ fn float_multiple_panes(env: &PluginEnv, pane_ids: Vec) { .senders .send_to_screen(ScreenInstruction::FloatMultiplePanes( pane_ids, - env.client_id, + acting_client(env), )); } @@ -5275,7 +5451,7 @@ fn embed_multiple_panes(env: &PluginEnv, pane_ids: Vec) { .senders .send_to_screen(ScreenInstruction::EmbedMultiplePanes( pane_ids, - env.client_id, + acting_client(env), )); } @@ -5402,7 +5578,7 @@ fn list_web_login_tokens(env: &PluginEnv) { fn set_self_mouse_selection_support(env: &PluginEnv, selection_support: bool) { env.senders .send_to_screen(ScreenInstruction::SetMouseSelectionSupport( - PaneId::Plugin(env.plugin_id), + PaneId::Plugin(self_pane_id(env)), selection_support, )) .with_context(|| { @@ -5421,7 +5597,7 @@ fn intercept_key_presses(env: &mut PluginEnv) { .senders .send_to_screen(ScreenInstruction::InterceptKeyPresses( env.plugin_id, - env.client_id, + acting_client(env), )); } @@ -5429,7 +5605,9 @@ fn clear_key_presses_intercepts(env: &mut PluginEnv) { env.intercepting_key_presses = false; let _ = env .senders - .send_to_screen(ScreenInstruction::ClearKeyPressesIntercepts(env.client_id)); + .send_to_screen(ScreenInstruction::ClearKeyPressesIntercepts(acting_client( + env, + ))); } fn replace_pane_with_existing_pane( @@ -5508,7 +5686,9 @@ fn override_layout( // formatted as string from the plugin. fn report_panic(env: &PluginEnv, msg: &str) { log::error!("PANIC IN PLUGIN!\n\r{}", msg); - handle_plugin_crash(env.plugin_id, msg.to_owned(), env.senders.clone()); + for pane_id in crash_pane_ids(env) { + handle_plugin_crash(pane_id, msg.to_owned(), env.senders.clone()); + } } // Helper Functions --------------------------------------------------------------------------------------------------- diff --git a/zellij-server/src/pty.rs b/zellij-server/src/pty.rs index 68a2371b2..e946718f3 100644 --- a/zellij-server/src/pty.rs +++ b/zellij-server/src/pty.rs @@ -1833,6 +1833,10 @@ impl Pty { .send_to_plugin(PluginInstruction::Unload(pid)), ), } + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::TrimAllocator); Ok(()) } pub fn close_tab(&mut self, ids: Vec) -> Result<()> { diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index 11700b052..d9d82a585 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -34,9 +34,11 @@ use std::net::{IpAddr, Ipv4Addr}; use std::path::PathBuf; use std::rc::Rc; use std::str; +use std::sync::Arc; use std::time::{Duration, Instant}; use crate::route::NotificationEnd; +use crate::SharedKeybinds; use log::{debug, warn}; use zellij_utils::data::{ @@ -805,7 +807,7 @@ pub enum ScreenInstruction { }, Reconfigure { client_id: ClientId, - keybinds: Keybinds, + keybinds: SharedKeybinds, default_mode: InputMode, theme: Styling, /// Resolved styling for `theme_dark`. When both this and @@ -1604,6 +1606,8 @@ pub(crate) struct Screen { pane_history: BTreeMap>, mode_info: BTreeMap, default_mode_info: ModeInfo, // TODO: restructure ModeInfo to prevent this duplication + default_keybinds: SharedKeybinds, + client_keybinds: BTreeMap, style: Style, pane_frame_style: PaneFrameStyle, auto_layout: bool, @@ -1656,6 +1660,8 @@ pub(crate) struct Screen { pane_render_subscribers: HashMap, background_plugin_subscriptions: HashMap<(PluginId, ClientId), HashSet>, last_reported_plugin_tab_indices: HashMap, + last_reported_client_visible_plugins: HashMap>, + pending_selectable_panes: HashMap, state_report_target: Option<(PluginId, ClientId)>, next_forward_token: u32, pending_forwarded_queries: HashMap, @@ -1763,7 +1769,7 @@ impl Screen { bus: Bus, client_attributes: &ClientAttributes, max_panes: Option, - mode_info: ModeInfo, + mut mode_info: ModeInfo, pane_frame_style: PaneFrameStyle, auto_layout: bool, session_is_mirrored: bool, @@ -1832,6 +1838,8 @@ impl Screen { tab_history: BTreeMap::new(), pane_history: BTreeMap::new(), mode_info: BTreeMap::new(), + default_keybinds: Arc::new(std::mem::take(&mut mode_info.keybinds)), + client_keybinds: BTreeMap::new(), default_mode_info: mode_info, pane_frame_style, auto_layout, @@ -1878,6 +1886,8 @@ impl Screen { pane_render_subscribers: HashMap::new(), background_plugin_subscriptions: HashMap::new(), last_reported_plugin_tab_indices: HashMap::new(), + last_reported_client_visible_plugins: HashMap::new(), + pending_selectable_panes: HashMap::new(), state_report_target: None, next_forward_token: 1, // 0 is reserved as the startup sentinel pending_forwarded_queries: HashMap::new(), @@ -4146,6 +4156,7 @@ impl Screen { if !changed && !ascend_keys_changed { return; } + self.ensure_client_keybinds(client_id); let mode_info = self .mode_info .entry(client_id) @@ -4169,29 +4180,23 @@ impl Screen { } fn own_ascend_shortcut(&self) -> Vec { - shortcut_for_action( - &self.default_mode_info.keybinds, - self.base_input_mode(), - |action| matches!(action, Action::FocusHostSession), - ) + shortcut_for_action(&self.default_keybinds, self.base_input_mode(), |action| { + matches!(action, Action::FocusHostSession) + }) .unwrap_or_default() } fn own_descend_shortcut(&self) -> Vec { - shortcut_for_action( - &self.default_mode_info.keybinds, - self.base_input_mode(), - |action| matches!(action, Action::FocusGuestSession), - ) + shortcut_for_action(&self.default_keybinds, self.base_input_mode(), |action| { + matches!(action, Action::FocusGuestSession) + }) .unwrap_or_default() } fn own_host_zoom_shortcut(&self) -> Vec { - shortcut_for_action( - &self.default_mode_info.keybinds, - self.base_input_mode(), - |action| matches!(action, Action::ToggleHostFullscreen), - ) + shortcut_for_action(&self.default_keybinds, self.base_input_mode(), |action| { + matches!(action, Action::ToggleHostFullscreen) + }) .unwrap_or_default() } @@ -4292,6 +4297,7 @@ impl Screen { } } for client_id in client_ids { + self.ensure_client_keybinds(client_id); let mode_info = self .mode_info .entry(client_id) @@ -5751,6 +5757,8 @@ impl Screen { self.recompute_tab_size(prev_tab_id) .with_context(err_context)?; } + self.mode_info.remove(&client_id); + self.client_keybinds.remove(&client_id); self.held_keybinds_requests.remove(&client_id); self.pending_keybinds_requests.retain(|_, pending| { !matches!(pending.reply_to, KeybindsReplyTo::Host { client_id: host_client_id, .. } if host_client_id == client_id) @@ -6057,14 +6065,52 @@ impl Screen { .send_to_plugin(PluginInstruction::UpdatePluginTabIndices(tab_indices)); self.last_reported_plugin_tab_indices = current; } + fn apply_pending_selectable_panes(&mut self) { + if self.pending_selectable_panes.is_empty() { + return; + } + let pending: Vec<(PaneId, bool)> = self.pending_selectable_panes.drain().collect(); + for (pane_id, selectable) in pending { + let tab = self + .tabs + .values_mut() + .find(|tab| tab.has_pane_with_pid(&pane_id)); + match tab { + Some(tab) => tab.set_pane_selectable(pane_id, selectable), + None => { + self.pending_selectable_panes.insert(pane_id, selectable); + }, + } + } + } + fn report_client_visible_plugins(&mut self) { + let mut visible: HashMap> = HashMap::new(); + for (client_id, tab_id) in self.active_tab_ids.iter() { + if let Some(tab) = self.tabs.get(tab_id) { + visible.insert(*client_id, tab.get_plugin_ids().into_iter().collect()); + } + } + if visible == self.last_reported_client_visible_plugins { + return; + } + let _ = self + .bus + .senders + .send_to_plugin(PluginInstruction::UpdateClientVisiblePlugins( + visible.clone(), + )); + self.last_reported_client_visible_plugins = visible; + } fn log_and_report_session_state(&mut self) -> Result<()> { let err_context = || format!("Failed to log and report session state"); + self.apply_pending_selectable_panes(); self.update_active_pane_ids(); self.revert_fit_disabled_without_reference_client() .with_context(err_context)?; self.reconcile_all_single_pane_focus(); self.report_plugin_tab_indices(); + self.report_client_visible_plugins(); // generate own session info let pane_manifest = self.generate_and_report_pane_state()?; let tab_infos = self.generate_and_report_tab_state()?; @@ -6610,6 +6656,7 @@ impl Screen { base_mode: Option, client_id: ClientId, ) -> Result<()> { + self.ensure_client_keybinds(client_id); let mut mode_info = self .mode_info .get(&client_id) @@ -6687,10 +6734,25 @@ impl Screen { } fn keybinds_for_client(&self, client_id: ClientId) -> KeybindsVec { - self.mode_info + self.client_keybinds .get(&client_id) - .map(|mode_info| mode_info.keybinds.clone()) - .unwrap_or_else(|| self.default_mode_info.keybinds.clone()) + .unwrap_or(&self.default_keybinds) + .as_ref() + .clone() + } + + fn update_keybinds(&mut self, new_keybinds: SharedKeybinds, client_id: ClientId) { + self.default_keybinds = new_keybinds.clone(); + if self.connected_clients_contains(&client_id) { + self.client_keybinds.insert(client_id, new_keybinds); + } + } + + fn ensure_client_keybinds(&mut self, client_id: ClientId) { + if !self.client_keybinds.contains_key(&client_id) { + self.client_keybinds + .insert(client_id, self.default_keybinds.clone()); + } } // Keep the client's mode in sync with the pane it just focused: entering a scrolled @@ -7482,7 +7544,7 @@ impl Screen { } pub fn reconfigure( &mut self, - new_keybinds: Keybinds, + new_keybinds: SharedKeybinds, new_default_mode: InputMode, theme: Styling, simplified_ui: bool, @@ -7529,7 +7591,7 @@ impl Screen { // `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.update_keybinds(new_keybinds, client_id); 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()); @@ -7596,7 +7658,6 @@ impl Screen { .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); @@ -7658,6 +7719,7 @@ impl Screen { let client_ids: Vec = self.active_tab_ids.keys().copied().collect(); let default_for_new = self.default_mode_info.clone(); for client_id in client_ids { + self.ensure_client_keybinds(client_id); let mode_info = self .mode_info .entry(client_id) @@ -8704,6 +8766,7 @@ pub(crate) fn screen_thread_main( config: Config, debug: bool, default_layout: Box, + default_keybinds: SharedKeybinds, ) -> Result<()> { // Resolve `theme_dark` / `theme_light` to concrete `Styling` from the // bundled themes BEFORE `config.options` is moved out below. These @@ -8821,7 +8884,7 @@ pub(crate) fn screen_thread_main( // ¯\_(ツ)_/¯ arrow_fonts: !arrow_fonts, }, - &config.keybinds, + &Keybinds::default(), config_options.default_mode, ), pane_frame_style, @@ -8860,6 +8923,7 @@ pub(crate) fn screen_thread_main( web_server_port, nested_session_handling, ); + screen.default_keybinds = default_keybinds; screen.host_theme_dark_styling = host_theme_dark_styling; screen.host_theme_light_styling = host_theme_light_styling; screen.paste_buffer_read_enabled = dangerously_enable_paste_buffer_read; @@ -10198,9 +10262,10 @@ pub(crate) fn screen_thread_main( break; } } - if !found_plugin { - pending_events_waiting_for_tab - .push(ScreenInstruction::SetSelectable(pid, selectable)); + if found_plugin { + screen.pending_selectable_panes.remove(&pid); + } else { + screen.pending_selectable_panes.insert(pid, selectable); } screen.render(None)?; screen.log_and_report_session_state()?; diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index 70723e049..3cef0dc0d 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -937,7 +937,7 @@ impl Tab { fullscreen_covers_ui.clone(), session_is_mirrored, pane_frame_style, - default_mode_info.clone(), + default_mode_info.mode, style, os_api.clone(), senders.clone(), @@ -953,7 +953,7 @@ impl Tab { fullscreen_covers_ui.clone(), pane_frame_style, session_is_mirrored, - default_mode_info.clone(), + default_mode_info.mode, style, os_api.clone(), senders.clone(), @@ -2350,10 +2350,7 @@ impl Tab { None }; } - self.mode_info - .borrow_mut() - .get_mut(&client_id) - .map(|c| c.change_to_default_mode()); // TODO: no races? + self.mode_info.borrow_mut().remove(&client_id); self.connected_clients.borrow_mut().remove(&client_id); self.mouse_help_text_visible.remove(&client_id); self.mouse_last_pane_id.remove(&client_id); diff --git a/zellij-server/src/tab/unit/layout_applier_tests.rs b/zellij-server/src/tab/unit/layout_applier_tests.rs index c8c811656..5692bc747 100644 --- a/zellij-server/src/tab/unit/layout_applier_tests.rs +++ b/zellij-server/src/tab/unit/layout_applier_tests.rs @@ -206,7 +206,7 @@ fn create_layout_applier_fixtures( fullscreen_covers_ui.clone(), session_is_mirrored, draw_pane_frames, - default_mode_info.clone(), + default_mode_info.mode, style.clone(), os_api.box_clone(), senders.clone(), @@ -224,7 +224,7 @@ fn create_layout_applier_fixtures( fullscreen_covers_ui, draw_pane_frames, session_is_mirrored, - default_mode_info, + default_mode_info.mode, style.clone(), os_api.box_clone(), senders.clone(), @@ -344,7 +344,7 @@ fn create_layout_applier_fixtures_with_receivers( fullscreen_covers_ui.clone(), session_is_mirrored, draw_pane_frames, - default_mode_info.clone(), + default_mode_info.mode, style.clone(), os_api.box_clone(), senders.clone(), @@ -362,7 +362,7 @@ fn create_layout_applier_fixtures_with_receivers( fullscreen_covers_ui, draw_pane_frames, session_is_mirrored, - default_mode_info, + default_mode_info.mode, style.clone(), os_api.box_clone(), senders.clone(), diff --git a/zellij-server/src/terminal_bytes.rs b/zellij-server/src/terminal_bytes.rs index 0fcdc4ce3..7433f55d5 100644 --- a/zellij-server/src/terminal_bytes.rs +++ b/zellij-server/src/terminal_bytes.rs @@ -10,6 +10,8 @@ use zellij_utils::{ logging::debug_to_file, }; +const READ_BUFFER_SIZE: usize = 65536; + pub(crate) struct TerminalBytes { terminal_id: u32, senders: ThreadSenders, @@ -50,26 +52,21 @@ impl TerminalBytes { let mut err_ctx = get_current_ctx(); err_ctx.add_call(ContextType::AsyncTask); - let mut buf = [0u8; 65536]; loop { - match self.async_reader.read(&mut buf).await { - Ok(0) => break, // EOF + match self.async_reader.read_chunk(READ_BUFFER_SIZE).await { + Ok(bytes) if bytes.is_empty() => break, Err(err) => { log::error!("{}", err); break; }, - Ok(n_bytes) => { + Ok(bytes) => { self.activity_flag.store(true, Ordering::Relaxed); - let bytes = &buf[..n_bytes]; if self.debug { - let _ = debug_to_file(bytes, self.terminal_id as i32); + let _ = debug_to_file(&bytes, self.terminal_id as i32); } - self.async_send_to_screen(ScreenInstruction::PtyBytes( - self.terminal_id, - bytes.to_vec(), - )) - .await - .with_context(err_context)?; + self.async_send_to_screen(ScreenInstruction::PtyBytes(self.terminal_id, bytes)) + .await + .with_context(err_context)?; }, } } diff --git a/zellij-server/src/unit/os_input_output_tests.rs b/zellij-server/src/unit/os_input_output_tests.rs index 71e08b7a0..cb72d90b0 100644 --- a/zellij-server/src/unit/os_input_output_tests.rs +++ b/zellij-server/src/unit/os_input_output_tests.rs @@ -157,7 +157,6 @@ fn spawn_and_read_output() { // Read output from the spawned terminal let mut output = Vec::new(); - let mut buf = [0u8; 4096]; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); let rt = tokio::runtime::Builder::new_current_thread() @@ -170,12 +169,15 @@ fn spawn_and_read_output() { if std::time::Instant::now() > deadline { break; } - match tokio::time::timeout(std::time::Duration::from_millis(500), reader.read(&mut buf)) - .await + match tokio::time::timeout( + std::time::Duration::from_millis(500), + reader.read_chunk(4096), + ) + .await { - Ok(Ok(0)) => break, - Ok(Ok(n)) => { - output.extend_from_slice(&buf[..n]); + Ok(Ok(bytes)) if bytes.is_empty() => break, + Ok(Ok(bytes)) => { + output.extend_from_slice(&bytes); let s = String::from_utf8_lossy(&output); if s.contains(test_message) { break; @@ -255,3 +257,30 @@ fn tcgetpgrp_returns_foreground_group() { let _ = server.force_kill(child_pid); } } + +#[test] +fn client_buffer_refuses_messages_when_limit_is_reached() { + let (buffer, receiver) = client_buffer(CLIENT_BUFFER_LIMIT); + let msg = || ServerToClientMsg::Exit { + exit_reason: ExitReason::Normal, + }; + for _ in 0..CLIENT_BUFFER_LIMIT { + assert!(buffer.try_send(msg()).is_ok()); + } + assert!(matches!(buffer.try_send(msg()), Err(TrySendError::Full(_)))); + assert!(matches!(buffer.try_send(msg()), Err(TrySendError::Full(_)))); + assert!(receiver.recv().is_some()); + assert!(buffer.try_send(msg()).is_ok()); + assert!(matches!(buffer.try_send(msg()), Err(TrySendError::Full(_)))); +} + +#[test] +fn client_buffer_reports_disconnect_when_receiver_is_gone() { + let (buffer, receiver) = client_buffer(CLIENT_BUFFER_LIMIT); + drop(receiver); + let result = buffer.try_send(ServerToClientMsg::Exit { + exit_reason: ExitReason::Normal, + }); + assert!(matches!(result, Err(TrySendError::Disconnected(_)))); + assert_eq!(buffer.queued.load(Ordering::Acquire), 0); +} diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs index 212e82622..3d971fc82 100644 --- a/zellij-server/src/unit/screen_tests.rs +++ b/zellij-server/src/unit/screen_tests.rs @@ -454,6 +454,7 @@ impl MockScreen { ) .should_silently_fail(); let debug = false; + let default_keybinds = std::sync::Arc::new(config.keybinds.to_keybinds_vec()); let screen_thread = std::thread::Builder::new() .name("screen_thread".to_string()) .spawn(move || { @@ -465,6 +466,7 @@ impl MockScreen { config, debug, Box::new(Layout::default()), + default_keybinds, ) .expect("TEST") }) @@ -540,6 +542,7 @@ impl MockScreen { ) .should_silently_fail(); let debug = false; + let default_keybinds = std::sync::Arc::new(config.keybinds.to_keybinds_vec()); let screen_thread = std::thread::Builder::new() .name("screen_thread".to_string()) .spawn(move || { @@ -551,6 +554,7 @@ impl MockScreen { config, debug, Box::new(Layout::default()), + default_keybinds, ) .expect("TEST") }) @@ -14824,3 +14828,113 @@ mod nested_hint_reporting { ); } } + +fn keybinds_with_quit_on(c: char) -> crate::SharedKeybinds { + std::sync::Arc::new(vec![( + InputMode::Normal, + vec![( + zellij_utils::data::KeyWithModifier::new(zellij_utils::data::BareKey::Char(c)), + vec![Action::Quit], + )], + )]) +} + +#[test] +fn client_mode_and_keybinds_entries_are_removed_on_disconnect() { + let size = Size { + cols: 121, + rows: 20, + }; + let mut screen = create_new_screen(size, true, true); + new_tab(&mut screen, 1, 0); + new_tab(&mut screen, 2, 1); + screen.add_client(2, false).expect("TEST"); + screen.change_mode(InputMode::Tab, None, 1).expect("TEST"); + screen.change_mode(InputMode::Pane, None, 2).expect("TEST"); + assert!(screen.mode_info.contains_key(&2)); + assert!(screen.client_keybinds.contains_key(&2)); + + screen.remove_client(2).expect("TEST"); + + assert!(!screen.mode_info.contains_key(&2)); + assert!(!screen.client_keybinds.contains_key(&2)); + for tab in screen.tabs.values() { + assert_eq!(tab.get_client_input_mode(2), None); + assert_eq!(tab.get_client_input_mode(1), Some(InputMode::Tab)); + } + assert!(screen.mode_info.contains_key(&1)); + assert!(screen.client_keybinds.contains_key(&1)); +} + +#[test] +fn reconfiguring_one_client_keybinds_does_not_affect_another() { + let size = Size { + cols: 121, + rows: 20, + }; + let mut screen = create_new_screen(size, true, true); + new_tab(&mut screen, 1, 0); + screen.add_client(2, false).expect("TEST"); + let original = keybinds_with_quit_on('a'); + screen.update_keybinds(original.clone(), 1); + screen.update_keybinds(original.clone(), 2); + screen + .change_mode(InputMode::Normal, None, 1) + .expect("TEST"); + screen + .change_mode(InputMode::Normal, None, 2) + .expect("TEST"); + + let reconfigured = keybinds_with_quit_on('b'); + screen.update_keybinds(reconfigured.clone(), 1); + + assert_eq!(screen.keybinds_for_client(1), *reconfigured); + assert_eq!(screen.keybinds_for_client(2), *original); + assert!(std::sync::Arc::ptr_eq( + screen.client_keybinds.get(&1).unwrap(), + &reconfigured + )); + assert!(std::sync::Arc::ptr_eq( + &screen.default_keybinds, + &reconfigured + )); + for mode_info in screen.mode_info.values() { + assert!(mode_info.keybinds.is_empty()); + } +} + +#[test] +fn per_client_modes_are_kept_across_tabs() { + let size = Size { + cols: 121, + rows: 20, + }; + let mut screen = create_new_screen(size, true, true); + new_tab(&mut screen, 1, 0); + new_tab(&mut screen, 2, 1); + screen.add_client(2, false).expect("TEST"); + screen + .change_mode(InputMode::Normal, None, 2) + .expect("TEST"); + screen.change_mode(InputMode::Pane, None, 1).expect("TEST"); + + for tab in screen.tabs.values() { + assert_eq!(tab.get_client_input_mode(1), Some(InputMode::Pane)); + assert_ne!(tab.get_client_input_mode(2), Some(InputMode::Pane)); + } + + new_tab(&mut screen, 3, 2); + assert_eq!( + screen.get_active_tab(1).unwrap().get_client_input_mode(1), + Some(InputMode::Pane) + ); + screen.switch_tab_prev(None, true, 1).expect("TEST"); + assert_eq!( + screen.get_active_tab(1).unwrap().get_client_input_mode(1), + Some(InputMode::Pane) + ); + assert_eq!( + screen.get_active_tab(2).unwrap().get_client_input_mode(2), + Some(InputMode::Normal) + ); +} diff --git a/zellij-tile/src/lib.rs b/zellij-tile/src/lib.rs index 1fe36280f..e663b2b9f 100644 --- a/zellij-tile/src/lib.rs +++ b/zellij-tile/src/lib.rs @@ -21,7 +21,9 @@ pub mod ui_components; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -use zellij_utils::data::{Event, PipeMessage}; +use zellij_utils::data::{ + ClientId, Event, EventContext, PipeMessage, RenderResponse, Slot, SlotId, +}; // use zellij_tile::shim::plugin_api::event::ProtobufEvent; @@ -48,6 +50,55 @@ pub trait ZellijPlugin: Default { fn render(&mut self, rows: usize, cols: usize) {} } +#[allow(unused_variables)] +pub trait ZellijSharedPlugin: Default { + fn load(&mut self, configuration: BTreeMap) {} + fn slot_added(&mut self, slot: Slot) {} + fn slot_removed(&mut self, slot_id: SlotId) {} + fn client_connected(&mut self, client_id: ClientId) {} + fn client_disconnected(&mut self, client_id: ClientId) {} + fn update(&mut self, event: Event, context: EventContext) -> RenderResponse { + RenderResponse::Nothing + } + fn pipe(&mut self, pipe_message: PipeMessage, context: EventContext) -> RenderResponse { + RenderResponse::Nothing + } + fn render(&mut self, rows: usize, cols: usize, slot_id: SlotId, client_id: ClientId) {} +} + +#[doc(hidden)] +pub fn read_shared_context() -> EventContext { + use prost::Message; + use zellij_utils::plugin_api::shared_plugin::ProtobufEventContext; + let protobuf_bytes: Vec = shim::protobuf_bytes_from_stdin().unwrap_or_default(); + ProtobufEventContext::decode(protobuf_bytes.as_slice()) + .map(EventContext::from) + .unwrap_or_default() +} + +#[doc(hidden)] +pub fn read_shared_slot() -> Slot { + use prost::Message; + use zellij_utils::plugin_api::shared_plugin::ProtobufSlot; + let protobuf_bytes: Vec = shim::protobuf_bytes_from_stdin().unwrap(); + Slot::from(ProtobufSlot::decode(protobuf_bytes.as_slice()).unwrap()) +} + +#[doc(hidden)] +pub fn write_render_request(render: RenderResponse) -> i32 { + use prost::Message; + use zellij_utils::plugin_api::shared_plugin::ProtobufRenderRequest; + match render { + RenderResponse::Nothing => 0, + RenderResponse::All => 1, + render => { + let request: ProtobufRenderRequest = render.into(); + shim::object_to_stdout(&request.encode_to_vec()); + 2 + }, + } +} + /// This trait is used to create workers. Workers can be used by plugins to run longer running /// background tasks without blocking their own rendering (eg. and showing some sort of loading /// indication in part of the UI as needed while waiting for the task to complete). @@ -218,3 +269,130 @@ macro_rules! register_worker { } }; } + +#[macro_export] +macro_rules! register_shared_plugin { + ($t:ty) => { + thread_local! { + static STATE: std::cell::RefCell<$t> = std::cell::RefCell::new(Default::default()); + } + + fn main() { + std::panic::set_hook(Box::new(|info| { + $crate::shim::report_panic(info); + })); + } + + #[no_mangle] + pub fn zellij_shared_plugin() {} + + #[no_mangle] + fn load() { + STATE.with(|state| { + use std::collections::BTreeMap; + use std::convert::TryFrom; + use $crate::shim::plugin_api::action::ProtobufPluginConfiguration; + use $crate::shim::prost::Message; + let protobuf_bytes: Vec = $crate::shim::protobuf_bytes_from_stdin().unwrap(); + let protobuf_configuration: ProtobufPluginConfiguration = + ProtobufPluginConfiguration::decode(protobuf_bytes.as_slice()).unwrap(); + let plugin_configuration: BTreeMap = + BTreeMap::try_from(&protobuf_configuration).unwrap(); + <$t as $crate::ZellijSharedPlugin>::load( + &mut *state.borrow_mut(), + plugin_configuration, + ); + }); + } + + #[no_mangle] + pub fn shared_update() -> i32 { + let context = $crate::read_shared_context(); + let event = { + let protobuf_bytes: Vec = $crate::shim::protobuf_bytes_from_stdin().unwrap(); + $crate::shim::plugin_api::event::event_from_protobuf_bytes(&protobuf_bytes).unwrap() + }; + let render = STATE.with(|state| { + <$t as $crate::ZellijSharedPlugin>::update(&mut *state.borrow_mut(), event, context) + }); + $crate::write_render_request(render) + } + + #[no_mangle] + pub fn shared_pipe() -> i32 { + use std::convert::TryInto; + use $crate::shim::plugin_api::pipe_message::ProtobufPipeMessage; + use $crate::shim::prost::Message; + let context = $crate::read_shared_context(); + let protobuf_pipe_message: ProtobufPipeMessage = { + let protobuf_bytes: Vec = $crate::shim::protobuf_bytes_from_stdin().unwrap(); + ProtobufPipeMessage::decode(protobuf_bytes.as_slice()).unwrap() + }; + let pipe_message = protobuf_pipe_message.try_into().unwrap(); + let render = STATE.with(|state| { + <$t as $crate::ZellijSharedPlugin>::pipe( + &mut *state.borrow_mut(), + pipe_message, + context, + ) + }); + $crate::write_render_request(render) + } + + #[no_mangle] + pub fn shared_render(rows: i32, cols: i32, slot_id: i32, client_id: i32) { + STATE.with(|state| { + <$t as $crate::ZellijSharedPlugin>::render( + &mut *state.borrow_mut(), + rows as usize, + cols as usize, + slot_id as u32, + client_id as u32, + ); + }); + } + + #[no_mangle] + pub fn slot_added() { + let slot = $crate::read_shared_slot(); + STATE.with(|state| { + <$t as $crate::ZellijSharedPlugin>::slot_added(&mut *state.borrow_mut(), slot); + }); + } + + #[no_mangle] + pub fn slot_removed(slot_id: i32) { + STATE.with(|state| { + <$t as $crate::ZellijSharedPlugin>::slot_removed( + &mut *state.borrow_mut(), + slot_id as u32, + ); + }); + } + + #[no_mangle] + pub fn client_connected(client_id: i32) { + STATE.with(|state| { + <$t as $crate::ZellijSharedPlugin>::client_connected( + &mut *state.borrow_mut(), + client_id as u32, + ); + }); + } + + #[no_mangle] + pub fn client_disconnected(client_id: i32) { + STATE.with(|state| { + <$t as $crate::ZellijSharedPlugin>::client_disconnected( + &mut *state.borrow_mut(), + client_id as u32, + ); + }); + } + + #[no_mangle] + pub fn plugin_version() { + println!("{}", $crate::prelude::VERSION); + } + }; +} diff --git a/zellij-tile/src/shim.rs b/zellij-tile/src/shim.rs index 722997eb8..68a085802 100644 --- a/zellij-tile/src/shim.rs +++ b/zellij-tile/src/shim.rs @@ -44,7 +44,8 @@ use zellij_utils::plugin_api::plugin_command::{ ProtobufOpenTerminalPaneInPlaceOfPaneIdResponse, ProtobufOpenTerminalResponse, ProtobufParseLayoutResponse, ProtobufPluginCommand, ProtobufRenameLayoutResponse, ProtobufSaveLayoutResponse, ProtobufSaveSessionResponse, ProtobufShowFloatingPanesResponse, - RenameWebTokenResponse, RevokeAllWebTokensResponse, RevokeTokenResponse, + ProtobufSlotCommandResponse, RenameWebTokenResponse, RevokeAllWebTokensResponse, + RevokeTokenResponse, }; use zellij_utils::plugin_api::plugin_ids::{ProtobufPluginIds, ProtobufZellijVersion}; @@ -92,6 +93,31 @@ pub fn show_cursor(cursor_position: Option<(usize, usize)>) { unsafe { host_run_plugin_command() }; } +fn run_slot_command(plugin_command: PluginCommand) -> Result<(), String> { + let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap(); + object_to_stdout(&protobuf_plugin_command.encode_to_vec()); + unsafe { host_run_plugin_command() }; + let response = + ProtobufSlotCommandResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap(); + response.into() +} + +pub fn set_selectable_slot(slot_id: SlotId, selectable: bool) -> Result<(), String> { + run_slot_command(PluginCommand::SetSelectableSlot(slot_id, selectable)) +} + +pub fn hide_slot(slot_id: SlotId) -> Result<(), String> { + run_slot_command(PluginCommand::HideSlot(slot_id)) +} + +pub fn show_slot(slot_id: SlotId, should_float_if_hidden: bool) -> Result<(), String> { + run_slot_command(PluginCommand::ShowSlot(slot_id, should_float_if_hidden)) +} + +pub fn close_slot(slot_id: SlotId) -> Result<(), String> { + run_slot_command(PluginCommand::CloseSlot(slot_id)) +} + pub fn request_permission(permissions: &[PermissionType]) { let plugin_command = PluginCommand::RequestPluginPermissions(permissions.into()); let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap(); @@ -2905,6 +2931,101 @@ pub fn bytes_from_stdin() -> Result> { serde_json::from_str(&json).with_context(err_context) } +#[doc(hidden)] +pub fn protobuf_bytes_from_stdin() -> Result> { + let stdin = io::stdin(); + let mut stdin = stdin.lock(); + read_json_byte_array_line(&mut stdin) + .with_context(|| "failed to deserialize bytes from stdin".to_string()) +} + +enum JsonBytesPosition { + BeforeArray, + InArray, + AfterArray, +} + +struct JsonBytesParser { + position: JsonBytesPosition, + bytes: Vec, + current: Option, +} + +impl JsonBytesParser { + fn feed(&mut self, byte: u8) -> std::result::Result<(), &'static str> { + match self.position { + JsonBytesPosition::BeforeArray => match byte { + b'[' => self.position = JsonBytesPosition::InArray, + byte if byte.is_ascii_whitespace() => {}, + _ => return Err("expected a byte array"), + }, + JsonBytesPosition::InArray => match byte { + b'0'..=b'9' => { + let value = self.current.unwrap_or(0) * 10 + (byte - b'0') as u16; + if value > u8::MAX as u16 { + return Err("byte value out of range"); + } + self.current = Some(value); + }, + b',' | b']' => { + match self.current.take() { + Some(value) => self.bytes.push(value as u8), + None if byte == b']' && self.bytes.is_empty() => {}, + None => return Err("missing byte value"), + } + if byte == b']' { + self.position = JsonBytesPosition::AfterArray; + } + }, + byte if byte.is_ascii_whitespace() && self.current.is_none() => {}, + _ => return Err("unexpected character in byte array"), + }, + JsonBytesPosition::AfterArray => { + if !byte.is_ascii_whitespace() { + return Err("unexpected character after byte array"); + } + }, + } + Ok(()) + } +} + +fn read_json_byte_array_line(reader: &mut impl io::BufRead) -> Result> { + let mut parser = JsonBytesParser { + position: JsonBytesPosition::BeforeArray, + bytes: Vec::new(), + current: None, + }; + let mut error: Option<&'static str> = None; + loop { + let chunk = reader.fill_buf()?; + if chunk.is_empty() { + break; + } + let mut consumed = 0; + let mut line_ended = false; + for byte in chunk { + consumed += 1; + if *byte == b'\n' { + line_ended = true; + break; + } + if error.is_none() { + error = parser.feed(*byte).err(); + } + } + reader.consume(consumed); + if line_ended { + break; + } + } + match (error, parser.position) { + (Some(error), _) => Err(anyhow!(error)), + (None, JsonBytesPosition::AfterArray) => Ok(parser.bytes), + (None, _) => Err(anyhow!("incomplete byte array")), + } +} + #[doc(hidden)] pub fn object_to_stdout(object: &impl Serialize) { // TODO: no crashy @@ -3025,3 +3146,47 @@ extern "C" { #[cfg(not(target_arch = "wasm32"))] unsafe fn host_run_plugin_command() {} + +#[cfg(test)] +mod tests { + use super::read_json_byte_array_line; + use std::io::{BufRead, BufReader, Cursor}; + + fn read_all(input: &str, capacity: usize) -> Vec, String>> { + let mut reader = BufReader::with_capacity(capacity, Cursor::new(input.as_bytes().to_vec())); + let mut results = vec![]; + while !reader.fill_buf().unwrap().is_empty() { + results.push(read_json_byte_array_line(&mut reader).map_err(|e| e.to_string())); + } + results + } + + #[test] + fn reads_serde_json_byte_arrays_line_by_line() { + let first: Vec = (0..=255).collect(); + let second: Vec = vec![]; + let third: Vec = vec![7, 42, 255]; + let input = format!( + "{}\r\n{}\r\n{}\r\n", + serde_json::to_string(&first).unwrap(), + serde_json::to_string(&second).unwrap(), + serde_json::to_string(&third).unwrap() + ); + for capacity in [1, 3, 8192] { + assert_eq!( + read_all(&input, capacity), + vec![Ok(first.clone()), Ok(second.clone()), Ok(third.clone())] + ); + } + } + + #[test] + fn malformed_line_is_consumed_and_reported() { + let results = read_all("[1,256]\r\n{\"a\":1}\r\n[1,,2]\r\n[3]\r\n", 4); + assert_eq!(results.len(), 4); + assert!(results[0].is_err()); + assert!(results[1].is_err()); + assert!(results[2].is_err()); + assert_eq!(results[3], Ok(vec![3])); + } +} diff --git a/zellij-utils/assets/config/default.kdl b/zellij-utils/assets/config/default.kdl index 5e5806187..252457253 100644 --- a/zellij-utils/assets/config/default.kdl +++ b/zellij-utils/assets/config/default.kdl @@ -240,10 +240,16 @@ keybinds { // Plugin aliases - can be used to change the implementation of Zellij // changing these requires a restart to take effect plugins { - tab-bar location="zellij:tab-bar" - status-bar location="zellij:status-bar" + tab-bar location="zellij:bars" { + role "tab-bar" + } + status-bar location="zellij:bars" { + role "status-bar" + } strider location="zellij:strider" - compact-bar location="zellij:compact-bar" + compact-bar location="zellij:bars" { + role "compact-bar" + } session-manager location="zellij:session-manager" welcome-screen location="zellij:session-manager" { welcome_screen true @@ -258,7 +264,9 @@ plugins { // Plugins to load in the background when a new session starts load_plugins { - "zellij:link" + "zellij:bars" { + role "link" + } // "file:/path/to/my-plugin.wasm" // "https://example.com/my-plugin.wasm" } diff --git a/zellij-utils/assets/plugins/about.wasm b/zellij-utils/assets/plugins/about.wasm index 7e34d61cf..cdc5ad809 100755 Binary files a/zellij-utils/assets/plugins/about.wasm and b/zellij-utils/assets/plugins/about.wasm differ diff --git a/zellij-utils/assets/plugins/bars.wasm b/zellij-utils/assets/plugins/bars.wasm new file mode 100755 index 000000000..fd9934b6a Binary files /dev/null and b/zellij-utils/assets/plugins/bars.wasm differ diff --git a/zellij-utils/assets/plugins/compact-bar.wasm b/zellij-utils/assets/plugins/compact-bar.wasm deleted file mode 100755 index 287f80ed1..000000000 Binary files a/zellij-utils/assets/plugins/compact-bar.wasm and /dev/null differ diff --git a/zellij-utils/assets/plugins/configuration.wasm b/zellij-utils/assets/plugins/configuration.wasm index 331f277b7..da8e219b3 100755 Binary files a/zellij-utils/assets/plugins/configuration.wasm and b/zellij-utils/assets/plugins/configuration.wasm differ diff --git a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm index 15fad43d0..4469ab4c2 100755 Binary files a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm and b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm differ diff --git a/zellij-utils/assets/plugins/fixture-shared-plugin-for-tests.wasm b/zellij-utils/assets/plugins/fixture-shared-plugin-for-tests.wasm new file mode 100755 index 000000000..b2837298d Binary files /dev/null and b/zellij-utils/assets/plugins/fixture-shared-plugin-for-tests.wasm differ diff --git a/zellij-utils/assets/plugins/layout-manager.wasm b/zellij-utils/assets/plugins/layout-manager.wasm index 1e55bd1d7..b3daaf1f6 100755 Binary files a/zellij-utils/assets/plugins/layout-manager.wasm and b/zellij-utils/assets/plugins/layout-manager.wasm differ diff --git a/zellij-utils/assets/plugins/link.wasm b/zellij-utils/assets/plugins/link.wasm deleted file mode 100755 index 3277169ea..000000000 Binary files a/zellij-utils/assets/plugins/link.wasm and /dev/null differ diff --git a/zellij-utils/assets/plugins/multiple-select.wasm b/zellij-utils/assets/plugins/multiple-select.wasm index 1530d6f77..90ee66761 100755 Binary files a/zellij-utils/assets/plugins/multiple-select.wasm and b/zellij-utils/assets/plugins/multiple-select.wasm differ diff --git a/zellij-utils/assets/plugins/plugin-manager.wasm b/zellij-utils/assets/plugins/plugin-manager.wasm index a3e143fe4..d953ce7c4 100755 Binary files a/zellij-utils/assets/plugins/plugin-manager.wasm and b/zellij-utils/assets/plugins/plugin-manager.wasm differ diff --git a/zellij-utils/assets/plugins/session-manager.wasm b/zellij-utils/assets/plugins/session-manager.wasm index b9c9404d2..41b6e7b07 100755 Binary files a/zellij-utils/assets/plugins/session-manager.wasm and b/zellij-utils/assets/plugins/session-manager.wasm differ diff --git a/zellij-utils/assets/plugins/share.wasm b/zellij-utils/assets/plugins/share.wasm index 65b57d39a..9f04a03bb 100755 Binary files a/zellij-utils/assets/plugins/share.wasm and b/zellij-utils/assets/plugins/share.wasm differ diff --git a/zellij-utils/assets/plugins/status-bar.wasm b/zellij-utils/assets/plugins/status-bar.wasm deleted file mode 100755 index 5c2849496..000000000 Binary files a/zellij-utils/assets/plugins/status-bar.wasm and /dev/null differ diff --git a/zellij-utils/assets/plugins/strider.wasm b/zellij-utils/assets/plugins/strider.wasm index 9a1a05f59..fd228bd71 100755 Binary files a/zellij-utils/assets/plugins/strider.wasm and b/zellij-utils/assets/plugins/strider.wasm differ diff --git a/zellij-utils/assets/plugins/tab-bar.wasm b/zellij-utils/assets/plugins/tab-bar.wasm deleted file mode 100755 index 9e23b9de3..000000000 Binary files a/zellij-utils/assets/plugins/tab-bar.wasm and /dev/null differ diff --git a/zellij-utils/assets/prost/api.plugin_command.rs b/zellij-utils/assets/prost/api.plugin_command.rs index 18f0628f3..8f09d1381 100644 --- a/zellij-utils/assets/prost/api.plugin_command.rs +++ b/zellij-utils/assets/prost/api.plugin_command.rs @@ -4,7 +4,7 @@ pub struct PluginCommand { #[prost(enumeration="CommandName", tag="1")] pub name: i32, - #[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 168, 169, 170, 171, 172, 173, 174, 175")] + #[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179")] pub payload: ::core::option::Option, } /// Nested message and enum types in `PluginCommand`. @@ -328,10 +328,40 @@ pub mod plugin_command { SetPaneBorderStylePayload(super::SetPaneBorderStylePayload), #[prost(message, tag="175")] GetNestedSessionKeybindsPayload(super::GetNestedSessionKeybindsPayload), + #[prost(message, tag="176")] + SetSelectableSlotPayload(super::SetSelectableSlotPayload), + #[prost(uint32, tag="177")] + HideSlotPayload(u32), + #[prost(message, tag="178")] + ShowSlotPayload(super::ShowSlotPayload), + #[prost(uint32, tag="179")] + CloseSlotPayload(u32), } } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct SetSelectableSlotPayload { + #[prost(uint32, tag="1")] + pub slot_id: u32, + #[prost(bool, tag="2")] + pub selectable: bool, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ShowSlotPayload { + #[prost(uint32, tag="1")] + pub slot_id: u32, + #[prost(bool, tag="2")] + pub should_float_if_hidden: bool, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SlotCommandResponse { + #[prost(string, optional, tag="1")] + pub error: ::core::option::Option<::prost::alloc::string::String>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct GetNestedSessionKeybindsPayload { #[prost(message, optional, tag="1")] pub pane_id: ::core::option::Option, @@ -2255,6 +2285,10 @@ pub enum CommandName { ApplyFloatingSwapLayout = 230, SetPaneBorderStyle = 231, GetNestedSessionKeybinds = 232, + SetSelectableSlot = 233, + HideSlot = 234, + ShowSlot = 235, + CloseSlot = 236, } impl CommandName { /// String value of the enum field names used in the ProtoBuf definition. @@ -2470,6 +2504,10 @@ impl CommandName { CommandName::ApplyFloatingSwapLayout => "ApplyFloatingSwapLayout", CommandName::SetPaneBorderStyle => "SetPaneBorderStyle", CommandName::GetNestedSessionKeybinds => "GetNestedSessionKeybinds", + CommandName::SetSelectableSlot => "SetSelectableSlot", + CommandName::HideSlot => "HideSlot", + CommandName::ShowSlot => "ShowSlot", + CommandName::CloseSlot => "CloseSlot", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -2682,6 +2720,10 @@ impl CommandName { "ApplyFloatingSwapLayout" => Some(Self::ApplyFloatingSwapLayout), "SetPaneBorderStyle" => Some(Self::SetPaneBorderStyle), "GetNestedSessionKeybinds" => Some(Self::GetNestedSessionKeybinds), + "SetSelectableSlot" => Some(Self::SetSelectableSlot), + "HideSlot" => Some(Self::HideSlot), + "ShowSlot" => Some(Self::ShowSlot), + "CloseSlot" => Some(Self::CloseSlot), _ => None, } } diff --git a/zellij-utils/assets/prost/api.shared_plugin.rs b/zellij-utils/assets/prost/api.shared_plugin.rs new file mode 100644 index 000000000..6a7c8a2f4 --- /dev/null +++ b/zellij-utils/assets/prost/api.shared_plugin.rs @@ -0,0 +1,87 @@ +// This file is @generated by prost-build. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Slot { + #[prost(uint32, tag="1")] + pub id: u32, + #[prost(enumeration="SlotKind", tag="2")] + pub kind: i32, + #[prost(message, optional, tag="3")] + pub configuration: ::core::option::Option, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct EventContext { + #[prost(uint32, optional, tag="1")] + pub slot_id: ::core::option::Option, + #[prost(uint32, optional, tag="2")] + pub client_id: ::core::option::Option, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RenderRequest { + #[prost(enumeration="RenderKind", tag="1")] + pub kind: i32, + #[prost(uint32, optional, tag="2")] + pub client_id: ::core::option::Option, + #[prost(uint32, repeated, tag="3")] + pub slot_ids: ::prost::alloc::vec::Vec, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum SlotKind { + Pane = 0, + Background = 1, +} +impl SlotKind { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + SlotKind::Pane => "Pane", + SlotKind::Background => "Background", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "Pane" => Some(Self::Pane), + "Background" => Some(Self::Background), + _ => None, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum RenderKind { + Nothing = 0, + All = 1, + Client = 2, + Slots = 3, +} +impl RenderKind { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + RenderKind::Nothing => "Nothing", + RenderKind::All => "All", + RenderKind::Client => "Client", + RenderKind::Slots => "Slots", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "Nothing" => Some(Self::Nothing), + "All" => Some(Self::All), + "Client" => Some(Self::Client), + "Slots" => Some(Self::Slots), + _ => None, + } + } +} diff --git a/zellij-utils/assets/prost/generated_plugin_api.rs b/zellij-utils/assets/prost/generated_plugin_api.rs index 18bf80b58..f18d60f58 100644 --- a/zellij-utils/assets/prost/generated_plugin_api.rs +++ b/zellij-utils/assets/prost/generated_plugin_api.rs @@ -39,6 +39,9 @@ pub mod api { pub mod resize { include!("api.resize.rs"); } + pub mod shared_plugin { + include!("api.shared_plugin.rs"); + } pub mod style { include!("api.style.rs"); } diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs index a69c53496..bba97fabf 100644 --- a/zellij-utils/src/data.rs +++ b/zellij-utils/src/data.rs @@ -3041,6 +3041,65 @@ pub struct PluginIds { pub client_id: ClientId, } +pub type SlotId = u32; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] +pub enum SlotKind { + Pane, + Background, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Slot { + pub id: SlotId, + pub kind: SlotKind, + pub configuration: BTreeMap, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Deserialize, Serialize)] +pub struct EventContext { + pub slot_id: Option, + pub client_id: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub enum RenderResponse { + #[default] + Nothing, + All, + Client(ClientId), + Slots(Vec), +} + +impl RenderResponse { + pub fn merge(self, other: RenderResponse) -> RenderResponse { + match (self, other) { + (RenderResponse::Nothing, other) => other, + (this, RenderResponse::Nothing) => this, + (RenderResponse::All, _) | (_, RenderResponse::All) => RenderResponse::All, + (RenderResponse::Client(a), RenderResponse::Client(b)) if a == b => { + RenderResponse::Client(a) + }, + (RenderResponse::Slots(mut a), RenderResponse::Slots(b)) => { + for slot_id in b { + if !a.contains(&slot_id) { + a.push(slot_id); + } + } + RenderResponse::Slots(a) + }, + _ => RenderResponse::All, + } + } + pub fn from_bool(should_render: bool) -> RenderResponse { + if should_render { + RenderResponse::All + } else { + RenderResponse::Nothing + } + } +} + /// Tag used to identify the plugin in layout and config kdl files #[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, PartialOrd, Ord)] pub struct PluginTag(String); @@ -4061,6 +4120,10 @@ pub enum PluginCommand { SetSoftKeyboard(bool), FocusHostSession, GetNestedSessionKeybinds(PaneId), + SetSelectableSlot(SlotId, bool), + HideSlot(SlotId), + ShowSlot(SlotId, bool), + CloseSlot(SlotId), } // Response type for plugin API methods that open a pane in a new tab diff --git a/zellij-utils/src/distribution.rs b/zellij-utils/src/distribution.rs index 8ca07c5bf..e2c3c55c6 100644 --- a/zellij-utils/src/distribution.rs +++ b/zellij-utils/src/distribution.rs @@ -184,9 +184,7 @@ impl Distribution { } pub const ZELLIJ_BUILTIN_PLUGIN_NAMES: &[&str] = &[ - "compact-bar", - "status-bar", - "tab-bar", + "bars", "strider", "session-manager", "configuration", @@ -195,7 +193,6 @@ pub const ZELLIJ_BUILTIN_PLUGIN_NAMES: &[&str] = &[ "share", "multiple-select", "layout-manager", - "link", ]; // Plugins are taken from: @@ -231,9 +228,7 @@ fn zellij_builtin_plugins() -> Vec { }; } vec![ - builtin_plugin!("compact-bar"), - builtin_plugin!("status-bar"), - builtin_plugin!("tab-bar"), + builtin_plugin!("bars"), builtin_plugin!("strider"), builtin_plugin!("session-manager"), builtin_plugin!("configuration"), @@ -242,7 +237,6 @@ fn zellij_builtin_plugins() -> Vec { builtin_plugin!("share"), builtin_plugin!("multiple-select"), builtin_plugin!("layout-manager"), - builtin_plugin!("link"), ] } @@ -483,7 +477,7 @@ mod tests { #[test] fn only_bundled_plugin_names_are_builtin() { - assert!(is_builtin_plugin_name("tab-bar")); + assert!(is_builtin_plugin_name("bars")); assert!(!is_builtin_plugin_name("yazelix-sidebar")); assert!(!is_builtin_plugin_name("")); } diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs index dd1ee3a4e..dfbed3f5b 100644 --- a/zellij-utils/src/errors.rs +++ b/zellij-utils/src/errors.rs @@ -543,6 +543,7 @@ pub enum PluginContext { AddClient, RemoveClient, UpdatePluginTabIndices, + UpdateClientVisiblePlugins, NewTab, OverrideLayout, ApplyCachedEvents, @@ -674,6 +675,7 @@ pub enum BackgroundJobContext { StopFlashTabBell, StartNestedGuestPing, StopNestedGuestPing, + TrimAllocator, Exit, } diff --git a/zellij-utils/src/input/config.rs b/zellij-utils/src/input/config.rs index d4421bb83..1794462fd 100644 --- a/zellij-utils/src/input/config.rs +++ b/zellij-utils/src/input/config.rs @@ -11,6 +11,7 @@ use std::io::{self, Read}; #[cfg(not(target_family = "wasm"))] use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; use thiserror::Error; use std::convert::TryFrom; @@ -32,7 +33,8 @@ type ConfigResult = Result; /// Main configuration. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub struct Config { - pub keybinds: Keybinds, + #[serde(with = "shared_keybinds")] + pub keybinds: Arc, pub options: Options, pub themes: Themes, pub plugins: PluginAliases, @@ -42,6 +44,25 @@ pub struct Config { pub web_client: WebClientConfig, } +mod shared_keybinds { + use super::Keybinds; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::sync::Arc; + + pub fn serialize( + keybinds: &Arc, + serializer: S, + ) -> Result { + keybinds.as_ref().serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + Keybinds::deserialize(deserializer).map(Arc::new) + } +} + #[derive(Error, Debug, Serialize, Deserialize)] pub struct KdlError { pub error_message: String, @@ -282,7 +303,9 @@ impl Config { } pub fn merge(&mut self, other: Config) -> Result<(), ConfigError> { self.options = self.options.merge(other.options); - self.keybinds.merge(other.keybinds.clone()); + if !other.keybinds.0.is_empty() && !Arc::ptr_eq(&self.keybinds, &other.keybinds) { + Arc::make_mut(&mut self.keybinds).merge(Arc::unwrap_or_clone(other.keybinds)); + } self.themes = self.themes.merge(other.themes); self.plugins.merge(other.plugins); self.ui = self.ui.merge(other.ui); diff --git a/zellij-utils/src/input/plugins.rs b/zellij-utils/src/input/plugins.rs index 448bb907a..199297806 100644 --- a/zellij-utils/src/input/plugins.rs +++ b/zellij-utils/src/input/plugins.rs @@ -54,6 +54,9 @@ impl PluginConfig { initial_userspace_configuration: run_plugin.configuration.clone(), initial_cwd: run_plugin.initial_cwd.clone(), }), + RunPluginLocation::Zellij(tag) if legacy_bars_role(&tag.to_string()).is_some() => { + Self::from_run_plugin(&canonical_run_plugin(run_plugin)) + }, RunPluginLocation::Zellij(tag) => { let tag = tag.to_string(); if distribution::is_builtin_plugin_name(&tag) { @@ -195,6 +198,53 @@ impl PluginConfig { } } +pub const BARS_PLUGIN_NAME: &str = "bars"; +pub const SHARED_INSTANCE_KEY: &str = "instance"; + +pub fn legacy_bars_role(tag: &str) -> Option<&'static str> { + match tag { + "tab-bar" => Some("tab-bar"), + "status-bar" => Some("status-bar"), + "compact-bar" => Some("compact-bar"), + "link" => Some("link"), + _ => None, + } +} + +pub fn canonical_plugin_location(location: &RunPluginLocation) -> RunPluginLocation { + match location { + RunPluginLocation::Zellij(tag) if legacy_bars_role(&tag.to_string()).is_some() => { + RunPluginLocation::Zellij(PluginTag::new(BARS_PLUGIN_NAME)) + }, + other => other.clone(), + } +} + +pub fn canonical_run_plugin(run_plugin: &RunPlugin) -> RunPlugin { + let mut canonical = run_plugin.clone(); + canonical.configuration = PluginUserConfiguration::new(slot_configuration( + &run_plugin.location, + &run_plugin.configuration, + )); + canonical.location = canonical_plugin_location(&run_plugin.location); + canonical +} + +pub fn slot_configuration( + location: &RunPluginLocation, + configuration: &PluginUserConfiguration, +) -> BTreeMap { + let mut configuration = configuration.inner().clone(); + if let RunPluginLocation::Zellij(tag) = location { + if let Some(role) = legacy_bars_role(&tag.to_string()) { + configuration + .entry("role".to_owned()) + .or_insert_with(|| role.to_owned()); + } + } + configuration +} + #[derive(Error, Debug, PartialEq)] pub enum PluginsConfigError { #[error("Duplication in plugin tag names is not allowed: '{}'", String::from(.0.clone()))] diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs index ccc3efb44..3c94b7dfd 100644 --- a/zellij-utils/src/kdl/mod.rs +++ b/zellij-utils/src/kdl/mod.rs @@ -5471,7 +5471,11 @@ impl Config { // TODO: handle cases where we have more than one of these blocks (eg. two "keybinds") // this should give an informative parsing error if let Some(kdl_keybinds) = kdl_config.get("keybinds") { - config.keybinds = Keybinds::from_kdl(&kdl_keybinds, config.keybinds, &config.options)?; + config.keybinds = std::sync::Arc::new(Keybinds::from_kdl( + &kdl_keybinds, + std::sync::Arc::unwrap_or_clone(config.keybinds), + &config.options, + )?); } if let Some(kdl_themes) = kdl_config.get("themes") { let sourced_from_external_file = false; diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap index d89915389..d03a5abea 100644 --- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap +++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap @@ -265,22 +265,30 @@ keybinds clear-defaults=true { } plugins { about location="zellij:about" - compact-bar location="zellij:compact-bar" + compact-bar location="zellij:bars" { + role "compact-bar" + } configuration location="zellij:configuration" filepicker location="zellij:strider" { cwd "/" } plugin-manager location="zellij:plugin-manager" session-manager location="zellij:session-manager" - status-bar location="zellij:status-bar" + status-bar location="zellij:bars" { + role "status-bar" + } strider location="zellij:strider" - tab-bar location="zellij:tab-bar" + tab-bar location="zellij:bars" { + role "tab-bar" + } welcome-screen location="zellij:session-manager" { welcome_screen true } } load_plugins { - zellij:link + zellij:bars { + role "link" + } } web_client { font "monospace" diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap index 78e1ecc5a..27dfb7962 100644 --- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap +++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap @@ -268,16 +268,22 @@ keybinds clear-defaults=true { // changing these requires a restart to take effect plugins { about location="zellij:about" - compact-bar location="zellij:compact-bar" + compact-bar location="zellij:bars" { + role "compact-bar" + } configuration location="zellij:configuration" filepicker location="zellij:strider" { cwd "/" } plugin-manager location="zellij:plugin-manager" session-manager location="zellij:session-manager" - status-bar location="zellij:status-bar" + status-bar location="zellij:bars" { + role "status-bar" + } strider location="zellij:strider" - tab-bar location="zellij:tab-bar" + tab-bar location="zellij:bars" { + role "tab-bar" + } welcome-screen location="zellij:session-manager" { welcome_screen true } @@ -287,7 +293,9 @@ plugins { // eg. "file:/path/to/my-plugin.wasm" // eg. "https://example.com/my-plugin.wasm" load_plugins { - zellij:link + zellij:bars { + role "link" + } } web_client { font "monospace" diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs index 5234a3225..dd7dcd20b 100644 --- a/zellij-utils/src/plugin_api/event.rs +++ b/zellij-utils/src/plugin_api/event.rs @@ -74,19 +74,23 @@ use std::time::Duration; /// between builds that do not share every mode, key and action, minus whatever they do not /// have in common. pub fn keybinds_to_protobuf(keybinds: KeybindsVec) -> Vec { + keybinds_ref_to_protobuf(&keybinds) +} + +pub fn keybinds_ref_to_protobuf(keybinds: &KeybindsVec) -> Vec { let mut protobuf_keybinds: Vec = vec![]; for (input_mode, input_mode_keybinds) in keybinds { - let Ok(mode) = ProtobufInputMode::try_from(input_mode) else { + let Ok(mode) = ProtobufInputMode::try_from(*input_mode) else { continue; }; let mut key_binds: Vec = vec![]; for (key, actions) in input_mode_keybinds { - let Ok(protobuf_key) = ProtobufKey::try_from(key) else { + let Ok(protobuf_key) = ProtobufKey::try_from(key.clone()) else { continue; }; let mut protobuf_actions: Vec = vec![]; for action in actions { - if let Ok(protobuf_action) = action.try_into() { + if let Ok(protobuf_action) = action.clone().try_into() { protobuf_actions.push(protobuf_action); } } @@ -103,35 +107,102 @@ pub fn keybinds_to_protobuf(keybinds: KeybindsVec) -> Vec, +) -> Result { + let mut protobuf_event: ProtobufEvent = event.try_into()?; + if let (Some(keybinds), Some(event::Payload::ModeUpdatePayload(payload))) = + (keybinds, protobuf_event.payload.as_mut()) + { + payload.keybinds = keybinds_ref_to_protobuf(keybinds); + } + Ok(protobuf_event) +} + /// Converts a keybinding table back out of its protobuf form. /// /// Anything that cannot be understood is dropped rather than failing the whole table: a /// mode, key, or action a given build does not know about simply does not appear. This /// keeps a table readable across builds that do not share every mode and action. pub fn keybinds_from_protobuf(protobuf_keybinds: Vec) -> KeybindsVec { - protobuf_keybinds - .into_iter() - .filter_map(|input_mode_keybinds| { - let input_mode: InputMode = ProtobufInputMode::try_from(input_mode_keybinds.mode) - .ok()? - .try_into() - .ok()?; - let key_binds = input_mode_keybinds - .key_bind - .into_iter() - .filter_map(|key_bind| { - let key: KeyWithModifier = key_bind.key?.try_into().ok()?; - let actions: Vec = key_bind - .action + let mut keybinds = Vec::with_capacity(protobuf_keybinds.len()); + keybinds.extend( + protobuf_keybinds + .into_iter() + .filter_map(|input_mode_keybinds| { + let input_mode: InputMode = ProtobufInputMode::try_from(input_mode_keybinds.mode) + .ok()? + .try_into() + .ok()?; + let mut key_binds = Vec::with_capacity(input_mode_keybinds.key_bind.len()); + key_binds.extend( + input_mode_keybinds + .key_bind .into_iter() - .filter_map(|action| action.try_into().ok()) - .collect(); - Some((key, actions)) - }) - .collect(); - Some((input_mode, key_binds)) - }) - .collect() + .filter_map(|key_bind| { + let key: KeyWithModifier = key_bind.key?.try_into().ok()?; + let mut actions: Vec = + Vec::with_capacity(key_bind.action.len()); + actions.extend( + key_bind + .action + .into_iter() + .filter_map(|action| action.try_into().ok()), + ); + Some((key, actions)) + }), + ); + Some((input_mode, key_binds)) + }), + ); + keybinds +} + +#[derive(Clone, PartialEq, ::prost::Message)] +struct InitialKeybindsEventParts { + #[prost(int32, tag = "1")] + name: i32, + #[prost(bytes = "vec", optional, tag = "38")] + initial_keybinds_payload: Option>, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +struct InitialKeybindsPayloadParts { + #[prost(bytes = "vec", repeated, tag = "1")] + keybinds: Vec>, +} + +fn initial_keybinds_from_protobuf_bytes(bytes: &[u8]) -> Option> { + use prost::Message; + let parts = InitialKeybindsEventParts::decode(bytes).ok()?; + if parts.name != ProtobufEventType::InitialKeybinds as i32 { + return None; + } + let payload = parts.initial_keybinds_payload?; + let modes = match InitialKeybindsPayloadParts::decode(payload.as_slice()) { + Ok(payload) => payload.keybinds, + Err(_) => return Some(Err("Malformed payload for InitialKeybinds Event")), + }; + drop(payload); + let mut keybinds = Vec::with_capacity(modes.len()); + for mode_bytes in modes { + match ProtobufInputModeKeybinds::decode(mode_bytes.as_slice()) { + Ok(mode) => keybinds.extend(keybinds_from_protobuf(vec![mode])), + Err(_) => return Some(Err("Malformed payload for InitialKeybinds Event")), + } + } + Some(Ok(Event::InitialKeybinds(keybinds))) +} + +pub fn event_from_protobuf_bytes(bytes: &[u8]) -> Result { + use prost::Message; + if let Some(event) = initial_keybinds_from_protobuf_bytes(bytes) { + return event; + } + ProtobufEvent::decode(bytes) + .map_err(|_| "Failed to decode event")? + .try_into() } impl TryFrom for Event { @@ -3669,3 +3740,102 @@ fn a_key_with_no_protobuf_form_costs_only_its_own_binding() { )] ); } + +#[test] +fn event_to_protobuf_with_keybinds_matches_embedded_keybinds() { + use crate::data::BareKey; + use prost::Message; + let keybinds: KeybindsVec = vec![ + ( + InputMode::Normal, + vec![ + ( + KeyWithModifier::new(BareKey::Char('p')).with_ctrl_modifier(), + vec![Action::SwitchToMode { + input_mode: InputMode::Pane, + }], + ), + ( + KeyWithModifier::new(BareKey::Char('q')).with_ctrl_modifier(), + vec![Action::Quit], + ), + ], + ), + ( + InputMode::Pane, + vec![( + KeyWithModifier::new(BareKey::Esc), + vec![Action::SwitchToMode { + input_mode: InputMode::Normal, + }], + )], + ), + ]; + let mode_info_without_keybinds = ModeInfo { + mode: InputMode::Pane, + base_mode: Some(InputMode::Normal), + session_name: Some("session".to_owned()), + ..Default::default() + }; + let mode_info_with_keybinds = ModeInfo { + keybinds: keybinds.clone(), + ..mode_info_without_keybinds.clone() + }; + let expected: ProtobufEvent = Event::ModeUpdate(mode_info_with_keybinds) + .try_into() + .unwrap(); + let with_shared_keybinds = event_to_protobuf_with_keybinds( + Event::ModeUpdate(mode_info_without_keybinds.clone()), + Some(&keybinds), + ) + .unwrap(); + assert_eq!( + expected.encode_to_vec(), + with_shared_keybinds.encode_to_vec() + ); + + let expected_empty: ProtobufEvent = Event::ModeUpdate(mode_info_without_keybinds.clone()) + .try_into() + .unwrap(); + let without_shared_keybinds = + event_to_protobuf_with_keybinds(Event::ModeUpdate(mode_info_without_keybinds), None) + .unwrap(); + assert_eq!( + expected_empty.encode_to_vec(), + without_shared_keybinds.encode_to_vec() + ); +} + +#[test] +fn event_from_protobuf_bytes_matches_full_decoding() { + use crate::input::config::Config; + use prost::Message; + let keybinds = Config::from_default_assets() + .unwrap() + .keybinds + .to_keybinds_vec(); + let events = vec![ + Event::InitialKeybinds(keybinds.clone()), + Event::InitialKeybinds(vec![]), + Event::ModeUpdate(ModeInfo { + keybinds, + ..Default::default() + }), + Event::TabUpdate(vec![TabInfo { + name: "tab".to_owned(), + active: true, + ..Default::default() + }]), + Event::InputReceived, + ]; + for event in events { + let bytes = ProtobufEvent::try_from(event.clone()) + .unwrap() + .encode_to_vec(); + let fully_decoded: Event = ProtobufEvent::decode(bytes.as_slice()) + .unwrap() + .try_into() + .unwrap(); + assert_eq!(event_from_protobuf_bytes(&bytes).unwrap(), fully_decoded); + } +} diff --git a/zellij-utils/src/plugin_api/mod.rs b/zellij-utils/src/plugin_api/mod.rs index 918ad4ae3..41559264f 100644 --- a/zellij-utils/src/plugin_api/mod.rs +++ b/zellij-utils/src/plugin_api/mod.rs @@ -11,6 +11,7 @@ pub mod plugin_command; pub mod plugin_ids; pub mod plugin_permission; pub mod resize; +pub mod shared_plugin; pub mod style; // NOTE: This code is currently out of order. // Refer to [the PR introducing this change][1] to learn more about the reasons. diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto index 5622c757e..7fbd37e3a 100644 --- a/zellij-utils/src/plugin_api/plugin_command.proto +++ b/zellij-utils/src/plugin_api/plugin_command.proto @@ -223,6 +223,10 @@ enum CommandName { ApplyFloatingSwapLayout = 230; SetPaneBorderStyle = 231; GetNestedSessionKeybinds = 232; + SetSelectableSlot = 233; + HideSlot = 234; + ShowSlot = 235; + CloseSlot = 236; } message PluginCommand { @@ -387,9 +391,27 @@ message PluginCommand { string apply_floating_swap_layout_payload = 173; SetPaneBorderStylePayload set_pane_border_style_payload = 174; GetNestedSessionKeybindsPayload get_nested_session_keybinds_payload = 175; + SetSelectableSlotPayload set_selectable_slot_payload = 176; + uint32 hide_slot_payload = 177; + ShowSlotPayload show_slot_payload = 178; + uint32 close_slot_payload = 179; } } +message SetSelectableSlotPayload { + uint32 slot_id = 1; + bool selectable = 2; +} + +message ShowSlotPayload { + uint32 slot_id = 1; + bool should_float_if_hidden = 2; +} + +message SlotCommandResponse { + optional string error = 1; +} + message GetNestedSessionKeybindsPayload { PaneId pane_id = 1; } diff --git a/zellij-utils/src/plugin_api/plugin_command.rs b/zellij-utils/src/plugin_api/plugin_command.rs index c0886b3c0..3bcc4dd74 100644 --- a/zellij-utils/src/plugin_api/plugin_command.rs +++ b/zellij-utils/src/plugin_api/plugin_command.rs @@ -123,12 +123,14 @@ pub use super::generated_api::api::{ SessionListSnapshot as ProtobufSessionListSnapshot, SetFloatingPanePinnedPayload, SetPaneBorderStylePayload, SetPaneBorderlessPayload, SetPaneColorPayload, SetPaneFrameStylePayload as ProtobufSetPaneFrameStylePayload, - SetPaneRegexHighlightsPayload, SetSelfMouseSelectionSupportPayload, + SetPaneRegexHighlightsPayload, SetSelectableSlotPayload, + SetSelfMouseSelectionSupportPayload, SetSoftKeyboardPayload as ProtobufSetSoftKeyboardPayload, SetTimeoutPayload, ShowCursorPayload, ShowFloatingPanesPayload as ProtobufShowFloatingPanesPayload, ShowFloatingPanesResponse as ProtobufShowFloatingPanesResponse, ShowPaneWithIdPayload, - StackPanesPayload, SubscribePayload, SwitchSessionPayload, SwitchTabToIdPayload, - SwitchTabToPayload, ToggleFloatingPanesPayload, TogglePaneBorderlessPayload, + ShowSlotPayload, SlotCommandResponse as ProtobufSlotCommandResponse, StackPanesPayload, + SubscribePayload, SwitchSessionPayload, SwitchTabToIdPayload, SwitchTabToPayload, + ToggleFloatingPanesPayload, TogglePaneBorderlessPayload, TogglePaneEmbedOrEjectForPaneIdPayload, TogglePaneIdFullscreenPayload, UnsubscribePayload, WebRequestPayload, WriteCharsToPaneIdPayload, WriteToPaneIdPayload, }, @@ -2521,6 +2523,27 @@ impl TryFrom for PluginCommand { Some(CommandName::CurrentSessionLastSavedTime) => { Ok(PluginCommand::CurrentSessionLastSavedTime) }, + Some(CommandName::SetSelectableSlot) => match protobuf_plugin_command.payload { + Some(Payload::SetSelectableSlotPayload(payload)) => Ok( + PluginCommand::SetSelectableSlot(payload.slot_id, payload.selectable), + ), + _ => Err("Mismatched payload for SetSelectableSlot"), + }, + Some(CommandName::HideSlot) => match protobuf_plugin_command.payload { + Some(Payload::HideSlotPayload(slot_id)) => Ok(PluginCommand::HideSlot(slot_id)), + _ => Err("Mismatched payload for HideSlot"), + }, + Some(CommandName::ShowSlot) => match protobuf_plugin_command.payload { + Some(Payload::ShowSlotPayload(payload)) => Ok(PluginCommand::ShowSlot( + payload.slot_id, + payload.should_float_if_hidden, + )), + _ => Err("Mismatched payload for ShowSlot"), + }, + Some(CommandName::CloseSlot) => match protobuf_plugin_command.payload { + Some(Payload::CloseSlotPayload(slot_id)) => Ok(PluginCommand::CloseSlot(slot_id)), + _ => Err("Mismatched payload for CloseSlot"), + }, Some(CommandName::GetNestedSessionKeybinds) => match protobuf_plugin_command.payload { Some(Payload::GetNestedSessionKeybindsPayload(payload)) => { let pane_id = payload @@ -4333,6 +4356,30 @@ impl TryFrom for ProtobufPluginCommand { CurrentSessionLastSavedTimePayload {}, )), }), + PluginCommand::SetSelectableSlot(slot_id, selectable) => Ok(ProtobufPluginCommand { + name: CommandName::SetSelectableSlot as i32, + payload: Some(Payload::SetSelectableSlotPayload( + SetSelectableSlotPayload { + slot_id, + selectable, + }, + )), + }), + PluginCommand::HideSlot(slot_id) => Ok(ProtobufPluginCommand { + name: CommandName::HideSlot as i32, + payload: Some(Payload::HideSlotPayload(slot_id)), + }), + PluginCommand::ShowSlot(slot_id, should_float_if_hidden) => Ok(ProtobufPluginCommand { + name: CommandName::ShowSlot as i32, + payload: Some(Payload::ShowSlotPayload(ShowSlotPayload { + slot_id, + should_float_if_hidden, + })), + }), + PluginCommand::CloseSlot(slot_id) => Ok(ProtobufPluginCommand { + name: CommandName::CloseSlot as i32, + payload: Some(Payload::CloseSlotPayload(slot_id)), + }), PluginCommand::GetNestedSessionKeybinds(pane_id) => { let protobuf_pane_id: ProtobufPaneId = pane_id.try_into()?; Ok(ProtobufPluginCommand { @@ -5478,3 +5525,20 @@ mod tests { } } } + +impl From> for ProtobufSlotCommandResponse { + fn from(result: Result<(), String>) -> Self { + ProtobufSlotCommandResponse { + error: result.err(), + } + } +} + +impl From for Result<(), String> { + fn from(response: ProtobufSlotCommandResponse) -> Self { + match response.error { + Some(error) => Err(error), + None => Ok(()), + } + } +} diff --git a/zellij-utils/src/plugin_api/shared_plugin.proto b/zellij-utils/src/plugin_api/shared_plugin.proto new file mode 100644 index 000000000..348a234bd --- /dev/null +++ b/zellij-utils/src/plugin_api/shared_plugin.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +import "action.proto"; + +package api.shared_plugin; + +enum SlotKind { + Pane = 0; + Background = 1; +} + +message Slot { + uint32 id = 1; + SlotKind kind = 2; + api.action.PluginConfiguration configuration = 3; +} + +message EventContext { + optional uint32 slot_id = 1; + optional uint32 client_id = 2; +} + +enum RenderKind { + Nothing = 0; + All = 1; + Client = 2; + Slots = 3; +} + +message RenderRequest { + RenderKind kind = 1; + optional uint32 client_id = 2; + repeated uint32 slot_ids = 3; +} diff --git a/zellij-utils/src/plugin_api/shared_plugin.rs b/zellij-utils/src/plugin_api/shared_plugin.rs new file mode 100644 index 000000000..26bae577d --- /dev/null +++ b/zellij-utils/src/plugin_api/shared_plugin.rs @@ -0,0 +1,111 @@ +pub use super::generated_api::api::{ + action::{ + NameAndValue as ProtobufNameAndValue, PluginConfiguration as ProtobufPluginConfiguration, + }, + shared_plugin::{ + EventContext as ProtobufEventContext, RenderKind as ProtobufRenderKind, + RenderRequest as ProtobufRenderRequest, Slot as ProtobufSlot, SlotKind as ProtobufSlotKind, + }, +}; +use crate::data::{EventContext, RenderResponse, Slot, SlotKind}; + +use std::collections::BTreeMap; +use std::convert::TryFrom; + +impl From for ProtobufEventContext { + fn from(context: EventContext) -> Self { + ProtobufEventContext { + slot_id: context.slot_id, + client_id: context.client_id, + } + } +} + +impl From for EventContext { + fn from(context: ProtobufEventContext) -> Self { + EventContext { + slot_id: context.slot_id, + client_id: context.client_id, + } + } +} + +impl From for ProtobufSlot { + fn from(slot: Slot) -> Self { + let kind = match slot.kind { + SlotKind::Pane => ProtobufSlotKind::Pane, + SlotKind::Background => ProtobufSlotKind::Background, + }; + ProtobufSlot { + id: slot.id, + kind: kind as i32, + configuration: Some(ProtobufPluginConfiguration { + name_and_value: slot + .configuration + .into_iter() + .map(|(name, value)| ProtobufNameAndValue { name, value }) + .collect(), + }), + } + } +} + +impl From for Slot { + fn from(slot: ProtobufSlot) -> Self { + let kind = match ProtobufSlotKind::try_from(slot.kind) { + Ok(ProtobufSlotKind::Background) => SlotKind::Background, + _ => SlotKind::Pane, + }; + let configuration = slot + .configuration + .as_ref() + .and_then(|c| BTreeMap::try_from(c).ok()) + .unwrap_or_default(); + Slot { + id: slot.id, + kind, + configuration, + } + } +} + +impl From for ProtobufRenderRequest { + fn from(render: RenderResponse) -> Self { + match render { + RenderResponse::Nothing => ProtobufRenderRequest { + kind: ProtobufRenderKind::Nothing as i32, + client_id: None, + slot_ids: vec![], + }, + RenderResponse::All => ProtobufRenderRequest { + kind: ProtobufRenderKind::All as i32, + client_id: None, + slot_ids: vec![], + }, + RenderResponse::Client(client_id) => ProtobufRenderRequest { + kind: ProtobufRenderKind::Client as i32, + client_id: Some(client_id), + slot_ids: vec![], + }, + RenderResponse::Slots(slot_ids) => ProtobufRenderRequest { + kind: ProtobufRenderKind::Slots as i32, + client_id: None, + slot_ids, + }, + } + } +} + +impl From for RenderResponse { + fn from(request: ProtobufRenderRequest) -> Self { + match ProtobufRenderKind::try_from(request.kind) { + Ok(ProtobufRenderKind::All) => RenderResponse::All, + Ok(ProtobufRenderKind::Client) => match request.client_id { + Some(client_id) => RenderResponse::Client(client_id), + None => RenderResponse::All, + }, + Ok(ProtobufRenderKind::Slots) => RenderResponse::Slots(request.slot_ids), + _ => RenderResponse::Nothing, + } + } +} diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap index 7ea1e84f2..b5982ed24 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap @@ -6174,11 +6174,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "compact-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "compact-bar", + }, ), initial_cwd: None, }, @@ -6236,11 +6238,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "status-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "status-bar", + }, ), initial_cwd: None, }, @@ -6260,11 +6264,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "tab-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "tab-bar", + }, ), initial_cwd: None, }, @@ -6313,11 +6319,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "link", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "link", + }, ), initial_cwd: None, }, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap index eb0eaefc7..6a0b68b0b 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap @@ -6174,11 +6174,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "compact-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "compact-bar", + }, ), initial_cwd: None, }, @@ -6236,11 +6238,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "status-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "status-bar", + }, ), initial_cwd: None, }, @@ -6260,11 +6264,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "tab-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "tab-bar", + }, ), initial_cwd: None, }, @@ -6317,11 +6323,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "link", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "link", + }, ), initial_cwd: None, }, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap index df98e8b59..80c4afc5f 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap @@ -169,11 +169,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "compact-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "compact-bar", + }, ), initial_cwd: None, }, @@ -231,11 +233,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "status-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "status-bar", + }, ), initial_cwd: None, }, @@ -255,11 +259,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "tab-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "tab-bar", + }, ), initial_cwd: None, }, @@ -308,11 +314,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "link", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "link", + }, ), initial_cwd: None, }, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap index f89a225fd..baa525863 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap @@ -7945,11 +7945,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "compact-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "compact-bar", + }, ), initial_cwd: None, }, @@ -8007,11 +8009,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "status-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "status-bar", + }, ), initial_cwd: None, }, @@ -8031,11 +8035,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "tab-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "tab-bar", + }, ), initial_cwd: None, }, @@ -8084,11 +8090,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "link", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "link", + }, ), initial_cwd: None, }, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap index f4af5fc82..fa6f82749 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap @@ -6174,11 +6174,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "compact-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "compact-bar", + }, ), initial_cwd: None, }, @@ -6236,11 +6238,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "status-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "status-bar", + }, ), initial_cwd: None, }, @@ -6260,11 +6264,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "tab-bar", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "tab-bar", + }, ), initial_cwd: None, }, @@ -6313,11 +6319,13 @@ Config { _allow_exec_host_cmd: false, location: Zellij( PluginTag( - "link", + "bars", ), ), configuration: PluginUserConfiguration( - {}, + { + "role": "link", + }, ), initial_cwd: None, }, diff --git a/zellij-utils/tests/distribution.rs b/zellij-utils/tests/distribution.rs index 1cba37b39..f5ef75077 100644 --- a/zellij-utils/tests/distribution.rs +++ b/zellij-utils/tests/distribution.rs @@ -15,10 +15,10 @@ use zellij_utils::input::plugins::PluginConfig; const NAME: &str = "testdist"; const SIDEBAR_BYTES: &[u8] = b"sidebar plugin bytes"; -const REPLACED_STATUS_BAR_BYTES: &[u8] = b"replacement status-bar bytes"; -const REMOVED_BUILTIN: &str = "strider"; -const REPLACED_BUILTIN: &str = "status-bar"; -const UNTOUCHED_BUILTIN: &str = "tab-bar"; +const REPLACED_STATUS_BAR_BYTES: &[u8] = b"replacement strider bytes"; +const REMOVED_BUILTIN: &str = "share"; +const REPLACED_BUILTIN: &str = "strider"; +const UNTOUCHED_BUILTIN: &str = "bars"; const BUNDLED_CONFIG: &str = r#" plugins { @@ -196,7 +196,7 @@ fn the_bundled_config_is_layered_over_zellij_defaults() { "the bundled alias should be present" ); assert!( - config.plugins.aliases.contains_key(UNTOUCHED_BUILTIN), + config.plugins.aliases.contains_key("tab-bar"), "Zellij's default aliases should survive the layering" ); assert_eq!( diff --git a/zellij-utils/tests/stock_distribution.rs b/zellij-utils/tests/stock_distribution.rs index c3e3d5f8d..6c91d5214 100644 --- a/zellij-utils/tests/stock_distribution.rs +++ b/zellij-utils/tests/stock_distribution.rs @@ -53,6 +53,21 @@ fn every_builtin_plugin_resolves_and_nothing_else_does() { } } assert!(!distribution::is_builtin_plugin_name("sidebar")); + for legacy_name in ["tab-bar", "status-bar", "compact-bar", "link"] { + let run_plugin = + RunPlugin::from_url(&format!("zellij:{}", legacy_name)).expect("the url should parse"); + let plugin = PluginConfig::from_run_plugin(&run_plugin) + .unwrap_or_else(|| panic!("'{}' should redirect to the bars plugin", legacy_name)); + assert_eq!(plugin.location.display(), "zellij:bars"); + assert_eq!( + plugin + .initial_userspace_configuration + .inner() + .get("role") + .map(|s| s.as_str()), + Some(legacy_name) + ); + } let unknown = RunPlugin::from_url("zellij:sidebar").expect("the url should parse"); assert!(PluginConfig::from_run_plugin(&unknown).is_none()); }