diff options
| author | Aram Drevekenin <aram@poor.dev> | 2025-10-09 11:29:55 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-10-09 11:29:55 +0200 |
| commit | 7e66ae3125bf5031b6c013dee8038c838b42bbc5 (patch) | |
| tree | 061c2311461cbe52b40b8ad433023085cfae67bf | |
| parent | build(cargo): Update Rust toolchain to 1.90.0 (#4457) (diff) | |
| download | sl0ppij-7e66ae3125bf5031b6c013dee8038c838b42bbc5.tar.gz sl0ppij-7e66ae3125bf5031b6c013dee8038c838b42bbc5.tar.xz sl0ppij-7e66ae3125bf5031b6c013dee8038c838b42bbc5.zip | |
Give plugins the ability to read pane contents (#4465)
* initial implementation
* add Selection and better types
* event to plugins
* only send viewport diff
* simplify data structure sent over the wire
* cleanups
* rustfmt
* remove dead code and fix tests
* commit tests
* get pane scrollback plugin command without implementation
* add lines above and lines below to pane contents
* implement get scrollback plugin command
* selection => selected_text
* add convenience method to get the selected text
* move Selection back to zellij-server
* rustfmt
* bring back client id to get contents command
* rustfmt
* add pr
29 files changed, 906 insertions, 32 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce11a9f..745ad0e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) * fix: properly serialize/resurrect layouts with one-line split panes (https://github.com/zellij-org/zellij/pull/4442) * feat: allow attaching to remote Zellij sessions over https (eg. `zellij attach https://example.com/my-cool-session`) (https://github.com/zellij-org/zellij/pull/4460) * build: Update Rust toolchain to 1.90.0 (https://github.com/zellij-org/zellij/pull/4457) +* feat: allow plugins to read pane scrollback (https://github.com/zellij-org/zellij/pull/4465) ## [0.43.1] - 2025-08-08 * fix: pane rename backspace regression (https://github.com/zellij-org/zellij/pull/4346) @@ -5905,6 +5905,7 @@ dependencies = [ "cassowary", "chrono", "close_fds", + "crossbeam", "daemonize", "highway", "insta", diff --git a/zellij-server/Cargo.toml b/zellij-server/Cargo.toml index 44e5d14c..9b70dce7 100644 --- a/zellij-server/Cargo.toml +++ b/zellij-server/Cargo.toml @@ -21,6 +21,7 @@ bytes = { version = "1.6.0", default-features = false, features = ["std"] } cassowary = { version = "0.3.0", default-features = false } chrono = { version = "0.4.19", default-features = false, features = ["std", "clock"] } close_fds = { version = "0.3.2", default-features = false } +crossbeam = { version = "0.8", default-features = false, features = ["crossbeam-channel", "std"] } daemonize = { workspace = true } highway = { version = "0.6.4", default-features = false, features = ["std"] } interprocess = { workspace = true } diff --git a/zellij-server/src/output/mod.rs b/zellij-server/src/output/mod.rs index 537e4cf2..3ed8db69 100644 --- a/zellij-server/src/output/mod.rs +++ b/zellij-server/src/output/mod.rs @@ -1,12 +1,12 @@ use std::collections::VecDeque; -use crate::panes::selection::Selection; use crate::panes::Row; +use crate::panes::Selection; use crate::{ panes::sixel::SixelImageStore, panes::terminal_character::{AnsiCode, CharacterStyles}, - panes::{LinkHandler, TerminalCharacter, DEFAULT_STYLES, EMPTY_TERMINAL_CHARACTER}, + panes::{LinkHandler, PaneId, TerminalCharacter, DEFAULT_STYLES, EMPTY_TERMINAL_CHARACTER}, ClientId, }; use std::cell::RefCell; @@ -16,6 +16,7 @@ use std::{ collections::{HashMap, HashSet}, str, }; +use zellij_utils::data::{PaneContents, PaneRenderReport}; use zellij_utils::errors::prelude::*; use zellij_utils::pane_size::PaneGeom; use zellij_utils::pane_size::SizeInPixels; @@ -246,6 +247,7 @@ pub struct Output { character_cell_size: Rc<RefCell<Option<SizeInPixels>>>, floating_panes_stack: Option<FloatingPanesStack>, styled_underlines: bool, + pane_render_report: PaneRenderReport, } impl Output { @@ -454,6 +456,19 @@ impl Output { .map(|s| s.cursor_is_visible(cursor_x, cursor_y)) .unwrap_or(true) } + pub fn add_pane_contents( + &mut self, + client_ids: &[ClientId], + pane_id: PaneId, + pane_contents: PaneContents, + ) { + self.pane_render_report + .add_pane_contents(client_ids, pane_id.into(), pane_contents); + } + pub fn drain_pane_render_report(&mut self) -> PaneRenderReport { + let empty_pane_render_report = PaneRenderReport::default(); + std::mem::replace(&mut self.pane_render_report, empty_pane_render_report) + } } // this struct represents the geometry of a group of floating panes diff --git a/zellij-server/src/panes/floating_panes/mod.rs b/zellij-server/src/panes/floating_panes/mod.rs index d8ce0c7c..a4361728 100644 --- a/zellij-server/src/panes/floating_panes/mod.rs +++ b/zellij-server/src/panes/floating_panes/mod.rs @@ -374,6 +374,28 @@ impl FloatingPanes { } else { Default::default() }; + + for (kind, pane) in &self.panes { + match kind { + PaneId::Terminal(_) => { + output.add_pane_contents( + &connected_clients, + pane.pid(), + pane.pane_contents(None, false), + ); + }, + PaneId::Plugin(_) => { + for client_id in &connected_clients { + output.add_pane_contents( + &[*client_id], + pane.pid(), + pane.pane_contents(Some(*client_id), false), + ); + } + }, + } + } + let mut floating_panes: Vec<_> = if self.panes_are_visible() { self.panes.iter_mut().collect() } else if self.has_pinned_panes() { diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs index c706c445..5bf26d08 100644 --- a/zellij-server/src/panes/grid.rs +++ b/zellij-server/src/panes/grid.rs @@ -33,12 +33,13 @@ use crate::panes::alacritty_functions::{parse_number, xparse_color}; use crate::panes::hyperlink_tracker::HyperlinkTracker; use crate::panes::link_handler::LinkHandler; use crate::panes::search::SearchResult; -use crate::panes::selection::Selection; use crate::panes::terminal_character::{ AnsiCode, CharsetIndex, Cursor, CursorShape, RcCharacterStyles, StandardCharset, TerminalCharacter, EMPTY_TERMINAL_CHARACTER, }; +use crate::panes::Selection; use crate::ui::components::UiComponentParser; +use zellij_utils::data::PaneContents; fn get_top_non_canonical_rows(rows: &mut Vec<Row>) -> Vec<Row> { let mut index_of_last_non_canonical_row = None; @@ -2444,6 +2445,34 @@ impl Grid { pub fn has_selection(&self) -> bool { !self.selection.is_empty() } + pub fn pane_contents(&self, get_full_scrollback: bool) -> PaneContents { + let mut viewport: Vec<String> = Vec::with_capacity(self.viewport.len()); + for row in &self.viewport { + let s: String = (&row.columns).into_iter().map(|x| x.character).collect(); + viewport.push(s); + } + if get_full_scrollback { + let mut lines_above_viewport: Vec<String> = Vec::with_capacity(self.lines_above.len()); + for row in &self.lines_above { + let s: String = (&row.columns).into_iter().map(|x| x.character).collect(); + lines_above_viewport.push(s); + } + let mut lines_below_viewport: Vec<String> = Vec::with_capacity(self.lines_below.len()); + for row in &self.lines_below { + let s: String = (&row.columns).into_iter().map(|x| x.character).collect(); + lines_below_viewport.push(s); + } + PaneContents::new_with_scrollback( + viewport, + self.selection.start, + self.selection.end, + lines_above_viewport, + lines_below_viewport, + ) + } else { + PaneContents::new(viewport, self.selection.start, self.selection.end) + } + } } impl Perform for Grid { diff --git a/zellij-server/src/panes/mod.rs b/zellij-server/src/panes/mod.rs index bbb74faa..86dab304 100644 --- a/zellij-server/src/panes/mod.rs +++ b/zellij-server/src/panes/mod.rs @@ -19,6 +19,7 @@ pub use floating_panes::*; pub use grid::*; pub use link_handler::*; pub(crate) use plugin_pane::*; +pub use selection::Selection; pub use sixel::*; pub(crate) use terminal_character::*; pub use terminal_pane::*; diff --git a/zellij-server/src/panes/plugin_pane.rs b/zellij-server/src/panes/plugin_pane.rs index be980a2b..65372ffd 100644 --- a/zellij-server/src/panes/plugin_pane.rs +++ b/zellij-server/src/panes/plugin_pane.rs @@ -19,6 +19,7 @@ use crate::ClientId; use std::cell::RefCell; use std::rc::Rc; use vte; +use zellij_utils::data::PaneContents; use zellij_utils::data::{ BareKey, KeyWithModifier, PermissionStatus, PermissionType, PluginPermission, }; @@ -851,6 +852,16 @@ impl Pane for PluginPane { } } } + fn pane_contents( + &self, + client_id: Option<ClientId>, + get_full_scrollback: bool, + ) -> PaneContents { + client_id + .and_then(|c| self.grids.get(&c)) + .map(|g| g.pane_contents(get_full_scrollback)) + .unwrap_or_else(Default::default) + } } impl PluginPane { diff --git a/zellij-server/src/panes/search.rs b/zellij-server/src/panes/search.rs index 573322dd..5c410ad6 100644 --- a/zellij-server/src/panes/search.rs +++ b/zellij-server/src/panes/search.rs @@ -1,4 +1,4 @@ -use crate::panes::selection::Selection; +use super::Selection; use crate::panes::terminal_character::TerminalCharacter; use crate::panes::{Grid, Row}; use std::borrow::Cow; diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs index fe69556f..21cdd64e 100644 --- a/zellij-server/src/panes/terminal_pane.rs +++ b/zellij-server/src/panes/terminal_pane.rs @@ -14,6 +14,7 @@ use std::fmt::Debug; use std::rc::Rc; use std::time::{self, Instant}; use vte; +use zellij_utils::data::PaneContents; use zellij_utils::input::command::RunCommand; use zellij_utils::input::mouse::{MouseEvent, MouseEventType}; use zellij_utils::pane_size::Offset; @@ -881,6 +882,13 @@ impl Pane for TerminalPane { fn reset_logical_position(&mut self) { self.geom.logical_position = None; } + fn pane_contents( + &self, + _client_id: Option<ClientId>, + get_full_scrollback: bool, + ) -> PaneContents { + self.grid.pane_contents(get_full_scrollback) + } } impl TerminalPane { diff --git a/zellij-server/src/panes/tiled_panes/mod.rs b/zellij-server/src/panes/tiled_panes/mod.rs index e877026f..e0388dd9 100644 --- a/zellij-server/src/panes/tiled_panes/mod.rs +++ b/zellij-server/src/panes/tiled_panes/mod.rs @@ -1020,6 +1020,25 @@ impl TiledPanes { .with_context(err_context)? }; for (kind, pane) in self.panes.iter_mut() { + match kind { + PaneId::Terminal(_) => { + output.add_pane_contents( + &connected_clients, + pane.pid(), + pane.pane_contents(None, false), + ); + }, + PaneId::Plugin(_) => { + for client_id in &connected_clients { + output.add_pane_contents( + &[*client_id], + pane.pid(), + pane.pane_contents(Some(*client_id), false), + ); + } + }, + } + if !self.panes_to_hide.contains(&pane.pid()) { let pane_is_stacked_under = stacked_pane_ids_under_flexible_pane.contains(&pane.pid()); diff --git a/zellij-server/src/panes/tiled_panes/unit/stacked_panes_tests.rs b/zellij-server/src/panes/tiled_panes/unit/stacked_panes_tests.rs index 7cc67608..6b4edf40 100644 --- a/zellij-server/src/panes/tiled_panes/unit/stacked_panes_tests.rs +++ b/zellij-server/src/panes/tiled_panes/unit/stacked_panes_tests.rs @@ -15,7 +15,7 @@ use crate::{ ClientId, }; use std::time::Instant; -use zellij_utils::data::{InputMode, PaletteColor}; +use zellij_utils::data::{InputMode, PaletteColor, PaneContents}; macro_rules! mock_pane { ($pane_id:expr, $dimension:expr, $inner:expr, $x:expr, $y:expr, $logical_position:expr, $mock_panes:expr) => { @@ -1236,4 +1236,11 @@ impl Pane for MockPane { fn custom_title(&self) -> Option<String> { unimplemented!() } + fn pane_contents( + &self, + _client_id: Option<ClientId>, + _get_full_scrollback: bool, + ) -> PaneContents { + unimplemented!() + } } diff --git a/zellij-server/src/plugins/mod.rs b/zellij-server/src/plugins/mod.rs index 6149ac97..14fbf345 100644 --- a/zellij-server/src/plugins/mod.rs +++ b/zellij-server/src/plugins/mod.rs @@ -18,6 +18,7 @@ use crate::panes::PaneId; use crate::screen::ScreenInstruction; use crate::session_layout_metadata::SessionLayoutMetadata; use crate::{pty::PtyInstruction, thread_bus::Bus, ClientId, ServerInstruction}; +use zellij_utils::data::PaneRenderReport; pub use wasm_bridge::PluginRenderAsset; use wasm_bridge::WasmBridge; @@ -166,6 +167,7 @@ pub enum PluginInstruction { ChangePluginHostDir(PathBuf, PluginId, ClientId), WebServerStarted(String), // String -> the base url of the web server FailedToStartWebServer(String), + PaneRenderReport(PaneRenderReport), Exit, } @@ -215,6 +217,7 @@ impl From<&PluginInstruction> for PluginContext { PluginInstruction::ChangePluginHostDir(..) => PluginContext::ChangePluginHostDir, PluginInstruction::WebServerStarted(..) => PluginContext::WebServerStarted, PluginInstruction::FailedToStartWebServer(..) => PluginContext::FailedToStartWebServer, + PluginInstruction::PaneRenderReport(..) => PluginContext::PaneRenderReport, } } } @@ -961,6 +964,11 @@ pub(crate) fn plugin_thread_main( .update_plugins(updates, shutdown_send.clone()) .non_fatal(); }, + PluginInstruction::PaneRenderReport(pane_render_report) => { + wasm_bridge + .handle_pane_render_report(pane_render_report, shutdown_send.clone()) + .non_fatal(); + }, PluginInstruction::Exit => { break; }, diff --git a/zellij-server/src/plugins/wasm_bridge.rs b/zellij-server/src/plugins/wasm_bridge.rs index fbb67a5e..ca9837a7 100644 --- a/zellij-server/src/plugins/wasm_bridge.rs +++ b/zellij-server/src/plugins/wasm_bridge.rs @@ -22,7 +22,8 @@ use std::{ use wasmtime::{Engine, Module}; use zellij_utils::consts::{ZELLIJ_CACHE_DIR, ZELLIJ_TMP_DIR}; use zellij_utils::data::{ - FloatingPaneCoordinates, InputMode, PermissionStatus, PermissionType, PipeMessage, PipeSource, + FloatingPaneCoordinates, InputMode, PaneContents, PaneRenderReport, PermissionStatus, + PermissionType, PipeMessage, PipeSource, }; use zellij_utils::downloader::Downloader; use zellij_utils::input::keybinds::Keybinds; @@ -111,6 +112,7 @@ pub struct WasmBridge { keybinds: HashMap<ClientId, Keybinds>, base_modes: HashMap<ClientId, InputMode>, downloader: Downloader, + previous_pane_render_report: Option<PaneRenderReport>, } impl WasmBridge { @@ -163,6 +165,7 @@ impl WasmBridge { keybinds: HashMap::new(), base_modes: HashMap::new(), downloader, + previous_pane_render_report: None, } } pub fn load_plugin( @@ -1033,7 +1036,72 @@ impl WasmBridge { .lock() .unwrap() .retain(|c| c != &client_id); + + // Remove client from cached pane render report + if let Some(ref mut prev_report) = self.previous_pane_render_report { + prev_report.all_pane_contents.remove(&client_id); + } } + + fn get_changed_panes_per_client( + &self, + new_report: &PaneRenderReport, + ) -> HashMap<ClientId, HashMap<zellij_utils::data::PaneId, PaneContents>> { + let mut result: HashMap<ClientId, HashMap<zellij_utils::data::PaneId, PaneContents>> = + HashMap::new(); + + // First report - return everything grouped by client + let Some(prev_report) = &self.previous_pane_render_report else { + for (client_id, panes) in &new_report.all_pane_contents { + result.insert(*client_id, panes.clone()); + } + return result; + }; + + // Compare each client's panes + for (client_id, new_panes) in &new_report.all_pane_contents { + let mut client_panes: HashMap<zellij_utils::data::PaneId, PaneContents> = + HashMap::new(); + + for (pane_id, new_contents) in new_panes { + let has_changed = prev_report + .all_pane_contents + .get(client_id) + .and_then(|prev_panes| prev_panes.get(pane_id)) + .map(|prev_contents| { + // Check if viewport or selected_text changed + prev_contents.viewport != new_contents.viewport + || prev_contents.selected_text != new_contents.selected_text + }) + .unwrap_or(true); // New pane - treat as changed + + if has_changed { + client_panes.insert(*pane_id, new_contents.clone()); + } + } + + if !client_panes.is_empty() { + result.insert(*client_id, client_panes); + } + } + + result + } + + pub fn handle_pane_render_report( + &mut self, + pane_render_report: PaneRenderReport, + shutdown_sender: Sender<()>, + ) -> Result<()> { + let changed_panes_per_client = self.get_changed_panes_per_client(&pane_render_report); + for (client_id, client_panes) in changed_panes_per_client { + let updates = vec![(None, Some(client_id), Event::PaneRenderReport(client_panes))]; + self.update_plugins(updates, shutdown_sender.clone())?; + } + self.previous_pane_render_report = Some(pane_render_report); + Ok(()) + } + pub fn cleanup(&mut self) { for (_plugin_id, loading_plugin_task) in self.loading_plugins.drain() { drop(loading_plugin_task.cancel()); @@ -1603,6 +1671,7 @@ fn check_event_permission( | Event::CommandPaneReRun(..) | Event::InputReceived => PermissionType::ReadApplicationState, Event::WebServerStatus(..) => PermissionType::StartWebServer, + Event::PaneRenderReport(..) => PermissionType::ReadPaneContents, _ => return (PermissionStatus::Granted, None), }; diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs index ee76fa05..7c287b0d 100644 --- a/zellij-server/src/plugins/zellij_exports.rs +++ b/zellij-server/src/plugins/zellij_exports.rs @@ -21,7 +21,8 @@ use std::{ use wasmtime::{Caller, Linker}; use zellij_utils::data::{ CommandType, ConnectToSession, FloatingPaneCoordinates, HttpVerb, KeyWithModifier, LayoutInfo, - MessageToPlugin, OriginatingPlugin, PermissionStatus, PermissionType, PluginPermission, + MessageToPlugin, OriginatingPlugin, PaneScrollbackResponse, PermissionStatus, PermissionType, + PluginPermission, }; use zellij_utils::input::permission::PermissionCache; use zellij_utils::ipc::{ClientToServerMsg, IpcSenderWithContext}; @@ -48,6 +49,7 @@ use zellij_utils::{ layout::{Layout, RunPluginOrAlias}, }, plugin_api::{ + event::ProtobufPaneScrollbackResponse, plugin_command::ProtobufPluginCommand, plugin_ids::{ProtobufPluginIds, ProtobufZellijVersion}, }, @@ -328,6 +330,10 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) { PluginCommand::EditScrollbackForPaneWithId(pane_id) => { edit_scrollback_for_pane_with_id(env, pane_id.into()) }, + PluginCommand::GetPaneScrollback { + pane_id, + get_full_scrollback, + } => get_pane_scrollback(env, pane_id.into(), get_full_scrollback), PluginCommand::WriteToPaneId(bytes, pane_id) => { write_to_pane_id(env, bytes, pane_id.into()) }, @@ -2122,6 +2128,70 @@ fn edit_scrollback_for_pane_with_id(env: &PluginEnv, pane_id: PaneId) { .send_to_screen(ScreenInstruction::EditScrollbackForPaneWithId(pane_id)); } +fn get_pane_scrollback(env: &PluginEnv, pane_id: PaneId, get_full_scrollback: bool) { + use crossbeam::channel::RecvTimeoutError; + use std::time::Duration; + + let err_context = || { + format!( + "failed to get pane scrollback for pane {:?} from plugin {}", + pane_id, + env.name() + ) + }; + + // Create oneshot channel for response + let (response_sender, response_receiver) = crossbeam::channel::bounded(1); + + // Send request to screen thread + env.senders + .send_to_screen(ScreenInstruction::GetPaneScrollback { + pane_id, + client_id: env.client_id, + get_full_scrollback, + response_channel: response_sender, + }) + .with_context(err_context) + .non_fatal(); + + // Block waiting for response with 5 second timeout + let response = match response_receiver.recv_timeout(Duration::from_secs(5)) { + Ok(response) => response, + Err(RecvTimeoutError::Timeout) => { + log::error!( + "GetPaneScrollback timed out after 5s for plugin {} requesting pane {:?}", + env.plugin_id, + pane_id + ); + PaneScrollbackResponse::Err(format!( + "Timeout retrieving scrollback for pane {:?}", + pane_id + )) + }, + Err(RecvTimeoutError::Disconnected) => { + log::error!( + "GetPaneScrollback channel disconnected for plugin {} requesting pane {:?}", + env.plugin_id, + pane_id + ); + PaneScrollbackResponse::Err(format!( + "Channel disconnected while retrieving scrollback for pane {:?}", + pane_id + )) + }, + }; + + // Convert to protobuf and write back to plugin + ProtobufPaneScrollbackResponse::try_from(response) + .map_err(|e| anyhow!("Failed to serialize pane scrollback response: {}", e)) + .and_then(|serialized| { + wasi_write_object(env, &serialized.encode_to_vec())?; + Ok(()) + }) + .with_context(err_context) + .non_fatal(); +} + fn write_to_pane_id(env: &PluginEnv, bytes: Vec<u8>, pane_id: PaneId) { let _ = env .senders @@ -2764,6 +2834,7 @@ fn check_command_permission( PluginCommand::InterceptKeyPresses | PluginCommand::ClearKeyPressesIntercepts => { PermissionType::InterceptInput }, + PluginCommand::GetPaneScrollback { .. } => PermissionType::ReadPaneContents, _ => return (PermissionStatus::Granted, None), }; diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index e90e2cbd..efc5f6bd 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -10,8 +10,9 @@ use std::time::Duration; use log::{debug, warn}; use zellij_utils::data::{ - Direction, FloatingPaneCoordinates, KeyWithModifier, PaneManifest, PluginPermission, Resize, - ResizeStrategy, SessionInfo, Styling, WebSharing, + Direction, FloatingPaneCoordinates, KeyWithModifier, PaneContents, PaneManifest, + PaneScrollbackResponse, PluginPermission, Resize, ResizeStrategy, SessionInfo, Styling, + WebSharing, }; use zellij_utils::errors::prelude::*; use zellij_utils::input::command::RunCommand; @@ -188,6 +189,12 @@ pub enum ScreenInstruction { // shell DumpLayoutToPlugin(PluginId), EditScrollback(ClientId), + GetPaneScrollback { + pane_id: PaneId, + client_id: ClientId, + get_full_scrollback: bool, + response_channel: crossbeam::channel::Sender<PaneScrollbackResponse>, + }, ScrollUp(ClientId), ScrollUpAt(Position, ClientId), ScrollDown(ClientId), @@ -490,6 +497,7 @@ impl From<&ScreenInstruction> for ScreenContext { ScreenInstruction::DumpLayout(..) => ScreenContext::DumpLayout, ScreenInstruction::DumpLayoutToPlugin(..) => ScreenContext::DumpLayoutToPlugin, ScreenInstruction::EditScrollback(..) => ScreenContext::EditScrollback, + ScreenInstruction::GetPaneScrollback { .. } => ScreenContext::GetPaneScrollback, ScreenInstruction::ScrollUp(..) => ScreenContext::ScrollUp, ScreenInstruction::ScrollDown(..) => ScreenContext::ScrollDown, ScreenInstruction::ScrollToBottom(..) => ScreenContext::ScrollToBottom, @@ -1326,6 +1334,13 @@ impl Screen { .context(err_context) .non_fatal(); } + + let pane_render_report = output.drain_pane_render_report(); + let _ = self + .bus + .senders + .send_to_plugin(PluginInstruction::PaneRenderReport(pane_render_report)); + if output.is_dirty() { let serialized_output = output.serialize().context(err_context)?; let _ = self @@ -3837,6 +3852,39 @@ pub(crate) fn screen_thread_main( screen.render(None)?; screen.log_and_report_session_state()?; }, + ScreenInstruction::GetPaneScrollback { + pane_id, + client_id, + get_full_scrollback, + response_channel, + } => { + let mut pane_contents: Option<PaneContents> = None; + for tab in screen.get_tabs_mut().values() { + if let Some(pane) = tab.get_pane_with_id(pane_id) { + pane_contents = + Some(pane.pane_contents(Some(client_id), get_full_scrollback)); + break; + } + } + // Send response back through channel + let response = match pane_contents { + Some(contents) => PaneScrollbackResponse::Ok(contents), + None => { + log::warn!( + "Plugin requested scrollback for pane {:?} but pane was not found", + pane_id + ); + PaneScrollbackResponse::Err(format!("Pane {:?} not found", pane_id)) + }, + }; + if let Err(_) = response_channel.send(response) { + // the plugin likely timed out and dropped the receiver + log::debug!( + "Plugin timed out before pane scrollback response was sent for pane {:?}", + pane_id + ); + } + }, ScreenInstruction::ScrollUp(client_id) => { active_tab_and_connected_client_id!( screen, diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index 3f98554f..f1232594 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -12,6 +12,7 @@ use std::env::temp_dir; use std::net::IpAddr; use std::path::PathBuf; use uuid::Uuid; +use zellij_utils::data::PaneContents; use zellij_utils::data::{ Direction, KeyWithModifier, PaneInfo, PermissionStatus, PermissionType, PluginPermission, ResizeStrategy, WebSharing, @@ -626,6 +627,11 @@ pub trait Pane { fn set_pinned(&mut self, _should_be_pinned: bool) {} fn reset_logical_position(&mut self) {} fn set_mouse_selection_support(&mut self, _selection_support: bool) {} + fn pane_contents( + &self, + client_id: Option<ClientId>, + _get_full_scrollback: bool, + ) -> PaneContents; } #[derive(Clone, Debug)] diff --git a/zellij-tile/src/shim.rs b/zellij-tile/src/shim.rs index 7afe0186..6030dcc7 100644 --- a/zellij-tile/src/shim.rs +++ b/zellij-tile/src/shim.rs @@ -8,6 +8,7 @@ use zellij_utils::data::*; use zellij_utils::errors::prelude::*; use zellij_utils::input::actions::Action; pub use zellij_utils::plugin_api; +use zellij_utils::plugin_api::event::ProtobufPaneScrollbackResponse; use zellij_utils::plugin_api::plugin_command::{ CreateTokenResponse, ListTokensResponse, ProtobufPluginCommand, RenameWebTokenResponse, RevokeAllWebTokensResponse, RevokeTokenResponse, @@ -1045,6 +1046,46 @@ pub fn edit_scrollback_for_pane_with_id(pane_id: PaneId) { unsafe { host_run_plugin_command() }; } +/// Retrieves the scrollback contents from the specified pane +/// +/// # Arguments +/// * `pane_id` - The ID of the pane to get scrollback from +/// * `get_full_scrollback` - Whether to retrieve the full scrollback buffer (including lines above and below viewport) +/// +/// # Returns +/// * `Ok(PaneContents)` - The pane contents if successful +/// * `Err(String)` - An error message if the pane was not found, timed out, or another error occurred +pub fn get_pane_scrollback( + pane_id: PaneId, + get_full_scrollback: bool, +) -> Result<PaneContents, String> { + let plugin_command = PluginCommand::GetPaneScrollback { + pane_id, + get_full_scrollback, + }; + let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap(); + object_to_stdout(&protobuf_plugin_command.encode_to_vec()); + unsafe { host_run_plugin_command() }; + + // Read response from stdin + let response_bytes = + bytes_from_stdin().map_err(|e| format!("Failed to read response from stdin: {:?}", e))?; + + // Decode protobuf response + let protobuf_response = ProtobufPaneScrollbackResponse::decode(response_bytes.as_slice()) + .map_err(|e| format!("Failed to decode protobuf response: {}", e))?; + + // Convert to Rust type + let response = PaneScrollbackResponse::try_from(protobuf_response) + .map_err(|e| format!("Failed to convert protobuf response: {}", e))?; + + // Convert Result enum to actual Result type + match response { + PaneScrollbackResponse::Ok(contents) => Ok(contents), + PaneScrollbackResponse::Err(error_msg) => Err(error_msg), + } +} + /// Write bytes to the `STDIN` of the specified pane pub fn write_to_pane_id(bytes: Vec<u8>, pane_id: PaneId) { let plugin_command = PluginCommand::WriteToPaneId(bytes, pane_id); diff --git a/zellij-utils/assets/prost/api.event.rs b/zellij-utils/assets/prost/api.event.rs index a1240693..7ce91771 100644 --- a/zellij-utils/assets/prost/api.event.rs +++ b/zellij-utils/assets/prost/api.event.rs @@ -9,7 +9,7 @@ pub struct EventNameList { pub struct Event { #[prost(enumeration="EventType", tag="1")] pub name: i32, - #[prost(oneof="event::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29")] + #[prost(oneof="event::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30")] pub payload: ::core::option::Option<event::Payload>, } /// Nested message and enum types in `Event`. @@ -73,6 +73,8 @@ pub mod event { FailedToStartWebServerPayload(super::FailedToStartWebServerPayload), #[prost(message, tag="29")] InterceptedKeyPayload(super::super::key::Key), + #[prost(message, tag="30")] + PaneRenderReportPayload(super::PaneRenderReportPayload), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -516,6 +518,57 @@ pub struct KeyBind { #[prost(message, repeated, tag="2")] pub action: ::prost::alloc::vec::Vec<super::action::Action>, } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PaneRenderReportPayload { + #[prost(message, repeated, tag="1")] + pub pane_contents: ::prost::alloc::vec::Vec<PaneContentsEntry>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PaneContentsEntry { + #[prost(message, optional, tag="1")] + pub pane_id: ::core::option::Option<PaneId>, + #[prost(message, optional, tag="2")] + pub pane_contents: ::core::option::Option<PaneContents>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PaneContents { + #[prost(string, repeated, tag="1")] + pub viewport: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(message, optional, tag="2")] + pub selected_text: ::core::option::Option<SelectedText>, + #[prost(string, repeated, tag="3")] + pub lines_above_viewport: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(string, repeated, tag="4")] + pub lines_below_viewport: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PaneScrollbackResponse { + #[prost(oneof="pane_scrollback_response::Response", tags="1, 2")] + pub response: ::core::option::Option<pane_scrollback_response::Response>, +} +/// Nested message and enum types in `PaneScrollbackResponse`. +pub mod pane_scrollback_response { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Response { + #[prost(message, tag="1")] + Ok(super::PaneContents), + #[prost(string, tag="2")] + Err(::prost::alloc::string::String), + } +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SelectedText { + #[prost(message, optional, tag="1")] + pub start: ::core::option::Option<super::action::Position>, + #[prost(message, optional, tag="2")] + pub end: ::core::option::Option<super::action::Position>, +} #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] pub enum EventType { @@ -569,6 +622,7 @@ pub enum EventType { BeforeClose = 32, FailedToStartWebServer = 34, InterceptedKeyPress = 35, + PaneRenderReport = 36, } impl EventType { /// String value of the enum field names used in the ProtoBuf definition. @@ -612,6 +666,7 @@ impl EventType { EventType::BeforeClose => "BeforeClose", EventType::FailedToStartWebServer => "FailedToStartWebServer", EventType::InterceptedKeyPress => "InterceptedKeyPress", + EventType::PaneRenderReport => "PaneRenderReport", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -652,6 +707,7 @@ impl EventType { "BeforeClose" => Some(Self::BeforeClose), "FailedToStartWebServer" => Some(Self::FailedToStartWebServer), "InterceptedKeyPress" => Some(Self::InterceptedKeyPress), + "PaneRenderReport" => Some(Self::PaneRenderReport), _ => None, } } diff --git a/zellij-utils/assets/prost/api.plugin_command.rs b/zellij-utils/assets/prost/api.plugin_command.rs index b30f2838..b33d0b8e 100644 --- a/zellij-utils/assets/prost/api.plugin_command.rs +++ b/zellij-utils/assets/prost/api.plugin_command.rs @@ -3,7 +3,7 @@ pub struct PluginCommand { #[prost(enumeration="CommandName", tag="1")] pub name: i32, - #[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112")] + #[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")] pub payload: ::core::option::Option<plugin_command::Payload>, } /// Nested message and enum types in `PluginCommand`. @@ -215,6 +215,8 @@ pub mod plugin_command { ReplacePaneWithExistingPanePayload(super::ReplacePaneWithExistingPanePayload), #[prost(message, tag="112")] NewTabPayload(super::NewTabPayload), + #[prost(message, tag="113")] + GetPaneScrollbackPayload(super::GetPaneScrollbackPayload), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -889,6 +891,14 @@ pub struct RenameWebTokenResponse { #[prost(string, optional, tag="2")] pub error: ::core::option::Option<::prost::alloc::string::String>, } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetPaneScrollbackPayload { + #[prost(message, optional, tag="1")] + pub pane_id: ::core::option::Option<PaneId>, + #[prost(bool, tag="2")] + pub get_full_scrollback: bool, +} #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] pub enum CommandName { @@ -1038,6 +1048,7 @@ pub enum CommandName { InterceptKeyPresses = 143, ClearKeyPressesIntercepts = 144, ReplacePaneWithExistingPane = 155, + GetPaneScrollback = 163, } impl CommandName { /// String value of the enum field names used in the ProtoBuf definition. @@ -1192,6 +1203,7 @@ impl CommandName { CommandName::InterceptKeyPresses => "InterceptKeyPresses", CommandName::ClearKeyPressesIntercepts => "ClearKeyPressesIntercepts", CommandName::ReplacePaneWithExistingPane => "ReplacePaneWithExistingPane", + CommandName::GetPaneScrollback => "GetPaneScrollback", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -1343,6 +1355,7 @@ impl CommandName { "InterceptKeyPresses" => Some(Self::InterceptKeyPresses), "ClearKeyPressesIntercepts" => Some(Self::ClearKeyPressesIntercepts), "ReplacePaneWithExistingPane" => Some(Self::ReplacePaneWithExistingPane), + "GetPaneScrollback" => Some(Self::GetPaneScrollback), _ => None, } } diff --git a/zellij-utils/assets/prost/api.plugin_permission.rs b/zellij-utils/assets/prost/api.plugin_permission.rs index 44d0330a..643f5b99 100644 --- a/zellij-utils/assets/prost/api.plugin_permission.rs +++ b/zellij-utils/assets/prost/api.plugin_permission.rs @@ -14,6 +14,7 @@ pub enum PermissionType { FullHdAccess = 10, StartWebServer = 11, InterceptInput = 12, + ReadPaneContents = 13, } impl PermissionType { /// String value of the enum field names used in the ProtoBuf definition. @@ -35,6 +36,7 @@ impl PermissionType { PermissionType::FullHdAccess => "FullHdAccess", PermissionType::StartWebServer => "StartWebServer", PermissionType::InterceptInput => "InterceptInput", + PermissionType::ReadPaneContents => "ReadPaneContents", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -53,6 +55,7 @@ impl PermissionType { "FullHdAccess" => Some(Self::FullHdAccess), "StartWebServer" => Some(Self::StartWebServer), "InterceptInput" => Some(Self::InterceptInput), + "ReadPaneContents" => Some(Self::ReadPaneContents), _ => None, } } diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs index 3328e1de..8632c7a0 100644 --- a/zellij-utils/src/data.rs +++ b/zellij-utils/src/data.rs @@ -4,6 +4,7 @@ use crate::input::config::ConversionError; use crate::input::keybinds::Keybinds; use crate::input::layout::{RunPlugin, SplitSize}; use crate::pane_size::PaneGeom; +use crate::position::Position; use crate::shared::{colors as default_colors, eightbit_to_rgb}; use clap::ArgEnum; use serde::{Deserialize, Serialize}; @@ -15,6 +16,7 @@ use std::path::{Path, PathBuf}; use std::str::{self, FromStr}; use std::time::Duration; use strum_macros::{Display, EnumDiscriminants, EnumIter, EnumString, ToString}; +use unicode_width::UnicodeWidthChar; #[cfg(not(target_family = "wasm"))] use termwiz::{ @@ -944,6 +946,7 @@ pub enum Event { FailedToStartWebServer(String), BeforeClose, InterceptedKeyPress(KeyWithModifier), + PaneRenderReport(HashMap<PaneId, PaneContents>), } #[derive(Debug, Clone, PartialEq, Eq, EnumDiscriminants, ToString, Serialize, Deserialize)] @@ -984,6 +987,7 @@ pub enum Permission { FullHdAccess, StartWebServer, InterceptInput, + ReadPaneContents, } impl PermissionType { @@ -1010,6 +1014,9 @@ impl PermissionType { "Start a local web server to serve Zellij sessions".to_owned() }, PermissionType::InterceptInput => "Intercept Input (keyboard & mouse)".to_owned(), + PermissionType::ReadPaneContents => { + "Read pane contents (viewport and selection)".to_owned() + }, } } } @@ -1864,6 +1871,224 @@ impl ClientInfo { } } +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct PaneRenderReport { + pub all_pane_contents: HashMap<ClientId, HashMap<PaneId, PaneContents>>, +} + +impl PaneRenderReport { + pub fn add_pane_contents( + &mut self, + client_ids: &[ClientId], + pane_id: PaneId, + pane_contents: PaneContents, + ) { + for client_id in client_ids { + let p = self + .all_pane_contents + .entry(*client_id) + .or_insert_with(|| HashMap::new()); + p.insert(pane_id, pane_contents.clone()); + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct PaneContents { + // NOTE: both lines_above_viewport and lines_below_viewport are only populated if explicitly + // requested (eg. with get_full_scrollback true in the plugin command) this is for performance + // reasons + pub lines_above_viewport: Vec<String>, + pub lines_below_viewport: Vec<String>, + pub viewport: Vec<String>, + pub selected_text: Option<SelectedText>, +} + +/// Extract text from a line between two column positions, accounting for wide characters +fn extract_text_by_columns(line: &str, start_col: usize, end_col: usize) -> String { + let mut current_col = 0; + let mut result = String::new(); + let mut capturing = false; + + for ch in line.chars() { + let char_width = ch.width().unwrap_or(0); + + // Start capturing when we reach start_col + if current_col >= start_col && !capturing { + capturing = true; + } + + // Stop if we've reached or passed end_col + if current_col >= end_col { + break; + } + + // Capture character if we're in the range + if capturing { + result.push(ch); + } + + current_col += char_width; + } + + result +} + +/// Extract text from a line starting at a column position, accounting for wide characters +fn extract_text_from_column(line: &str, start_col: usize) -> String { + let mut current_col = 0; + let mut result = String::new(); + let mut capturing = false; + + for ch in line.chars() { + let char_width = ch.width().unwrap_or(0); + + if current_col >= start_col { + capturing = true; + } + + if capturing { + result.push(ch); + } + + current_col += char_width; + } + + result +} + +/// Extract text from a line up to a column position, accounting for wide characters +fn extract_text_to_column(line: &str, end_col: usize) -> String { + let mut current_col = 0; + let mut result = String::new(); + + for ch in line.chars() { + let char_width = ch.width().unwrap_or(0); + + if current_col >= end_col { + break; + } + + result.push(ch); + current_col += char_width; + } + + result +} + +impl PaneContents { + pub fn new(viewport: Vec<String>, selection_start: Position, selection_end: Position) -> Self { + PaneContents { + viewport, + selected_text: SelectedText::from_positions(selection_start, selection_end), + ..Default::default() + } + } + pub fn new_with_scrollback( + viewport: Vec<String>, + selection_start: Position, + selection_end: Position, + lines_above_viewport: Vec<String>, + lines_below_viewport: Vec<String>, + ) -> Self { + PaneContents { + viewport, + selected_text: SelectedText::from_positions(selection_start, selection_end), + lines_above_viewport, + lines_below_viewport, + } + } + + /// Returns the actual text content of the selection, if any exists. + /// Selection only occurs within the viewport. + pub fn get_selected_text(&self) -> Option<String> { + let selected_text = self.selected_text?; + + let start_line = selected_text.start.line() as usize; + let start_col = selected_text.start.column(); + let end_line = selected_text.end.line() as usize; + let end_col = selected_text.end.column(); + + // Handle out of bounds + if start_line >= self.viewport.len() || end_line >= self.viewport.len() { + return None; + } + + if start_line == end_line { + // Single line selection + let line = &self.viewport[start_line]; + Some(extract_text_by_columns(line, start_col, end_col)) + } else { + // Multi-line selection + let mut result = String::new(); + + // First line - from start column to end of line + let first_line = &self.viewport[start_line]; + result.push_str(&extract_text_from_column(first_line, start_col)); + result.push('\n'); + + // Middle lines - complete lines + for i in (start_line + 1)..end_line { + result.push_str(&self.viewport[i]); + result.push('\n'); + } + + // Last line - from start to end column + let last_line = &self.viewport[end_line]; + result.push_str(&extract_text_to_column(last_line, end_col)); + + Some(result) + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PaneScrollbackResponse { + Ok(PaneContents), + Err(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SelectedText { + pub start: Position, + pub end: Position, +} + +impl SelectedText { + pub fn new(start: Position, end: Position) -> Self { + // Normalize: ensure start <= end + let (normalized_start, normalized_end) = if start <= end { + (start, end) + } else { + (end, start) + }; + + // Normalize negative line values to 0 + // (column is already usize so can't be negative) + let normalized_start = Position::new( + normalized_start.line().max(0) as i32, + normalized_start.column() as u16, + ); + let normalized_end = Position::new( + normalized_end.line().max(0) as i32, + normalized_end.column() as u16, + ); + + SelectedText { + start: normalized_start, + end: normalized_end, + } + } + + pub fn from_positions(start: Position, end: Position) -> Option<Self> { + if start == end { + None + } else { + Some(Self::new(start, end)) + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] pub struct PluginIds { pub plugin_id: u32, @@ -2467,6 +2692,10 @@ pub enum PluginCommand { RerunCommandPane(u32), // u32 - terminal pane id ResizePaneIdWithDirection(ResizeStrategy, PaneId), EditScrollbackForPaneWithId(PaneId), + GetPaneScrollback { + pane_id: PaneId, + get_full_scrollback: bool, + }, WriteToPaneId(Vec<u8>, PaneId), WriteCharsToPaneId(String, PaneId), MovePaneWithPaneId(PaneId), diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs index 4980724a..624ef414 100644 --- a/zellij-utils/src/errors.rs +++ b/zellij-utils/src/errors.rs @@ -260,6 +260,7 @@ pub enum ScreenContext { DumpScreen, DumpLayout, EditScrollback, + GetPaneScrollback, ScrollUp, ScrollUpAt, ScrollDown, @@ -454,6 +455,7 @@ pub enum PluginContext { ChangePluginHostDir, WebServerStarted, FailedToStartWebServer, + PaneRenderReport, } /// Stack call representations corresponding to the different types of [`ClientInstruction`]s. diff --git a/zellij-utils/src/plugin_api/event.proto b/zellij-utils/src/plugin_api/event.proto index 52c1801c..f5059131 100644 --- a/zellij-utils/src/plugin_api/event.proto +++ b/zellij-utils/src/plugin_api/event.proto @@ -58,6 +58,7 @@ enum EventType { BeforeClose = 32; FailedToStartWebServer = 34; InterceptedKeyPress = 35; + PaneRenderReport = 36; } message EventNameList { @@ -95,6 +96,7 @@ message Event { WebServerStatusPayload web_server_status_payload = 27; FailedToStartWebServerPayload failed_to_start_web_server_payload = 28; key.Key intercepted_key_payload = 29; + PaneRenderReportPayload pane_render_report_payload = 30; } } @@ -388,3 +390,31 @@ message KeyBind { key.Key key = 1; repeated action.Action action = 2; } + +message PaneRenderReportPayload { + repeated PaneContentsEntry pane_contents = 1; +} + +message PaneContentsEntry { + PaneId pane_id = 1; + PaneContents pane_contents = 2; +} + +message PaneContents { + repeated string viewport = 1; + optional SelectedText selected_text = 2; + repeated string lines_above_viewport = 3; + repeated string lines_below_viewport = 4; +} + +message PaneScrollbackResponse { + oneof response { + PaneContents ok = 1; + string err = 2; + } +} + +message SelectedText { + action.Position start = 1; + action.Position end = 2; +} diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs index fe8822e4..02ef1842 100644 --- a/zellij-utils/src/plugin_api/event.rs +++ b/zellij-utils/src/plugin_api/event.rs @@ -1,18 +1,21 @@ pub use super::generated_api::api::{ action::{Action as ProtobufAction, Position as ProtobufPosition}, event::{ - event::Payload as ProtobufEventPayload, ClientInfo as ProtobufClientInfo, - ClientTabHistory as ProtobufClientTabHistory, CopyDestination as ProtobufCopyDestination, - Event as ProtobufEvent, EventNameList as ProtobufEventNameList, - EventType as ProtobufEventType, FileMetadata as ProtobufFileMetadata, - InputModeKeybinds as ProtobufInputModeKeybinds, KeyBind as ProtobufKeyBind, - LayoutInfo as ProtobufLayoutInfo, ModeUpdatePayload as ProtobufModeUpdatePayload, - PaneId as ProtobufPaneId, PaneInfo as ProtobufPaneInfo, - PaneManifest as ProtobufPaneManifest, PaneType as ProtobufPaneType, + event::Payload as ProtobufEventPayload, pane_scrollback_response, + ClientInfo as ProtobufClientInfo, ClientTabHistory as ProtobufClientTabHistory, + CopyDestination as ProtobufCopyDestination, Event as ProtobufEvent, + EventNameList as ProtobufEventNameList, EventType as ProtobufEventType, + FileMetadata as ProtobufFileMetadata, InputModeKeybinds as ProtobufInputModeKeybinds, + KeyBind as ProtobufKeyBind, LayoutInfo as ProtobufLayoutInfo, + ModeUpdatePayload as ProtobufModeUpdatePayload, PaneContents as ProtobufPaneContents, + PaneContentsEntry as ProtobufPaneContentsEntry, PaneId as ProtobufPaneId, + PaneInfo as ProtobufPaneInfo, PaneManifest as ProtobufPaneManifest, + PaneRenderReportPayload as ProtobufPaneRenderReportPayload, + PaneScrollbackResponse as ProtobufPaneScrollbackResponse, PaneType as ProtobufPaneType, PluginInfo as ProtobufPluginInfo, ResurrectableSession as ProtobufResurrectableSession, - SessionManifest as ProtobufSessionManifest, TabInfo as ProtobufTabInfo, - WebServerStatusPayload as ProtobufWebServerStatusPayload, WebSharing as ProtobufWebSharing, - *, + SelectedText as ProtobufSelectedText, SessionManifest as ProtobufSessionManifest, + TabInfo as ProtobufTabInfo, WebServerStatusPayload as ProtobufWebServerStatusPayload, + WebSharing as ProtobufWebSharing, *, }, input_mode::InputMode as ProtobufInputMode, key::Key as ProtobufKey, @@ -21,8 +24,9 @@ pub use super::generated_api::api::{ #[allow(hidden_glob_reexports)] use crate::data::{ ClientInfo, CopyDestination, Event, EventType, FileMetadata, InputMode, KeyWithModifier, - LayoutInfo, ModeInfo, Mouse, PaneId, PaneInfo, PaneManifest, PermissionStatus, - PluginCapabilities, PluginInfo, SessionInfo, Style, TabInfo, WebServerStatus, WebSharing, + LayoutInfo, ModeInfo, Mouse, PaneContents, PaneId, PaneInfo, PaneManifest, + PaneScrollbackResponse, PermissionStatus, PluginCapabilities, PluginInfo, SelectedText, + SessionInfo, Style, TabInfo, WebServerStatus, WebSharing, }; use crate::errors::prelude::*; @@ -387,6 +391,12 @@ impl TryFrom<ProtobufEvent> for Event { }, _ => Err("Malformed payload for the InterceptedKeyPress Event"), }, + Some(ProtobufEventType::PaneRenderReport) => match protobuf_event.payload { + Some(ProtobufEventPayload::PaneRenderReportPayload(protobuf_payload)) => { + Ok(Event::PaneRenderReport(protobuf_payload.try_into()?)) + }, + _ => Err("Malformed payload for the PaneRenderReport Event"), + }, None => Err("Unknown Protobuf Event"), } } @@ -781,6 +791,12 @@ impl TryFrom<Event> for ProtobufEvent { name: ProtobufEventType::InterceptedKeyPress as i32, payload: Some(event::Payload::KeyPayload(key.try_into()?)), }), + Event::PaneRenderReport(pane_contents_map) => Ok(ProtobufEvent { + name: ProtobufEventType::PaneRenderReport as i32, + payload: Some(event::Payload::PaneRenderReportPayload( + pane_contents_map.try_into()?, + )), + }), } } } @@ -1459,6 +1475,7 @@ impl TryFrom<ProtobufEventType> for EventType { ProtobufEventType::BeforeClose => EventType::BeforeClose, ProtobufEventType::FailedToStartWebServer => EventType::FailedToStartWebServer, ProtobufEventType::InterceptedKeyPress => EventType::InterceptedKeyPress, + ProtobufEventType::PaneRenderReport => EventType::PaneRenderReport, }) } } @@ -1502,6 +1519,7 @@ impl TryFrom<EventType> for ProtobufEventType { EventType::BeforeClose => ProtobufEventType::BeforeClose, EventType::FailedToStartWebServer => ProtobufEventType::FailedToStartWebServer, EventType::InterceptedKeyPress => ProtobufEventType::InterceptedKeyPress, + EventType::PaneRenderReport => ProtobufEventType::PaneRenderReport, }) } } @@ -2243,3 +2261,134 @@ impl TryFrom<ProtobufWebServerStatusPayload> for WebServerStatus { } } } + +impl TryFrom<ProtobufPaneRenderReportPayload> for HashMap<PaneId, PaneContents> { + type Error = &'static str; + fn try_from(protobuf_payload: ProtobufPaneRenderReportPayload) -> Result<Self, &'static str> { + let mut pane_contents_map = HashMap::new(); + + for entry in protobuf_payload.pane_contents { + let pane_id = entry + .pane_id + .ok_or("Missing pane_id in PaneContentsEntry")? + .try_into()?; + let pane_contents = entry + .pane_contents + .ok_or("Missing pane_contents in PaneContentsEntry")? + .try_into()?; + pane_contents_map.insert(pane_id, pane_contents); + } + + Ok(pane_contents_map) + } +} + +impl TryFrom<HashMap<PaneId, PaneContents>> for ProtobufPaneRenderReportPayload { + type Error = &'static str; + fn try_from(pane_contents_map: HashMap<PaneId, PaneContents>) -> Result<Self, &'static str> { + let mut pane_contents_vec = vec![]; + + for (pane_id, pane_contents) in pane_contents_map { + pane_contents_vec.push(ProtobufPaneContentsEntry { + pane_id: Some(pane_id.try_into()?), + pane_contents: Some(pane_contents.try_into()?), + }); + } + + Ok(ProtobufPaneRenderReportPayload { + pane_contents: pane_contents_vec, + }) + } +} + +impl TryFrom<ProtobufPaneContents> for PaneContents { + type Error = &'static str; + fn try_from(protobuf_contents: ProtobufPaneContents) -> Result<Self, &'static str> { + let selected_text = protobuf_contents + .selected_text + .map(|st| st.try_into()) + .transpose()?; + + Ok(PaneContents { + viewport: protobuf_contents.viewport, + selected_text, + lines_above_viewport: protobuf_contents.lines_above_viewport, + lines_below_viewport: protobuf_contents.lines_below_viewport, + }) + } +} + +impl TryFrom<PaneContents> for ProtobufPaneContents { + type Error = &'static str; + fn try_from(pane_contents: PaneContents) -> Result<Self, &'static str> { + let selected_text = pane_contents + .selected_text + .map(|st| st.try_into()) + .transpose()?; + + Ok(ProtobufPaneContents { + viewport: pane_contents.viewport, + selected_text, + lines_above_viewport: pane_contents.lines_above_viewport, + lines_below_viewport: pane_contents.lines_below_viewport, + }) + } +} + +impl TryFrom<ProtobufPaneScrollbackResponse> for PaneScrollbackResponse { + type Error = &'static str; + fn try_from(protobuf_response: ProtobufPaneScrollbackResponse) -> Result<Self, &'static str> { + match protobuf_response.response { + Some(pane_scrollback_response::Response::Ok(pane_contents)) => { + Ok(PaneScrollbackResponse::Ok(pane_contents.try_into()?)) + }, + Some(pane_scrollback_response::Response::Err(error_msg)) => { + Ok(PaneScrollbackResponse::Err(error_msg)) + }, + None => Err("PaneScrollbackResponse missing response field"), + } + } +} + +impl TryFrom<PaneScrollbackResponse> for ProtobufPaneScrollbackResponse { + type Error = &'static str; + fn try_from(response: PaneScrollbackResponse) -> Result<Self, &'static str> { + let response_field = match response { + PaneScrollbackResponse::Ok(pane_contents) => { + pane_scrollback_response::Response::Ok(pane_contents.try_into()?) + }, + PaneScrollbackResponse::Err(error_msg) => { + pane_scrollback_response::Response::Err(error_msg) + }, + }; + Ok(ProtobufPaneScrollbackResponse { + response: Some(response_field), + }) + } +} + +impl TryFrom<ProtobufSelectedText> for SelectedText { + type Error = &'static str; + fn try_from(protobuf_selected_text: ProtobufSelectedText) -> Result<Self, &'static str> { + Ok(SelectedText { + start: protobuf_selected_text + .start + .ok_or("Missing start in SelectedText")? + .try_into()?, + end: protobuf_selected_text + .end + .ok_or("Missing end in SelectedText")? + .try_into()?, + }) + } +} + +impl TryFrom<SelectedText> for ProtobufSelectedText { + type Error = &'static str; + fn try_from(selected_text: SelectedText) -> Result<Self, &'static str> { + Ok(ProtobufSelectedText { + start: Some(selected_text.start.try_into()?), + end: Some(selected_text.end.try_into()?), + }) + } +} diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto index 49ab98c6..7ffe2fc1 100644 --- a/zellij-utils/src/plugin_api/plugin_command.proto +++ b/zellij-utils/src/plugin_api/plugin_command.proto @@ -159,6 +159,7 @@ enum CommandName { InterceptKeyPresses = 143; ClearKeyPressesIntercepts = 144; ReplacePaneWithExistingPane = 155; + GetPaneScrollback = 163; } message PluginCommand { @@ -266,6 +267,7 @@ message PluginCommand { RenameWebLoginTokenPayload rename_web_login_token_payload = 110; ReplacePaneWithExistingPanePayload replace_pane_with_existing_pane_payload = 111; NewTabPayload new_tab_payload = 112; + GetPaneScrollbackPayload get_pane_scrollback_payload = 113; } } @@ -703,3 +705,8 @@ message RenameWebTokenResponse { bool successfully_renamed = 1; optional string error = 2; } + +message GetPaneScrollbackPayload { + PaneId pane_id = 1; + bool get_full_scrollback = 2; +} diff --git a/zellij-utils/src/plugin_api/plugin_command.rs b/zellij-utils/src/plugin_api/plugin_command.rs index 4fc9d939..c1ba877e 100644 --- a/zellij-utils/src/plugin_api/plugin_command.rs +++ b/zellij-utils/src/plugin_api/plugin_command.rs @@ -12,14 +12,14 @@ pub use super::generated_api::api::{ FixedOrPercent as ProtobufFixedOrPercent, FixedOrPercentValue as ProtobufFixedOrPercentValue, FloatMultiplePanesPayload, FloatingPaneCoordinates as ProtobufFloatingPaneCoordinates, GenerateWebLoginTokenPayload, - GroupAndUngroupPanesPayload, HidePaneWithIdPayload, HighlightAndUnhighlightPanesPayload, - HttpVerb as ProtobufHttpVerb, IdAndNewName, KeyToRebind, KeyToUnbind, KillSessionsPayload, - ListTokensResponse, LoadNewPluginPayload, MessageToPluginPayload, - MovePaneWithPaneIdInDirectionPayload, MovePaneWithPaneIdPayload, MovePayload, - NewPluginArgs as ProtobufNewPluginArgs, NewTabPayload, NewTabsWithLayoutInfoPayload, - OpenCommandPaneFloatingNearPluginPayload, OpenCommandPaneInPlaceOfPluginPayload, - OpenCommandPaneNearPluginPayload, OpenCommandPanePayload, - OpenFileFloatingNearPluginPayload, OpenFileInPlaceOfPluginPayload, + GetPaneScrollbackPayload, GroupAndUngroupPanesPayload, HidePaneWithIdPayload, + HighlightAndUnhighlightPanesPayload, HttpVerb as ProtobufHttpVerb, IdAndNewName, + KeyToRebind, KeyToUnbind, KillSessionsPayload, ListTokensResponse, LoadNewPluginPayload, + MessageToPluginPayload, MovePaneWithPaneIdInDirectionPayload, MovePaneWithPaneIdPayload, + MovePayload, NewPluginArgs as ProtobufNewPluginArgs, NewTabPayload, + NewTabsWithLayoutInfoPayload, OpenCommandPaneFloatingNearPluginPayload, + OpenCommandPaneInPlaceOfPluginPayload, OpenCommandPaneNearPluginPayload, + OpenCommandPanePayload, OpenFileFloatingNearPluginPayload, OpenFileInPlaceOfPluginPayload, OpenFileNearPluginPayload, OpenFilePayload, OpenTerminalFloatingNearPluginPayload, OpenTerminalInPlaceOfPluginPayload, OpenTerminalNearPluginPayload, PageScrollDownInPaneIdPayload, PageScrollUpInPaneIdPayload, PaneId as ProtobufPaneId, @@ -1109,6 +1109,18 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand { }, _ => Err("Mismatched payload for EditScrollback"), }, + Some(CommandName::GetPaneScrollback) => match protobuf_plugin_command.payload { + Some(Payload::GetPaneScrollbackPayload(get_pane_scrollback_payload)) => { + match get_pane_scrollback_payload.pane_id { + Some(pane_id) => Ok(PluginCommand::GetPaneScrollback { + pane_id: pane_id.try_into()?, + get_full_scrollback: get_pane_scrollback_payload.get_full_scrollback, + }), + _ => Err("Malformed get_pane_scrollback_payload"), + } + }, + _ => Err("Mismatched payload for GetPaneScrollback"), + }, Some(CommandName::WriteToPaneId) => match protobuf_plugin_command.payload { Some(Payload::WriteToPaneIdPayload(write_to_pane_id_payload)) => { match write_to_pane_id_payload.pane_id { @@ -2373,6 +2385,18 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand { }, )), }), + PluginCommand::GetPaneScrollback { + pane_id, + get_full_scrollback, + } => Ok(ProtobufPluginCommand { + name: CommandName::GetPaneScrollback as i32, + payload: Some(Payload::GetPaneScrollbackPayload( + GetPaneScrollbackPayload { + pane_id: Some(pane_id.try_into()?), + get_full_scrollback, + }, + )), + }), PluginCommand::WriteToPaneId(bytes_to_write, pane_id) => Ok(ProtobufPluginCommand { name: CommandName::WriteToPaneId as i32, payload: Some(Payload::WriteToPaneIdPayload(WriteToPaneIdPayload { diff --git a/zellij-utils/src/plugin_api/plugin_permission.proto b/zellij-utils/src/plugin_api/plugin_permission.proto index 0f53bb55..85126830 100644 --- a/zellij-utils/src/plugin_api/plugin_permission.proto +++ b/zellij-utils/src/plugin_api/plugin_permission.proto @@ -16,4 +16,5 @@ enum PermissionType { FullHdAccess = 10; StartWebServer = 11; InterceptInput = 12; + ReadPaneContents = 13; } diff --git a/zellij-utils/src/plugin_api/plugin_permission.rs b/zellij-utils/src/plugin_api/plugin_permission.rs index 926f993f..307d3576 100644 --- a/zellij-utils/src/plugin_api/plugin_permission.rs +++ b/zellij-utils/src/plugin_api/plugin_permission.rs @@ -28,6 +28,7 @@ impl TryFrom<ProtobufPermissionType> for PermissionType { ProtobufPermissionType::FullHdAccess => Ok(PermissionType::FullHdAccess), ProtobufPermissionType::StartWebServer => Ok(PermissionType::StartWebServer), ProtobufPermissionType::InterceptInput => Ok(PermissionType::InterceptInput), + ProtobufPermissionType::ReadPaneContents => Ok(PermissionType::ReadPaneContents), } } } @@ -57,6 +58,7 @@ impl TryFrom<PermissionType> for ProtobufPermissionType { PermissionType::FullHdAccess => Ok(ProtobufPermissionType::FullHdAccess), PermissionType::StartWebServer => Ok(ProtobufPermissionType::StartWebServer), PermissionType::InterceptInput => Ok(ProtobufPermissionType::InterceptInput), + PermissionType::ReadPaneContents => Ok(ProtobufPermissionType::ReadPaneContents), } } } |
