diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-03-05 21:13:02 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-05 21:13:02 +0100 |
| commit | 903cdf5d32476a8d6cc208d2c418a1f2c67e5a53 (patch) | |
| tree | 9a4647d9dc3efcdea8149fd78fdfa956b3c8d8cb | |
| parent | build: make test mirror build command for plugins (#4801) (diff) | |
| download | sl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.tar.gz sl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.tar.xz sl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.zip | |
Click file paths to open in editor, allow plugins to change styles and hover of viewport (#4802)
* initial implementation
* implement hover
* add styles and foreground/background emphases, fix upsert hover collisions
* initial implementation of link plugin
* suppress highlighting + clicks on unfocused and mouse mode panes
* also make italic
* fix env var and ~ expansions
* fix regex issues and add defensive validations
* fix tests and add plugin assets
* add tests
* refactor all the things
* add pr
58 files changed, 3002 insertions, 79 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index abb8db24..1018bdde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) * platform: native Windows support (https://github.com/zellij-org/zellij/pull/4720, https://github.com/zellij-org/zellij/pull/4721, https://github.com/zellij-org/zellij/pull/4722, https://github.com/zellij-org/zellij/pull/4738, https://github.com/zellij-org/zellij/pull/4740, https://github.com/zellij-org/zellij/pull/4746, https://github.com/zellij-org/zellij/pull/4759, https://github.com/zellij-org/zellij/pull/4768) * feat: allow changing pane foreground/background color through the cli/plugin-api (https://github.com/zellij-org/zellij/pull/4737) * build: fix redundant proto codegen, plugin batch building and testing, and profile mismatch between build and run (https://github.com/zellij-org/zellij/pull/4799, https://github.com/zellij-org/zellij/pull/4801) +* feat: click to open file-paths in viewport, allow plugins to highlight viewport text (https://github.com/zellij-org/zellij/pull/4802) ## [0.43.1] - 2025-08-08 @@ -2171,6 +2171,13 @@ dependencies = [ ] [[package]] +name = "link" +version = "0.1.0" +dependencies = [ + "zellij-tile", +] + +[[package]] name = "linked-hash-map" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -53,6 +53,7 @@ members = [ "default-plugins/share", "default-plugins/multiple-select", "default-plugins/layout-manager", + "default-plugins/link", "zellij-client", "zellij-server", "zellij-utils", diff --git a/default-plugins/fixture-plugin-for-tests/src/main.rs b/default-plugins/fixture-plugin-for-tests/src/main.rs index 6091586c..da0e6482 100644 --- a/default-plugins/fixture-plugin-for-tests/src/main.rs +++ b/default-plugins/fixture-plugin-for-tests/src/main.rs @@ -76,6 +76,7 @@ impl ZellijPlugin for State { EventType::FileSystemDelete, EventType::BeforeClose, EventType::PluginConfigurationChanged, + EventType::HighlightClicked, ]); watch_filesystem(); } @@ -590,6 +591,23 @@ impl ZellijPlugin for State { // Test send_sigkill_to_pane_id send_sigkill_to_pane_id(PaneId::Terminal(1)); }, + BareKey::Char('g') if key.has_only_modifiers(&[KeyModifier::Super]) => { + // Test set_pane_regex_highlights + let highlights = vec![RegexHighlight { + pattern: "test_pattern".to_string(), + style: HighlightStyle::Emphasis0, + context: BTreeMap::new(), + on_hover: false, + bold: false, + italic: true, + underline: true, + }]; + set_pane_regex_highlights(PaneId::Terminal(1), highlights); + }, + BareKey::Char('h') if key.has_only_modifiers(&[KeyModifier::Super]) => { + // Test clear_pane_highlights + clear_pane_highlights(PaneId::Terminal(1)); + }, BareKey::Char('a') if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) => { diff --git a/default-plugins/link/.cargo/config.toml b/default-plugins/link/.cargo/config.toml new file mode 100644 index 00000000..6b509f5b --- /dev/null +++ b/default-plugins/link/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasip1" diff --git a/default-plugins/link/Cargo.toml b/default-plugins/link/Cargo.toml new file mode 100644 index 00000000..d4676b24 --- /dev/null +++ b/default-plugins/link/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "link" +version = "0.1.0" +authors = ["Aram Drevekenin <aram@poor.dev>"] +edition.workspace = true +license.workspace = true + +[dependencies] +zellij-tile = { path = "../../zellij-tile" } diff --git a/default-plugins/link/src/main.rs b/default-plugins/link/src/main.rs new file mode 100644 index 00000000..3862e5d0 --- /dev/null +++ b/default-plugins/link/src/main.rs @@ -0,0 +1,459 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use zellij_tile::prelude::*; + +const FILE_PATH_REGEX: &str = r#"(?:(?:\./|\.\./|/)[A-Za-z0-9_./\-+@%,#=~!\$\{\}\[\]]+|~/[A-Za-z0-9_./\-+@%,#=~!\$\{\}\[\]]+|\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/[A-Za-z0-9_./\-+@%,#=~!\$\{\}\[\]]+|[a-zA-Z0-9_][a-zA-Z0-9_.\-]*/[A-Za-z0-9_./\-+@%,#=~!\$\{\}\[\]]+)(?::\d+(?::\d+)?)?"#; + +const CWD_CONTEXT_KEY: &str = "cwd"; + +#[derive(Default)] +struct State { + known_terminal_panes: HashSet<PaneId>, + /// Tracks the current CWD for each terminal pane. + pane_cwds: HashMap<PaneId, PathBuf>, + /// Tracks the directory entry names highlighted for each pane, + /// so they can be removed when the CWD changes. + pane_dir_entries: HashMap<PaneId, Vec<String>>, + /// Session environment variables, fetched once on load. + /// Used for `~` and `$VAR` expansion in clicked paths. + env_vars: BTreeMap<String, String>, +} + +register_plugin!(State); + +impl ZellijPlugin for State { + fn load(&mut self, _configuration: BTreeMap<String, String>) { + 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/<absolute_path>. + change_host_folder(PathBuf::from("/")); + self.env_vars = get_session_environment_variables(); + } + + fn update(&mut self, event: Event) -> bool { + 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); + }, + Event::CwdChanged(pane_id, new_cwd, _focused_client_ids) => { + self.handle_cwd_changed(pane_id, new_cwd); + }, + _ => {}, + } + 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) { + let mut current_panes: HashSet<PaneId> = HashSet::new(); + + for (_tab_index, panes) in &pane_manifest.panes { + for pane_info in panes { + if !pane_info.is_plugin { + let pane_id = PaneId::Terminal(pane_info.id); + current_panes.insert(pane_id); + } + } + } + + // 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); + } + self.set_all_highlights_for_pane(pane_id); + } + } + + // 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); + self.pane_dir_entries.remove(&pane_id); + } + } + + self.known_terminal_panes = current_panes; + } + + fn handle_cwd_changed(&mut self, pane_id: PaneId, new_cwd: PathBuf) { + let old_cwd = self.pane_cwds.get(&pane_id); + if old_cwd == Some(&new_cwd) { + return; + } + + 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); + } + + fn handle_highlight_clicked(&self, matched_string: String, context: BTreeMap<String, String>) { + let (path_str, line_number) = parse_path_and_line(&matched_string); + let path_str = path_str.trim(); + 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) { + PathBuf::from(cwd).join(path_str) + } else { + 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 + }; + + if metadata.is_dir() { + let mut args = BTreeMap::new(); + let mut configuration = BTreeMap::new(); + args.insert("open_directly".to_owned(), "true".to_owned()); + configuration.insert("caller_cwd".to_owned(), absolute_path.display().to_string()); + pipe_message_to_plugin( + MessageToPlugin::new("filepicker") + .with_plugin_url("filepicker") + .new_plugin_instance_should_have_pane_title(&format!( + "Browse: {}", + absolute_path.display() + )) + .new_plugin_instance_should_be_focused() + .new_plugin_instance_should_have_cwd(absolute_path) + .with_args(args) + .with_plugin_config(configuration), + ); + } else { + let mut file_to_open = FileToOpen::new(&absolute_path); + if let Some(line) = line_number { + file_to_open = file_to_open.with_line_number(line); + } + open_file_floating(file_to_open, None, BTreeMap::new()); + } + } + + /// (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, + context: context.clone(), + on_hover: true, + bold: false, + italic: true, + underline: true, + }); + + // 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 pattern = regex_escape(entry_name); + highlights.push(RegexHighlight { + pattern, + style: HighlightStyle::None, + context: context.clone(), + on_hover: true, + bold: false, + italic: true, + underline: true, + }); + } + } + + set_pane_regex_highlights(pane_id, highlights); + } + + fn scan_and_store_dir_entries(&mut self, pane_id: PaneId, cwd: &Path) { + let host_path = Path::new("/host").join(cwd.strip_prefix("/").unwrap_or(cwd)); + let dir_entries = scan_directory(&host_path); + self.pane_dir_entries.insert(pane_id, dir_entries); + } + + fn cwd_context_for_pane(&self, pane_id: PaneId) -> BTreeMap<String, String> { + let mut context = BTreeMap::new(); + if let Some(cwd) = self.pane_cwds.get(&pane_id) { + context.insert(CWD_CONTEXT_KEY.to_owned(), cwd.display().to_string()); + } + context + } +} + +/// Scan a directory for first-level file and folder names. +/// Returns an empty vec on any error. +fn scan_directory(path: &Path) -> Vec<String> { + let mut entries = Vec::new(); + let read_dir = match std::fs::read_dir(path) { + Ok(rd) => rd, + Err(_) => return entries, + }; + for entry in read_dir { + if let Ok(entry) = entry { + if let Some(name) = entry.file_name().to_str() { + entries.push(name.to_owned()); + } + } + } + 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() { + match c { + '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' => { + escaped.push('\\'); + escaped.push(c); + }, + _ => escaped.push(c), + } + } + 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, String>) -> String { + // Step 1: tilde expansion (only leading ~) + let after_tilde = if path == "~" { + match env_vars.get("HOME") { + Some(home) => home.clone(), + None => path.to_owned(), + } + } else if let Some(rest) = path.strip_prefix("~/") { + match env_vars.get("HOME") { + Some(home) => format!("{}/{}", home, rest), + None => path.to_owned(), + } + } else { + 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); + let mut i = 0; + + 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; + while end < len && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') { + end += 1; + } + (&after_tilde[start..end], end) + } else { + result.push('$'); + i += 1; + continue; + } + }; + + 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; + } else { + result.push(bytes[i] as char); + i += 1; + } + } + + result +} + +fn parse_path_and_line(matched_string: &str) -> (&str, Option<usize>) { + let mut end = matched_string.len(); + let mut numeric_segments: Vec<(usize, &str)> = Vec::new(); + + loop { + if end == 0 { + break; + } + let search_region = &matched_string[..end]; + if let Some(colon_pos) = search_region.rfind(':') { + let segment = &matched_string[colon_pos + 1..end]; + if !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()) { + numeric_segments.push((colon_pos, segment)); + end = colon_pos; + } else { + break; + } + } else { + break; + } + } + + numeric_segments.reverse(); + + match numeric_segments.first() { + None => (matched_string, None), + Some(&(colon_pos, line_str)) => { + let path = &matched_string[..colon_pos]; + (path, line_str.parse::<usize>().ok()) + }, + } +} + +#[cfg(test)] +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"); + assert_eq!(path, "src/main.rs"); + assert_eq!(line, None); + } + + #[test] + fn parse_path_and_line_with_line_number() { + let (path, line) = parse_path_and_line("src/main.rs:42"); + assert_eq!(path, "src/main.rs"); + assert_eq!(line, Some(42)); + } + + #[test] + fn parse_path_and_line_with_line_and_col() { + let (path, line) = parse_path_and_line("src/main.rs:42:10"); + assert_eq!(path, "src/main.rs"); + assert_eq!(line, Some(42)); + } + + #[test] + fn parse_path_and_line_no_trailing_number() { + let (path, line) = parse_path_and_line("src/main.rs:"); + assert_eq!(path, "src/main.rs:"); + assert_eq!(line, None); + } + + // --- expand_path tests --- + + #[test] + fn expand_path_tilde() { + let mut env = BTreeMap::new(); + env.insert("HOME".into(), "/home/user".into()); + assert_eq!(expand_path("~/foo/bar", &env), "/home/user/foo/bar"); + } + + #[test] + fn expand_path_env_var() { + let mut env = BTreeMap::new(); + env.insert("HOME".into(), "/home/user".into()); + assert_eq!(expand_path("$HOME/foo", &env), "/home/user/foo"); + } + + #[test] + fn expand_path_braced_var() { + let mut env = BTreeMap::new(); + env.insert("HOME".into(), "/home/user".into()); + assert_eq!(expand_path("${HOME}/foo", &env), "/home/user/foo"); + } + + #[test] + fn expand_path_unknown_var_preserved() { + let env = BTreeMap::new(); + assert_eq!(expand_path("$UNKNOWN/foo", &env), "$UNKNOWN/foo"); + } + + #[test] + fn expand_path_no_expansion_needed() { + let env = BTreeMap::new(); + 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"); + assert_eq!(regex_escape("a+b*c?"), r"a\+b\*c\?"); + assert_eq!(regex_escape("(foo)[bar]{baz}"), r"\(foo\)\[bar\]\{baz\}"); + } + + #[test] + fn regex_escape_no_special_chars() { + assert_eq!(regex_escape("foobar"), "foobar"); + assert_eq!(regex_escape("hello_world"), "hello_world"); + } + + // --- scan_directory tests --- + + #[test] + fn scan_directory_returns_entries() { + use std::fs; + let dir = std::env::temp_dir().join("zellij_test_scan_dir"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("file1.txt"), "").unwrap(); + fs::write(dir.join("file2.rs"), "").unwrap(); + fs::create_dir(dir.join("subdir")).unwrap(); + let mut entries = scan_directory(&dir); + entries.sort(); + assert_eq!(entries, vec!["file1.txt", "file2.rs", "subdir"]); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/default-plugins/strider/src/main.rs b/default-plugins/strider/src/main.rs index 8870232f..6357d955 100644 --- a/default-plugins/strider/src/main.rs +++ b/default-plugins/strider/src/main.rs @@ -138,11 +138,24 @@ impl ZellijPlugin for State { fn pipe(&mut self, pipe_message: PipeMessage) -> bool { if pipe_message.is_private && pipe_message.name == "filepicker" { - if let PipeSource::Cli(pipe_id) = &pipe_message.source { - #[cfg(target_family = "wasm")] - block_cli_pipe_input(pipe_id); + let open_directly = pipe_message + .args + .get("open_directly") + .map(|v| v == "true") + .unwrap_or(false); + if open_directly { + // Standalone mode: selecting a file opens it directly, + // then the plugin closes itself. + self.close_on_selection = true; + } else { + // Filepicker callback mode: send result back to caller. + if let PipeSource::Cli(pipe_id) = &pipe_message.source { + #[cfg(target_family = "wasm")] + block_cli_pipe_input(pipe_id); + } + self.handling_filepick_request_from = + Some((pipe_message.source, pipe_message.args)); } - self.handling_filepick_request_from = Some((pipe_message.source, pipe_message.args)); true } else { false diff --git a/xtask/src/main.rs b/xtask/src/main.rs index d78d9433..2b249b5a 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -83,6 +83,10 @@ fn workspace_members() -> &'static Vec<WorkspaceMember> { build: true, }, WorkspaceMember { + crate_name: "default-plugins/link", + build: true, + }, + WorkspaceMember { crate_name: "zellij-utils", build: false, }, diff --git a/zellij-server/src/output/mod.rs b/zellij-server/src/output/mod.rs index 9297be11..5fd4ff2d 100644 --- a/zellij-server/src/output/mod.rs +++ b/zellij-server/src/output/mod.rs @@ -40,23 +40,44 @@ fn vte_hide_cursor_instruction(vte_output: &mut String) -> Result<()> { write!(vte_output, "\u{1b}[?25l").context("failed to execute VTE instruction to hide cursor") } +/// A selection region with associated styling for highlights and text selection. +#[derive(Debug, Clone, Copy)] +pub struct HighlightSelection { + pub selection: Selection, + pub bg: Option<AnsiCode>, + pub fg: Option<AnsiCode>, + pub bold: bool, + pub italic: bool, + pub underline: bool, +} + fn adjust_styles_for_possible_selection( - chunk_selection_and_colors: Vec<(Selection, AnsiCode, Option<AnsiCode>)>, + chunk_selection_and_colors: &[HighlightSelection], character_styles: CharacterStyles, chunk_y: usize, chunk_width: usize, ) -> CharacterStyles { chunk_selection_and_colors .iter() - .find(|(selection, _background_color, _foreground_color)| { - selection.contains(chunk_y, chunk_width) - }) - .map(|(_selection, background_color, foreground_color)| { - let mut character_styles = character_styles.background(Some(*background_color)); - if let Some(foreground_color) = foreground_color { - character_styles = character_styles.foreground(Some(*foreground_color)); + .find(|hs| hs.selection.contains(chunk_y, chunk_width)) + .map(|hs| { + let mut styles = character_styles; + if let Some(bg) = hs.bg { + styles = styles.background(Some(bg)); + } + if let Some(fg) = hs.fg { + styles = styles.foreground(Some(fg)); + } + if hs.bold { + styles = styles.bold(Some(AnsiCode::On)); + } + if hs.italic { + styles = styles.italic(Some(AnsiCode::On)); + } + if hs.underline { + styles = styles.underline(Some(AnsiCode::Underline(None))); } - character_styles + styles }) .unwrap_or(character_styles) } @@ -982,9 +1003,9 @@ pub struct CharacterChunk { pub x: usize, pub y: usize, pub changed_colors: Option<[Option<AnsiCode>; 256]>, - selection_and_colors: Vec<(Selection, AnsiCode, Option<AnsiCode>)>, // Selection, background color, optional foreground color pub pane_default_fg: Option<AnsiCode>, pub pane_default_bg: Option<AnsiCode>, + selection_and_colors: Vec<HighlightSelection>, } #[derive(Debug, Clone, Copy, Default)] @@ -1009,21 +1030,17 @@ impl CharacterChunk { } pub fn add_selection_and_colors( &mut self, - selection: Selection, - background_color: AnsiCode, - foreground_color: Option<AnsiCode>, + highlight: HighlightSelection, offset_x: usize, offset_y: usize, ) { - self.selection_and_colors.push(( - selection.offset(offset_x, offset_y), - background_color, - foreground_color, - )); + self.selection_and_colors.push(HighlightSelection { + selection: highlight.selection.offset(offset_x, offset_y), + ..highlight + }); } - pub fn selection_and_colors(&self) -> Vec<(Selection, AnsiCode, Option<AnsiCode>)> { - // Selection, background color, optional foreground color - self.selection_and_colors.clone() + pub fn selection_and_colors(&self) -> &[HighlightSelection] { + &self.selection_and_colors } pub fn add_changed_colors(&mut self, changed_colors: Option<[Option<AnsiCode>; 256]>) { self.changed_colors = changed_colors; diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs index c7eee368..cb629ac9 100644 --- a/zellij-server/src/panes/grid.rs +++ b/zellij-server/src/panes/grid.rs @@ -1,9 +1,11 @@ use super::sixel::{PixelRect, SixelGrid, SixelImageStore}; use std::borrow::Cow; use std::cell::RefCell; +use std::collections::BTreeMap; use std::collections::HashMap; use std::rc::Rc; -use zellij_utils::data::Style; +use unicode_width::UnicodeWidthChar; +use zellij_utils::data::{HighlightStyle, RegexHighlight, Style}; use zellij_utils::errors::prelude::*; use std::{ @@ -28,7 +30,7 @@ pub const MAX_TITLE_STACK_SIZE: usize = 1000; use vte::{Params, Perform}; use zellij_utils::{consts::VERSION, shared::version_number}; -use crate::output::{CharacterChunk, OutputBuffer, SixelImageChunk}; +use crate::output::{CharacterChunk, HighlightSelection, OutputBuffer, SixelImageChunk}; use crate::panes::alacritty_functions::{parse_number, xparse_color}; use crate::panes::hyperlink_tracker::HyperlinkTracker; use crate::panes::link_handler::LinkHandler; @@ -349,6 +351,128 @@ fn utf8_mouse_coordinates(column: usize, line: isize) -> Vec<u8> { coordinates } +/// Find the canonical root row for a logical line group containing `row_idx`, +/// collect the non-canonical tail rows, and build the concatenated text with +/// a byte-offset boundary table. +/// +/// Returns `(canonical_idx, group_len, text, boundaries)` where `group_len` +/// is the total number of viewport rows in the group (1 + tail count). +/// Returns `None` if `row_idx` is out of bounds. +fn collect_and_build_logical_line( + viewport: &VecDeque<Row>, + row_idx: usize, +) -> Option<(usize, usize, String, Vec<(usize, usize)>)> { + if row_idx >= viewport.len() { + return None; + } + // Walk backward to find the canonical root. + let mut canonical = row_idx; + while canonical > 0 { + match viewport.get(canonical) { + Some(r) if !r.is_canonical => canonical -= 1, + _ => break, + } + } + let canonical_row = viewport.get(canonical)?; + // Collect non-canonical tail rows. + let mut tail_count = 0; + loop { + let tail_idx = canonical + tail_count + 1; + match viewport.get(tail_idx) { + Some(r) if !r.is_canonical => tail_count += 1, + _ => break, + } + } + let group_len = 1 + tail_count; + // Build concatenated text and boundary table. + let mut text = String::new(); + let mut boundaries: Vec<(usize, usize)> = Vec::with_capacity(group_len); + boundaries.push((canonical, 0)); + for ch in &canonical_row.columns { + text.push(ch.character); + } + for i in 0..tail_count { + let idx = canonical + 1 + i; + boundaries.push((idx, text.len())); + if let Some(row) = viewport.get(idx) { + for ch in &row.columns { + text.push(ch.character); + } + } + } + Some((canonical, group_len, text, boundaries)) +} + +/// Map a byte offset in the concatenated logical-line string back to +/// (viewport_row_idx, display_column). +/// +/// display_column is the sum of character.width() for all characters +/// before the target character in that row — NOT a char count or byte count. +/// This correctly handles wide characters (CJK, emoji). +/// +/// `boundaries` is the table produced by `collect_and_build_logical_line`. +/// `viewport` is `&self.viewport`. +fn byte_offset_to_display_col( + byte_offset: usize, + boundaries: &[(usize, usize)], + viewport: &VecDeque<Row>, +) -> Option<(usize, usize)> { + if boundaries.is_empty() { + return None; + } + // Find which row the byte offset falls in (last boundary whose byte_start <= offset). + let boundary_idx = boundaries + .partition_point(|&(_, byte_start)| byte_start <= byte_offset) + .saturating_sub(1); + let &(row_idx, row_byte_start) = boundaries.get(boundary_idx)?; + let intra_byte_offset = byte_offset - row_byte_start; + + // Count display columns up to (but not including) the character at intra_byte_offset. + let row = viewport.get(row_idx)?; + let mut display_col = 0usize; + let mut bytes_seen = 0usize; + for ch in &row.columns { + if bytes_seen >= intra_byte_offset { + break; + } + bytes_seen += ch.character.len_utf8(); + display_col += ch.character.width().unwrap_or(1); + } + Some((row_idx, display_col)) +} + +/// Convert a regex match into a `Selection` spanning the matched display region. +/// Returns `None` if the boundary table or viewport lookup fails. +fn match_to_selection( + mat: ®ex::Match, + boundaries: &[(usize, usize)], + viewport: &VecDeque<Row>, +) -> Option<(Selection, usize, usize, usize, usize)> { + let (start_row, start_col) = byte_offset_to_display_col(mat.start(), boundaries, viewport)?; + let (end_row, end_col) = byte_offset_to_display_col(mat.end(), boundaries, viewport)?; + let mut sel = Selection::default(); + sel.set_start_and_end_positions( + Position::new(start_row as i32, start_col as u16), + Position::new(end_row as i32, end_col as u16), + ); + Some((sel, start_row, start_col, end_row, end_col)) +} + +/// 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( + row: usize, + col: usize, + start_row: usize, + start_col: usize, + end_row: usize, + end_col: usize, +) -> bool { + let after_start = row > start_row || (row == start_row && col >= start_col); + let before_end = row < end_row || (row == end_row && col < end_col); + after_start && before_end +} + #[derive(Clone)] pub struct Grid { pub(crate) lines_above: VecDeque<Row>, @@ -406,6 +530,9 @@ pub struct Grid { hyperlink_tracker: HyperlinkTracker, pub pane_default_fg: Option<AnsiCode>, pub pane_default_bg: Option<AnsiCode>, + pub plugin_highlights: HashMap<u32, Vec<(String, CompiledHighlight)>>, + // key: plugin_id (u32), inner vec: (pattern, compiled) pairs + pub hover_position: Option<Position>, // pane-relative cursor cell; None when outside pane } impl Grid { @@ -431,6 +558,26 @@ fn ansi_code_to_color_string(code: AnsiCode) -> Option<String> { } } +/// A compiled highlight entry for one plugin/pattern combination. +#[derive(Clone)] +pub struct CompiledHighlight { + pub regex: regex::Regex, + pub fg: Option<AnsiCode>, + pub bg: Option<AnsiCode>, + pub context: BTreeMap<String, String>, + pub on_hover: bool, + pub bold: bool, + pub italic: bool, + pub underline: bool, +} + +impl CompiledHighlight { + /// Whether this highlight would produce any visible styling change. + pub fn has_visual_effect(&self) -> bool { + self.bg.is_some() || self.fg.is_some() || self.bold || self.italic || self.underline + } +} + const CLICK_TIME_THRESHOLD: u128 = 400; // Doherty Threshold #[derive(Clone, Debug, Default)] @@ -483,7 +630,7 @@ impl Default for MouseMode { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub enum MouseTracking { Off, Normal, @@ -544,6 +691,49 @@ impl Debug for Grid { } } +fn resolve_highlight_colors( + style_decl: &HighlightStyle, + style: &Style, +) -> (Option<AnsiCode>, Option<AnsiCode>) { + let palette_to_ansi = |c: PaletteColor| -> AnsiCode { + match c { + PaletteColor::Rgb(rgb) => AnsiCode::RgbCode(rgb), + PaletteColor::EightBit(i) => AnsiCode::ColorIndex(i), + } + }; + let tu = &style.colors.text_unselected; + let emphasis = [tu.emphasis_0, tu.emphasis_1, tu.emphasis_2, tu.emphasis_3]; + match style_decl { + HighlightStyle::None => (None, None), + HighlightStyle::Emphasis0 => (Some(palette_to_ansi(emphasis[0])), None), + HighlightStyle::Emphasis1 => (Some(palette_to_ansi(emphasis[1])), None), + HighlightStyle::Emphasis2 => (Some(palette_to_ansi(emphasis[2])), None), + HighlightStyle::Emphasis3 => (Some(palette_to_ansi(emphasis[3])), None), + HighlightStyle::BackgroundEmphasis0 => ( + Some(palette_to_ansi(tu.background)), + Some(palette_to_ansi(emphasis[0])), + ), + HighlightStyle::BackgroundEmphasis1 => ( + Some(palette_to_ansi(tu.background)), + Some(palette_to_ansi(emphasis[1])), + ), + HighlightStyle::BackgroundEmphasis2 => ( + Some(palette_to_ansi(tu.background)), + Some(palette_to_ansi(emphasis[2])), + ), + HighlightStyle::BackgroundEmphasis3 => ( + Some(palette_to_ansi(tu.background)), + Some(palette_to_ansi(emphasis[3])), + ), + HighlightStyle::CustomRgb { fg, bg } => { + (fg.map(AnsiCode::RgbCode), bg.map(AnsiCode::RgbCode)) + }, + HighlightStyle::CustomIndex { fg, bg } => { + (fg.map(AnsiCode::ColorIndex), bg.map(AnsiCode::ColorIndex)) + }, + } +} + impl Grid { pub fn new( rows: usize, @@ -621,6 +811,8 @@ impl Grid { hyperlink_tracker: HyperlinkTracker::new(), pane_default_fg: None, pane_default_bg: None, + plugin_highlights: HashMap::new(), + hover_position: None, } } pub fn render_full_viewport(&mut self) { @@ -1199,6 +1391,9 @@ impl Grid { let mut raw_vte_output = String::new(); let (mut character_chunks, sixel_image_chunks) = self.read_changes(content_x, content_y); + + let plugin_highlight_selections = self.compute_plugin_highlight_selections(); + for character_chunk in character_chunks.iter_mut() { character_chunk.add_changed_colors(self.changed_colors); character_chunk.add_pane_defaults(self.pane_default_fg, self.pane_default_bg); @@ -1216,9 +1411,14 @@ impl Grid { }; character_chunk.add_selection_and_colors( - self.selection, - background_color, - Some(foreground_color), + HighlightSelection { + selection: self.selection, + bg: Some(background_color), + fg: Some(foreground_color), + bold: false, + italic: false, + underline: false, + }, content_x, content_y, ); @@ -1246,15 +1446,29 @@ impl Grid { PaletteColor::EightBit(col) => AnsiCode::ColorIndex(col), }; character_chunk.add_selection_and_colors( - *res, - background_color, - Some(foreground_color), + HighlightSelection { + selection: *res, + bg: Some(background_color), + fg: Some(foreground_color), + bold: false, + italic: false, + underline: false, + }, content_x, content_y, ); } } } + // Apply pre-computed plugin highlight selections to this chunk. + for hs in &plugin_highlight_selections { + if hs + .selection + .contains_row(character_chunk.y.saturating_sub(content_y)) + { + character_chunk.add_selection_and_colors(*hs, content_x, content_y); + } + } } return Ok(Some(( character_chunks, @@ -1851,6 +2065,223 @@ impl Grid { fn set_preceding_character(&mut self, terminal_character: TerminalCharacter) { self.preceding_char = Some(terminal_character); } + /// Called by the server-side handler for SetPaneRegexHighlights. + /// Upserts highlights keyed by pattern string for the given plugin. + pub fn set_plugin_regex_highlights( + &mut self, + plugin_id: u32, + highlights: Vec<RegexHighlight>, + style: &Style, + ) { + let slot = self + .plugin_highlights + .entry(plugin_id) + .or_insert_with(Vec::new); + for h in highlights { + let (fg, bg) = resolve_highlight_colors(&h.style, style); + if let Ok(regex) = regex::Regex::new(&h.pattern) { + // Upsert: replace existing entry with same pattern and on_hover flag, or push new + let on_hover = h.on_hover; + if let Some(existing) = slot + .iter_mut() + .find(|(p, c)| p == &h.pattern && c.on_hover == on_hover) + { + existing.1 = CompiledHighlight { + regex, + fg, + bg, + context: h.context, + on_hover: h.on_hover, + bold: h.bold, + italic: h.italic, + underline: h.underline, + }; + } else { + slot.push(( + h.pattern, + CompiledHighlight { + regex, + fg, + bg, + context: h.context, + on_hover: h.on_hover, + bold: h.bold, + italic: h.italic, + underline: h.underline, + }, + )); + } + } else { + log::warn!( + "Plugin {} supplied invalid regex: {:?}", + plugin_id, + h.pattern + ); + } + } + self.output_buffer.update_all_lines(); + } + + /// Called by the server-side handler for ClearPaneHighlights. + pub fn clear_plugin_highlights(&mut self, plugin_id: u32) { + if self.plugin_highlights.remove(&plugin_id).is_some() { + self.output_buffer.update_all_lines(); + } + } + + /// Returns (plugin_id, pattern, matched_string, context) if the given + /// pane-relative position falls inside any plugin highlight match, or None. + /// Uses logical-line grouping so multi-line (wrapped) matches are detected. + pub fn plugin_highlight_at( + &self, + position: &Position, + ) -> Option<(u32, String, String, BTreeMap<String, String>)> { + let click_row = position.line.0 as usize; + let click_col = position.column.0 as usize; + + let (_canonical, _group_len, logical_text, boundaries) = + collect_and_build_logical_line(&self.viewport, click_row)?; + + for (plugin_id, pattern_map) in &self.plugin_highlights { + for (pattern, compiled) in pattern_map { + for mat in compiled.regex.find_iter(&logical_text) { + if let Some((_sel, start_row, start_col, end_row, end_col)) = + match_to_selection(&mat, &boundaries, &self.viewport) + { + if position_in_span( + click_row, click_col, start_row, start_col, end_row, end_col, + ) { + return Some(( + *plugin_id, + pattern.clone(), + mat.as_str().to_string(), + compiled.context.clone(), + )); + } + } + } + } + } + None + } + + pub fn set_hover_position(&mut self, new_pos: Option<Position>) { + if self.hover_position == new_pos { + return; + } + + // Mark the canonical group containing the old hover row dirty. + if let Some(old_pos) = self.hover_position { + self.mark_logical_line_dirty(old_pos.line.0 as usize); + } + // Mark the canonical group containing the new hover row dirty. + if let Some(new_pos_inner) = new_pos { + self.mark_logical_line_dirty(new_pos_inner.line.0 as usize); + } + + self.hover_position = new_pos; + } + + /// Mark all physical rows belonging to the logical line group that contains + /// `row_idx` as dirty in the output buffer. + fn mark_logical_line_dirty(&mut self, row_idx: usize) { + if let Some((canonical, group_len, _, _)) = + collect_and_build_logical_line(&self.viewport, row_idx) + { + for r in canonical..canonical + group_len { + self.output_buffer.update_line(r); + } + } + } + + /// Pre-compute plugin highlight selections across all logical line groups in + /// the viewport. Hover highlights are emitted first so they take priority + /// when the cursor overlaps a match; non-hover highlights follow. + fn compute_plugin_highlight_selections(&self) -> Vec<HighlightSelection> { + if self.plugin_highlights.is_empty() { + return vec![]; + } + let mut selections = Vec::new(); + let viewport_len = self.viewport.len(); + let mut ridx = 0; + while ridx < viewport_len { + let (_canonical, group_len, logical_text, boundaries) = + match collect_and_build_logical_line(&self.viewport, ridx) { + Some(v) => v, + None => break, + }; + + // Hover highlights are pushed first so that `.find()` in + // `adjust_styles_for_possible_selection` returns the hover + // style when the cursor overlaps the match. They are suppressed + // when mouse tracking is active (events pass through to the app) + // and on unfocused panes (hover_position is not set for those). + if self.mouse_tracking == MouseTracking::Off { + if let Some(hover_pos) = self.hover_position { + let hover_row = hover_pos.line.0 as usize; + let group_end_row = ridx + group_len - 1; + if hover_row >= ridx && hover_row <= group_end_row { + let hover_col = hover_pos.column.0; + for (_plugin_id, pattern_map) in &self.plugin_highlights { + for (_pattern, compiled) in pattern_map { + if !compiled.on_hover || !compiled.has_visual_effect() { + continue; + } + for mat in compiled.regex.find_iter(&logical_text) { + if let Some((sel, start_row, start_col, end_row, end_col)) = + match_to_selection(&mat, &boundaries, &self.viewport) + { + if position_in_span( + hover_row, hover_col, start_row, start_col, end_row, + end_col, + ) { + selections.push(HighlightSelection { + selection: sel, + bg: compiled.bg, + fg: compiled.fg, + bold: compiled.bold, + italic: compiled.italic, + underline: compiled.underline, + }); + break; // only one match per pattern per hover + } + } + } + } + } + } + } + } + + // Non-hover highlights are pushed after hover so that hover + // takes precedence for overlapping regions. + for (_plugin_id, pattern_map) in &self.plugin_highlights { + for (_pattern, compiled) in pattern_map { + if compiled.on_hover || !compiled.has_visual_effect() { + continue; + } + for mat in compiled.regex.find_iter(&logical_text) { + if let Some((sel, _, _, _, _)) = + match_to_selection(&mat, &boundaries, &self.viewport) + { + selections.push(HighlightSelection { + selection: sel, + bg: compiled.bg, + fg: compiled.fg, + bold: compiled.bold, + italic: compiled.italic, + underline: compiled.underline, + }); + } + } + } + } + + ridx += group_len; // advance past this logical line group + } + selections + } + pub fn start_selection(&mut self, start: &Position) { let old_selection = self.selection; self.click.record_click(*start); diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs index a3f73a72..f0fd3949 100644 --- a/zellij-server/src/panes/terminal_pane.rs +++ b/zellij-server/src/panes/terminal_pane.rs @@ -22,7 +22,7 @@ use zellij_utils::pane_size::Offset; use zellij_utils::{ data::{ BareKey, InputMode, KeyWithModifier, Palette, PaletteColor, PaneId as ZellijUtilsPaneId, - Style, Styling, + RegexHighlight, Style, Styling, }, errors::prelude::*, input::layout::Run, @@ -958,6 +958,38 @@ impl Pane for TerminalPane { } } } + fn set_plugin_regex_highlights( + &mut self, + plugin_id: u32, + highlights: Vec<RegexHighlight>, + style: &Style, + ) { + self.grid + .set_plugin_regex_highlights(plugin_id, highlights, style); + self.set_should_render(true); + } + fn clear_plugin_highlights(&mut self, plugin_id: u32) { + self.grid.clear_plugin_highlights(plugin_id); + self.set_should_render(true); + } + fn set_hover_position(&mut self, position: Option<Position>) { + self.grid.set_hover_position(position); + self.set_should_render(true); + } + fn plugin_highlight_at( + &self, + position: &Position, + ) -> Option<( + u32, + String, + String, + std::collections::BTreeMap<String, String>, + )> { + self.grid.plugin_highlight_at(position) + } + fn terminal_emulator_wants_mouse(&self) -> bool { + self.grid.mouse_tracking != crate::panes::grid::MouseTracking::Off + } } impl TerminalPane { diff --git a/zellij-server/src/panes/unit/grid_tests.rs b/zellij-server/src/panes/unit/grid_tests.rs index e942caa8..7010cefb 100644 --- a/zellij-server/src/panes/unit/grid_tests.rs +++ b/zellij-server/src/panes/unit/grid_tests.rs @@ -4556,3 +4556,302 @@ fn osc_11_set_bg_produces_ansi_in_render_output() { ); } } + +// ===================================================================== +// Plugin Highlight Engine Tests +// ===================================================================== + +use crate::panes::grid::MouseTracking; +use crate::panes::terminal_character::AnsiCode; +use std::collections::BTreeMap; +use zellij_utils::data::{HighlightStyle, RegexHighlight}; + +fn create_highlight( + pattern: &str, + on_hover: bool, + bold: bool, + italic: bool, + underline: bool, +) -> RegexHighlight { + RegexHighlight { + pattern: pattern.to_string(), + style: HighlightStyle::Emphasis0, + context: BTreeMap::new(), + on_hover, + bold, + italic, + underline, + } +} + +#[test] +fn set_plugin_regex_highlights_basic_match() { + let mut grid = create_grid_with_content("hello foo bar\n"); + let highlights = vec![RegexHighlight { + pattern: "foo".into(), + style: HighlightStyle::Emphasis0, + context: BTreeMap::new(), + on_hover: false, + bold: false, + italic: false, + underline: true, + }]; + grid.set_plugin_regex_highlights(1, highlights, &Style::default()); + let slot = grid.plugin_highlights.get(&1); + assert!(slot.is_some()); + let entries = slot.unwrap(); + assert_eq!(entries.len(), 1); + assert!(entries[0].1.regex.is_match("foo")); +} + +#[test] +fn set_plugin_regex_highlights_no_match() { + let mut grid = create_grid_with_content("hello world bar\n"); + let highlights = vec![create_highlight("xyz123", false, false, false, false)]; + grid.set_plugin_regex_highlights(1, highlights, &Style::default()); + + // No position in the viewport should match + for col in 0..15 { + assert!(grid.plugin_highlight_at(&Position::new(0, col)).is_none()); + } +} + +#[test] +fn clear_plugin_highlights_removes_highlights() { + let mut grid = create_grid_with_content("hello foo bar\n"); + let highlights = vec![create_highlight("foo", false, false, false, true)]; + grid.set_plugin_regex_highlights(1, highlights, &Style::default()); + assert!(grid.plugin_highlights.get(&1).is_some()); + + grid.clear_plugin_highlights(1); + assert!(grid.plugin_highlights.get(&1).is_none()); +} + +#[test] +fn multiple_plugins_highlights_independent() { + let mut grid = create_grid_with_content("aaa bbb ccc\n"); + let h1 = vec![create_highlight("aaa", false, false, false, false)]; + let h2 = vec![create_highlight("bbb", false, false, false, false)]; + grid.set_plugin_regex_highlights(1, h1, &Style::default()); + grid.set_plugin_regex_highlights(2, h2, &Style::default()); + + assert!(grid.plugin_highlights.get(&1).is_some()); + assert!(grid.plugin_highlights.get(&2).is_some()); + + grid.clear_plugin_highlights(1); + assert!(grid.plugin_highlights.get(&1).is_none()); + assert!(grid.plugin_highlights.get(&2).is_some()); +} + +#[test] +fn upsert_replaces_same_pattern() { + let mut grid = create_grid_with_content("foo bar\n"); + let h1 = vec![RegexHighlight { + pattern: "foo".into(), + style: HighlightStyle::Emphasis0, + context: BTreeMap::new(), + on_hover: false, + bold: false, + italic: false, + underline: true, + }]; + grid.set_plugin_regex_highlights(1, h1, &Style::default()); + + let h2 = vec![RegexHighlight { + pattern: "foo".into(), + style: HighlightStyle::Emphasis0, + context: BTreeMap::new(), + on_hover: false, + bold: false, + italic: false, + underline: false, + }]; + grid.set_plugin_regex_highlights(1, h2, &Style::default()); + + let entries = grid.plugin_highlights.get(&1).unwrap(); + assert_eq!(entries.len(), 1); + assert!(!entries[0].1.underline); +} + +#[test] +fn invalid_regex_does_not_crash() { + let mut grid = create_grid_with_content("hello\n"); + let highlights = vec![create_highlight("[invalid", false, false, false, false)]; + grid.set_plugin_regex_highlights(1, highlights, &Style::default()); + // Invalid regex should be skipped + let slot = grid.plugin_highlights.get(&1); + match slot { + None => {}, // acceptable + Some(entries) => assert_eq!(entries.len(), 0), + } +} + +#[test] +fn plugin_highlight_at_returns_match() { + let mut grid = create_grid_with_content("hello foo bar\n"); + let mut context = BTreeMap::new(); + context.insert("key".to_string(), "value".to_string()); + let highlights = vec![RegexHighlight { + pattern: "foo".into(), + style: HighlightStyle::Emphasis0, + context: context.clone(), + on_hover: false, + bold: false, + italic: false, + underline: true, + }]; + grid.set_plugin_regex_highlights(1, highlights, &Style::default()); + + // "foo" starts at column 6 in "hello foo bar" + let result = grid.plugin_highlight_at(&Position::new(0, 6)); + assert!(result.is_some()); + let (plugin_id, pattern, matched_string, ctx) = result.unwrap(); + assert_eq!(plugin_id, 1); + assert_eq!(pattern, "foo"); + assert_eq!(matched_string, "foo"); + assert_eq!(ctx.get("key").unwrap(), "value"); +} + +#[test] +fn plugin_highlight_at_returns_none_on_miss() { + let mut grid = create_grid_with_content("hello foo bar\n"); + let highlights = vec![create_highlight("foo", false, false, false, true)]; + grid.set_plugin_regex_highlights(1, highlights, &Style::default()); + + // Position 0 is in "hello", not "foo" + let result = grid.plugin_highlight_at(&Position::new(0, 0)); + assert!(result.is_none()); +} + +#[test] +fn plugin_highlight_at_wrapped_line() { + // Create a narrow grid (10 cols) so that a long string wraps + 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, + Style::default(), + false, + true, + true, + true, + false, + ); + // Feed a long string that wraps: "abcdefghij" fills row 0, "klmnopqrst" fills row 1 + let content = "abcdefghijklmnopqrst"; + let mut vte_parser = vte::Parser::new(); + for &byte in content.as_bytes() { + vte_parser.advance(&mut grid, byte); + } + + // Set a highlight for "jklm" which spans the wrap boundary + let highlights = vec![create_highlight("jklm", false, false, false, true)]; + grid.set_plugin_regex_highlights(1, highlights, &Style::default()); + + // "jklm" spans row 0 col 9 through row 1 col 3 + // Position in the wrapped portion (row 1, col 1 = 'k') + let result = grid.plugin_highlight_at(&Position::new(1, 1)); + assert!(result.is_some()); + let (plugin_id, _pattern, matched_string, _ctx) = result.unwrap(); + assert_eq!(plugin_id, 1); + assert_eq!(matched_string, "jklm"); +} + +#[test] +fn hover_position_triggers_on_hover_highlight() { + let mut grid = create_grid_with_content("hello link_text bar\n"); + let highlights = vec![create_highlight("link_text", true, false, false, true)]; + grid.set_plugin_regex_highlights(1, highlights, &Style::default()); + + // Set hover position inside "link_text" (starts at col 6) + grid.set_hover_position(Some(Position::new(0, 8))); + assert!(grid.hover_position.is_some()); + assert_eq!(grid.hover_position.unwrap(), Position::new(0, 8)); + + // The on_hover entry should exist in plugin_highlights + let entries = grid.plugin_highlights.get(&1).unwrap(); + assert_eq!(entries.len(), 1); + assert!(entries[0].1.on_hover); +} + +#[test] +fn hover_suppressed_when_mouse_tracking_on() { + let mut grid = create_grid_with_content("hello link_text bar\n"); + let highlights = vec![create_highlight("link_text", true, false, false, true)]; + grid.set_plugin_regex_highlights(1, highlights, &Style::default()); + + // Enable mouse tracking — the render path should skip hover highlights + grid.mouse_tracking = MouseTracking::Normal; + grid.set_hover_position(Some(Position::new(0, 8))); + + // The hover position is set regardless (the guard is in the render path), + // but we verify that mouse_tracking is non-Off + assert!(grid.hover_position.is_some()); + assert_ne!(grid.mouse_tracking, MouseTracking::Off); +} + +#[test] +fn wide_char_display_column_mapping() { + // CJK characters: "你好" = 2 chars, each 2 display cols wide, so "world" starts at display col 4 + let mut grid = create_grid_with_content("你好world\n"); + let highlights = vec![create_highlight("world", false, false, false, true)]; + grid.set_plugin_regex_highlights(1, highlights, &Style::default()); + + // "你好" occupies display cols 0-3, "world" starts at display col 4 + let result = grid.plugin_highlight_at(&Position::new(0, 4)); + assert!(result.is_some()); + let (plugin_id, _pattern, matched_string, _ctx) = result.unwrap(); + assert_eq!(plugin_id, 1); + assert_eq!(matched_string, "world"); + + // Position 2 should be inside "你好", not "world" + let result_miss = grid.plugin_highlight_at(&Position::new(0, 2)); + assert!(result_miss.is_none()); +} + +#[test] +fn highlight_style_variants_resolve_colors() { + use super::resolve_highlight_colors; + + let style = Style::default(); + + // HighlightStyle::None returns (None, None) + let (fg, bg): (Option<AnsiCode>, Option<AnsiCode>) = + resolve_highlight_colors(&HighlightStyle::None, &style); + assert!(fg.is_none()); + assert!(bg.is_none()); + + // HighlightStyle::CustomRgb with fg only + let (fg, bg): (Option<AnsiCode>, Option<AnsiCode>) = resolve_highlight_colors( + &HighlightStyle::CustomRgb { + fg: Some((255, 0, 0)), + bg: None, + }, + &style, + ); + assert_eq!(fg, Some(AnsiCode::RgbCode((255, 0, 0)))); + assert!(bg.is_none()); + + // HighlightStyle::CustomIndex with bg only + let (fg, bg): (Option<AnsiCode>, Option<AnsiCode>) = resolve_highlight_colors( + &HighlightStyle::CustomIndex { + fg: None, + bg: Some(42), + }, + &style, + ); + assert!(fg.is_none()); + assert_eq!(bg, Some(AnsiCode::ColorIndex(42))); + + // HighlightStyle::Emphasis0 should return a foreground color from the palette + let (fg, bg): (Option<AnsiCode>, Option<AnsiCode>) = + resolve_highlight_colors(&HighlightStyle::Emphasis0, &style); + assert!(fg.is_some()); + assert!(bg.is_none()); +} diff --git a/zellij-server/src/plugins/mod.rs b/zellij-server/src/plugins/mod.rs index d26fb7d2..4bd4a82e 100644 --- a/zellij-server/src/plugins/mod.rs +++ b/zellij-server/src/plugins/mod.rs @@ -212,6 +212,14 @@ pub enum PluginInstruction { response_channel: crossbeam::channel::Sender<Option<u64>>, }, DetectPluginConfigChanges(PluginAliases), + HighlightClicked { + plugin_id: u32, + client_id: ClientId, + pane_id: PaneId, + pattern: String, + matched_string: String, + context: BTreeMap<String, String>, + }, Exit, } @@ -275,6 +283,7 @@ impl From<&PluginInstruction> for PluginContext { PluginInstruction::DetectPluginConfigChanges(..) => { PluginContext::DetectPluginConfigChanges }, + PluginInstruction::HighlightClicked { .. } => PluginContext::HighlightClicked, } } } @@ -1232,6 +1241,23 @@ pub(crate) fn plugin_thread_main( wasm_bridge .detect_and_notify_plugin_config_changes(&new_plugins, shutdown_send.clone())?; }, + PluginInstruction::HighlightClicked { + plugin_id, + client_id, + pane_id, + pattern, + matched_string, + context, + } => { + let event = Event::HighlightClicked { + pane_id: pane_id.into(), + pattern, + matched_string, + context, + }; + let updates = vec![(Some(plugin_id), Some(client_id), event)]; + wasm_bridge.update_plugins(updates, shutdown_send.clone())?; + }, PluginInstruction::Exit => { break; }, diff --git a/zellij-server/src/plugins/unit/plugin_tests.rs b/zellij-server/src/plugins/unit/plugin_tests.rs index a5f2e420..4e2ad047 100644 --- a/zellij-server/src/plugins/unit/plugin_tests.rs +++ b/zellij-server/src/plugins/unit/plugin_tests.rs @@ -12323,3 +12323,274 @@ pub fn get_session_environment_variables_plugin_command() { }); assert_snapshot!(format!("{:#?}", plugin_bytes_event)); } + +// ===================================================================== +// Plugin Highlight API Integration Tests +// ===================================================================== + +use crate::panes::PaneId; +use zellij_utils::data::{HighlightStyle, RegexHighlight}; + +#[test] +#[ignore] +pub fn set_pane_regex_highlights_via_plugin() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = create_plugin_thread(None, None); + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread_struct_variant!( + received_screen_instructions, + ScreenInstruction::SetPluginRegexHighlights, + screen_receiver, + 1, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + client_id + ); + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + false, // close_replaced_pane + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Super+g key event to trigger set_pane_regex_highlights in the fixture plugin + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key(KeyWithModifier::new(BareKey::Char('g')).with_super_modifier()), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let highlight_instruction = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::SetPluginRegexHighlights { + pane_id, + plugin_id, + highlights, + } = i + { + Some((*pane_id, *plugin_id, highlights.clone())) + } else { + None + } + }); + + assert!( + highlight_instruction.is_some(), + "Expected SetPluginRegexHighlights instruction" + ); + let (pane_id, _plugin_id, highlights) = highlight_instruction.unwrap(); + assert_eq!(pane_id, PaneId::Terminal(1)); + assert_eq!(highlights.len(), 1); + assert_eq!(highlights[0].pattern, "test_pattern"); + assert!(highlights[0].italic); + assert!(highlights[0].underline); +} + +#[test] +#[ignore] +pub fn clear_pane_highlights_via_plugin() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = create_plugin_thread(None, None); + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread_struct_variant!( + received_screen_instructions, + ScreenInstruction::ClearPluginHighlights, + screen_receiver, + 1, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + client_id + ); + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + false, // close_replaced_pane + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Super+h key event to trigger clear_pane_highlights in the fixture plugin + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key(KeyWithModifier::new(BareKey::Char('h')).with_super_modifier()), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let clear_instruction = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::ClearPluginHighlights { pane_id, plugin_id } = i { + Some((*pane_id, *plugin_id)) + } else { + None + } + }); + + assert!( + clear_instruction.is_some(), + "Expected ClearPluginHighlights instruction" + ); + let (pane_id, _plugin_id) = clear_instruction.unwrap(); + assert_eq!(pane_id, PaneId::Terminal(1)); +} + +#[test] +#[ignore] +pub fn highlight_clicked_event_delivered_to_plugin() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = create_plugin_thread(None, None); + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + client_id + ); + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + false, // close_replaced_pane + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send a HighlightClicked event directly to the plugin + let _ = plugin_thread_sender.send(PluginInstruction::HighlightClicked { + plugin_id: 0, + client_id, + pane_id: PaneId::Terminal(1), + pattern: "test".into(), + matched_string: "test_match".into(), + context: BTreeMap::new(), + }); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("HighlightClicked") { + return Some(plugin_bytes); + } + } + } + None + }); + assert!( + plugin_bytes_event.is_some(), + "Expected PluginBytes containing 'HighlightClicked'" + ); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__highlight_clicked_event_delivered_to_plugin.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__highlight_clicked_event_delivered_to_plugin.snap new file mode 100644 index 00000000..f331b82a --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__highlight_clicked_event_delivered_to_plugin.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Rows: 20, Cols: 121, Received events: [HighlightClicked { pane_id: Terminal(1), pattern: \"test\", matched_string: \"test_match\", context: {} }]\n\r", +) diff --git a/zellij-server/src/plugins/wasm_bridge.rs b/zellij-server/src/plugins/wasm_bridge.rs index d82705ef..1c928eb1 100644 --- a/zellij-server/src/plugins/wasm_bridge.rs +++ b/zellij-server/src/plugins/wasm_bridge.rs @@ -514,6 +514,10 @@ impl WasmBridge { .senders .send_to_screen(ScreenInstruction::ClearKeyPressesIntercepts(client_id)); } + // Clear any regex highlights this plugin registered across all panes + let _ = self + .senders + .send_to_screen(ScreenInstruction::ClearAllPluginHighlights(plugin_id)); // Send worker exit messages for (_worker_name, worker_sender) in workers { @@ -2010,6 +2014,7 @@ fn check_event_permission( | Event::CwdChanged(..) | Event::AvailableLayoutInfo(..) | Event::PluginConfigurationChanged(..) + | Event::HighlightClicked { .. } | Event::InputReceived => PermissionType::ReadApplicationState, Event::WebServerStatus(..) => PermissionType::StartWebServer, Event::PaneRenderReport(..) => PermissionType::ReadPaneContents, diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs index dd323977..d48a80b9 100644 --- a/zellij-server/src/plugins/zellij_exports.rs +++ b/zellij-server/src/plugins/zellij_exports.rs @@ -37,7 +37,7 @@ use zellij_utils::data::{ OpenTerminalInPlaceOfPluginResponse, OpenTerminalInPlaceResponse, OpenTerminalNearPluginResponse, OpenTerminalPaneInPlaceOfPaneIdResponse, OpenTerminalResponse, OriginatingPlugin, PaneScrollbackResponse, PermissionStatus, PermissionType, PluginPermission, - RenameLayoutResponse, SaveLayoutResponse, TabMetadata, + RegexHighlight, RenameLayoutResponse, SaveLayoutResponse, TabMetadata, }; use zellij_utils::home::default_layout_dir; use zellij_utils::input::permission::PermissionCache; @@ -739,6 +739,12 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) { ), PluginCommand::ShowFloatingPanes { tab_id } => show_floating_panes(env, tab_id), PluginCommand::HideFloatingPanes { tab_id } => hide_floating_panes(env, tab_id), + PluginCommand::SetPaneRegexHighlights(pane_id, highlights) => { + set_pane_regex_highlights(env, pane_id, highlights) + }, + PluginCommand::ClearPaneHighlights(pane_id) => { + clear_pane_highlights(env, pane_id) + }, }, (PermissionStatus::Denied, permission) => { log::error!( @@ -3387,6 +3393,35 @@ fn hide_floating_panes(env: &PluginEnv, tab_id: Option<usize>) { let _ = wasi_write_object(env, &response.encode_to_vec()); } +fn set_pane_regex_highlights( + env: &PluginEnv, + pane_id: zellij_utils::data::PaneId, + highlights: Vec<RegexHighlight>, +) { + let err_context = || "failed to set pane regex highlights".to_string(); + let pane_id: PaneId = pane_id.into(); + env.senders + .send_to_screen(ScreenInstruction::SetPluginRegexHighlights { + pane_id, + plugin_id: env.plugin_id, + highlights, + }) + .with_context(err_context) + .non_fatal(); +} + +fn clear_pane_highlights(env: &PluginEnv, pane_id: zellij_utils::data::PaneId) { + let err_context = || "failed to clear pane highlights".to_string(); + let pane_id: PaneId = pane_id.into(); + env.senders + .send_to_screen(ScreenInstruction::ClearPluginHighlights { + pane_id, + plugin_id: env.plugin_id, + }) + .with_context(err_context) + .non_fatal(); +} + fn current_session_last_saved_time(env: &PluginEnv) { use zellij_utils::plugin_api::plugin_command::ProtobufCurrentSessionLastSavedTimeResponse; @@ -5086,7 +5121,9 @@ fn check_command_permission( | PluginCommand::RenameLayout { .. } | PluginCommand::EditLayout { .. } | PluginCommand::ShowFloatingPanes { .. } - | PluginCommand::HideFloatingPanes { .. } => PermissionType::ChangeApplicationState, + | PluginCommand::HideFloatingPanes { .. } + | PluginCommand::SetPaneRegexHighlights(..) + | PluginCommand::ClearPaneHighlights(..) => PermissionType::ChangeApplicationState, PluginCommand::UnblockCliPipeInput(..) | PluginCommand::BlockCliPipeInput(..) | PluginCommand::CliPipeOutput(..) => PermissionType::ReadCliPipes, diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index 6a18042d..b84c2058 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -43,7 +43,8 @@ use zellij_utils::data::{ CommandOrPlugin, Direction, FloatingPaneCoordinates, GetFocusedPaneInfoResponse, KeyWithModifier, LayoutInfo, LayoutWithError, ListPanesResponse, ListTabsResponse, NewPanePlacement, PaneContents, PaneInfo, PaneListEntry, PaneManifest, PaneScrollbackResponse, - PluginPermission, Resize, ResizeStrategy, SessionInfo, Styling, TabInfo, WebSharing, + PluginPermission, RegexHighlight, Resize, ResizeStrategy, SessionInfo, Styling, TabInfo, + WebSharing, }; use zellij_utils::errors::prelude::*; use zellij_utils::input::command::RunCommand; @@ -670,6 +671,16 @@ pub enum ScreenInstruction { WatcherTerminalResize(ClientId, Size), ClearMouseHelpText(ClientId), UpdateAvailableLayouts(Vec<LayoutInfo>, Vec<LayoutWithError>), + SetPluginRegexHighlights { + pane_id: PaneId, + plugin_id: u32, + highlights: Vec<RegexHighlight>, + }, + ClearPluginHighlights { + pane_id: PaneId, + plugin_id: u32, + }, + ClearAllPluginHighlights(u32), // plugin_id — clears across all panes } impl From<&ScreenInstruction> for ScreenContext { @@ -933,6 +944,11 @@ impl From<&ScreenInstruction> for ScreenContext { ScreenInstruction::WatcherTerminalResize(..) => ScreenContext::WatcherTerminalResize, ScreenInstruction::ClearMouseHelpText(..) => ScreenContext::ClearMouseHelpText, ScreenInstruction::UpdateAvailableLayouts(..) => ScreenContext::UpdateAvailableLayouts, + ScreenInstruction::SetPluginRegexHighlights { .. } => { + ScreenContext::SetPluginRegexHighlights + }, + ScreenInstruction::ClearPluginHighlights { .. } => ScreenContext::ClearPluginHighlights, + ScreenInstruction::ClearAllPluginHighlights(..) => ScreenContext::ClearPluginHighlights, } } } @@ -7645,6 +7661,40 @@ pub(crate) fn screen_thread_main( screen.render(None)?; } }, + ScreenInstruction::SetPluginRegexHighlights { + pane_id, + plugin_id, + highlights, + } => { + let style = screen.style.clone(); + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.set_plugin_regex_highlights_for_pane( + pane_id, plugin_id, highlights, &style, + ); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::ClearPluginHighlights { pane_id, plugin_id } => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.clear_plugin_highlights_for_pane(pane_id, plugin_id); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::ClearAllPluginHighlights(plugin_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + tab.clear_all_plugin_highlights(plugin_id); + } + screen.render(None)?; + }, } } Ok(()) diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index 49b232a3..861a3103 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -16,7 +16,7 @@ use uuid::Uuid; use zellij_utils::data::PaneContents; use zellij_utils::data::{ Direction, KeyWithModifier, NewPanePlacement, PaneInfo, PermissionStatus, PermissionType, - PluginPermission, ResizeStrategy, WebSharing, + PluginPermission, RegexHighlight, ResizeStrategy, Style, WebSharing, }; use zellij_utils::errors::prelude::*; use zellij_utils::input::command::RunCommand; @@ -55,9 +55,7 @@ use std::{ str, }; use zellij_utils::{ - data::{ - Event, FloatingPaneCoordinates, InputMode, ModeInfo, Palette, PaletteColor, Style, Styling, - }, + data::{Event, FloatingPaneCoordinates, InputMode, ModeInfo, Palette, PaletteColor, Styling}, input::{ command::TerminalAction, layout::{ @@ -608,6 +606,24 @@ pub trait Pane { _get_full_scrollback: bool, ) -> PaneContents; fn update_exit_status(&mut self, _exit_status: i32) {} + fn set_plugin_regex_highlights( + &mut self, + _plugin_id: u32, + _highlights: Vec<RegexHighlight>, + _style: &Style, + ) { + } + fn clear_plugin_highlights(&mut self, _plugin_id: u32) {} + fn set_hover_position(&mut self, _position: Option<Position>) {} + fn plugin_highlight_at( + &self, + _position: &Position, + ) -> Option<(u32, String, String, BTreeMap<String, String>)> { + None + } + fn terminal_emulator_wants_mouse(&self) -> bool { + false + } } #[derive(Clone, Debug)] @@ -4726,6 +4742,32 @@ impl Tab { pane.clear_pane_frame_color_override(client_id); } } + pub fn set_plugin_regex_highlights_for_pane( + &mut self, + pane_id: PaneId, + plugin_id: u32, + highlights: Vec<RegexHighlight>, + style: &Style, + ) { + if let Some(pane) = self.get_pane_with_id_mut(pane_id) { + pane.set_plugin_regex_highlights(plugin_id, highlights, style); + } + } + + pub fn clear_all_plugin_highlights(&mut self, plugin_id: u32) { + for pane_id in self.get_all_pane_ids() { + if let Some(pane) = self.get_pane_with_id_mut(pane_id) { + pane.clear_plugin_highlights(plugin_id); + } + } + } + + pub fn clear_plugin_highlights_for_pane(&mut self, pane_id: PaneId, plugin_id: u32) { + if let Some(pane) = self.get_pane_with_id_mut(pane_id) { + pane.clear_plugin_highlights(plugin_id); + } + } + pub fn update_plugin_loading_stage(&mut self, pid: u32, loading_indication: LoadingIndication) { if let Some(plugin_pane) = self .tiled_panes diff --git a/zellij-server/src/tab/mouse_handler.rs b/zellij-server/src/tab/mouse_handler.rs index 037c5acd..f98dc3f4 100644 --- a/zellij-server/src/tab/mouse_handler.rs +++ b/zellij-server/src/tab/mouse_handler.rs @@ -7,10 +7,25 @@ use zellij_utils::position::Position; use crate::background_jobs::BackgroundJob; use crate::panes::PaneId; +use crate::plugins::PluginInstruction; use crate::ClientId; use super::{Pane, Tab}; +/// Remove the hover pane tracking for `client_id` and clear the hover position +/// on the previously hovered pane (if any). Returns `true` if a pane was +/// cleared. +fn clear_hover_for_client(tab: &mut Tab, client_id: ClientId) -> bool { + if let Some(prev_pid) = tab.mouse_hover_pane_id.remove(&client_id) { + if let Some(pane) = tab.get_pane_with_id_mut(prev_pid) { + pane.set_hover_position(None); + } + true + } else { + false + } +} + #[derive(Debug, Default, Copy, Clone)] pub struct MouseEffect { pub state_changed: bool, @@ -136,6 +151,7 @@ enum MouseAction { }, UpdateHover { pane_id: Option<PaneId>, + position: Option<Position>, }, SendToTerminal { pane_id: PaneId, @@ -636,7 +652,7 @@ impl MouseHandler { is_floating: _, position, } => { - tab.mouse_hover_pane_id.remove(&client_id); + clear_hover_for_client(tab, client_id); Self::start_pane_resize_with_mouse(tab, pane_id, edge, position, client_id) .with_context(err_context)?; Ok(MouseEffect::state_changed()) @@ -667,6 +683,24 @@ impl MouseHandler { .get_pane_with_id_mut(pane_id) .ok_or_else(|| anyhow!("Failed to find pane {pane_id:?}"))?; let relative_position = pane.relative_position(&position); + + // Check if click lands on a plugin highlight + if let Some((hit_plugin_id, pattern, matched_string, context)) = + pane.plugin_highlight_at(&relative_position) + { + let _ = tab + .senders + .send_to_plugin(PluginInstruction::HighlightClicked { + plugin_id: hit_plugin_id, + client_id, + pane_id, + pattern, + matched_string, + context, + }); + return Ok(MouseEffect::state_changed()); + } + let mut leave_clipboard_message = false; pane.start_selection(&relative_position, client_id); if pane.get_selected_text(client_id).is_some() { @@ -718,8 +752,8 @@ impl MouseHandler { MouseAction::ResizeScrollDown { pane_id } => { Self::handle_resize_scroll_down(tab, pane_id, client_id).with_context(err_context) }, - MouseAction::UpdateHover { pane_id } => { - Self::execute_update_hover(tab, pane_id, client_id) + MouseAction::UpdateHover { pane_id, position } => { + Self::execute_update_hover(tab, pane_id, position, client_id) }, MouseAction::SendToTerminal { pane_id, event } => { Self::execute_send_to_terminal(tab, pane_id, event, client_id) @@ -762,7 +796,7 @@ impl MouseHandler { client_id: ClientId, ) -> Result<MouseEffect> { let err_context = || "failed to focus pane"; - tab.mouse_hover_pane_id.remove(&client_id); + clear_hover_for_client(tab, client_id); let active_pane_id_before = tab .get_active_pane_id(client_id) .ok_or_else(|| anyhow!("Failed to find active pane"))?; @@ -894,9 +928,11 @@ impl MouseHandler { fn execute_update_hover( tab: &mut Tab, pane_id: Option<PaneId>, + _position: Option<Position>, client_id: ClientId, ) -> Result<MouseEffect> { let mut should_render = false; + let previous_hover_pane_id = tab.mouse_hover_pane_id.get(&client_id).copied(); match pane_id { Some(pid) => { if let Some(pane) = tab.get_pane_with_id(pid) { @@ -917,6 +953,18 @@ impl MouseHandler { }, } + // Clear hover position on previously hovered pane when the hovered + // pane has changed (or cursor left all panes). Hover position is + // intentionally not set on unfocused panes so that hover-only plugin + // highlights only activate on the focused pane. + if let Some(prev_pane_id) = previous_hover_pane_id { + if Some(prev_pane_id) != pane_id { + if let Some(pane) = tab.get_pane_with_id_mut(prev_pane_id) { + pane.set_hover_position(None); + } + } + } + if tab.mouse_help_text_visible.remove(&client_id).is_some() { should_render = true; } @@ -954,10 +1002,23 @@ impl MouseHandler { .with_context(err_context)?; } } - let removed_hover = tab.mouse_hover_pane_id.remove(&client_id); - if removed_hover.is_some() { + if clear_hover_for_client(tab, client_id) { should_render = true; } + // Update hover position on the active pane during motion events + // so that on_hover highlights work for the focused pane too. + // Skip when the terminal application has mouse tracking enabled, + // because hover highlights are suppressed in that case and the + // update would only trigger unnecessary re-renders. + if event.event_type == MouseEventType::Motion { + if let Some(pane) = tab.get_pane_with_id_mut(pane_id) { + if !pane.terminal_emulator_wants_mouse() { + let relative = pane.relative_position(&event.position); + pane.set_hover_position(Some(relative)); + should_render = true; + } + } + } if event.event_type == MouseEventType::Motion && tab.mouse_hover_effects { tab.last_mouse_activity_time @@ -1203,7 +1264,10 @@ impl MouseHandler { && !event.middle; if is_buttonless_motion { let Some(pane_id) = ctx.pane_id_at_position else { - return Ok(MouseAction::UpdateHover { pane_id: None }); + return Ok(MouseAction::UpdateHover { + pane_id: None, + position: None, + }); }; let is_active_pane = Some(pane_id) == ctx.active_pane_id; if is_active_pane { @@ -1214,6 +1278,7 @@ impl MouseHandler { } return Ok(MouseAction::UpdateHover { pane_id: Some(pane_id), + position: Some(event.position), }); } diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap new file mode 100644 index 00000000..8070d41a --- /dev/null +++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap @@ -0,0 +1,24 @@ +--- +source: zellij-server/src/tab/./unit/tab_integration_tests.rs +expression: snapshot +--- +00 (C): ┌ Pane #1 ──────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐ +01 (C): │link_text ││right pane │ +02 (C): │ ││ │ +03 (C): │ ││ │ +04 (C): │ ││ │ +05 (C): │ ││ │ +06 (C): │ ││ │ +07 (C): │ ││ │ +08 (C): │ ││ │ +09 (C): │ ││ │ +10 (C): │ ││ │ +11 (C): │ ││ │ +12 (C): │ ││ │ +13 (C): │ ││ │ +14 (C): │ ││ │ +15 (C): │ ││ │ +16 (C): │ ││ │ +17 (C): │ ││ │ +18 (C): │ ││ │ +19 (C): └ Alt <Click> - group, Alt <Right-Click> - ungroup all ─────┘└──────────────────────────────────────────────────────────┘ diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling-2.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling-2.snap new file mode 100644 index 00000000..2c23c59d --- /dev/null +++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling-2.snap @@ -0,0 +1,24 @@ +--- +source: zellij-server/src/tab/./unit/tab_integration_tests.rs +expression: snapshot_without_hover +--- +00 (C): +01 (C): hello link_text bar +02 (C): +03 (C): +04 (C): +05 (C): +06 (C): +07 (C): +08 (C): +09 (C): +10 (C): +11 (C): +12 (C): +13 (C): +14 (C): +15 (C): +16 (C): +17 (C): +18 (C): +19 (C): diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling.snap new file mode 100644 index 00000000..d101a254 --- /dev/null +++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling.snap @@ -0,0 +1,24 @@ +--- +source: zellij-server/src/tab/./unit/tab_integration_tests.rs +expression: snapshot_with_hover +--- +00 (C): ┌ Pane #1 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +01 (C): │hello link_text bar │ +02 (C): │ │ +03 (C): │ │ +04 (C): │ │ +05 (C): │ │ +06 (C): │ │ +07 (C): │ │ +08 (C): │ │ +09 (C): │ │ +10 (C): │ │ +11 (C): │ │ +12 (C): │ │ +13 (C): │ │ +14 (C): │ │ +15 (C): │ │ +16 (C): │ │ +17 (C): │ │ +18 (C): │ │ +19 (C): └───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__set_and_clear_highlights_across_tiled_and_floating.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__set_and_clear_highlights_across_tiled_and_floating.snap new file mode 100644 index 00000000..8730fd6c --- /dev/null +++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__set_and_clear_highlights_across_tiled_and_floating.snap @@ -0,0 +1,24 @@ +--- +source: zellij-server/src/tab/./unit/tab_integration_tests.rs +expression: snapshot +--- +00 (C): ┌ Pane #1 ──────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐ +01 (C): │aaa bbb ││aaa bbb │ +02 (C): │ ││ │ +03 (C): │ ││ │ +04 (C): │ ││ │ +05 (C): │ ┌ Pane #3 ──────────────────────────────────────── PIN [ ] ┐ │ +06 (C): │ │aaa bbb │ │ +07 (C): │ │ │ │ +08 (C): │ │ │ │ +09 (C): │ │ │ │ +10 (C): │ │ │ │ +11 (C): │ │ │ │ +12 (C): │ │ │ │ +13 (C): │ │ │ │ +14 (C): │ └──────────────────────────────────────────────────────────┘ │ +15 (C): │ ││ │ +16 (C): │ ││ │ +17 (C): │ ││ │ +18 (C): │ ││ │ +19 (C): └───────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘ diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs index bcd83279..ca68538c 100644 --- a/zellij-server/src/tab/unit/tab_integration_tests.rs +++ b/zellij-server/src/tab/unit/tab_integration_tests.rs @@ -12460,3 +12460,470 @@ fn in_place_pane_with_close_replaced_pane_true_closes_original() { // Snapshot shows only pane 1 (pane 2 was permanently closed, not restored) assert_snapshot!(snapshot); } + +// ===================================================================== +// Plugin Highlight Mouse Integration Tests +// ===================================================================== + +use zellij_utils::data::{HighlightStyle, RegexHighlight}; + +fn create_new_tab_with_plugin_receiver( + size: Size, + default_mode: ModeInfo, +) -> (Tab, Receiver<(PluginInstruction, ErrorContext)>) { + set_session_name("test".into()); + let index = 0; + let position = 0; + let name = String::new(); + let os_api = Box::new(FakeInputOutput::default()); + let mut senders = ThreadSenders::default().silently_fail_on_send(); + let (mock_plugin_sender, mock_plugin_receiver): ChannelWithContext<PluginInstruction> = + channels::unbounded(); + senders.replace_to_plugin(SenderWithContext::new(mock_plugin_sender)); + let max_panes = None; + let mode_info = default_mode; + let style = Style::default(); + let draw_pane_frames = true; + let auto_layout = true; + let client_id = 1; + let session_is_mirrored = true; + let mut connected_clients = HashMap::new(); + connected_clients.insert(client_id, false); + let connected_clients = Rc::new(RefCell::new(connected_clients)); + let character_cell_info = Rc::new(RefCell::new(None)); + let stacked_resize = Rc::new(RefCell::new(true)); + let terminal_emulator_colors = Rc::new(RefCell::new(Palette::default())); + let copy_options = CopyOptions::default(); + let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new())); + let sixel_image_store = Rc::new(RefCell::new(SixelImageStore::default())); + let current_group = Rc::new(RefCell::new(PaneGroups::new(ThreadSenders::default()))); + let currently_marking_pane_group = Rc::new(RefCell::new(HashMap::new())); + let debug = false; + let arrow_fonts = true; + let styled_underlines = true; + let osc8_hyperlinks = true; + let explicitly_disable_kitty_keyboard_protocol = false; + let advanced_mouse_actions = true; + let web_sharing = WebSharing::Off; + let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); + let web_server_port = 8080; + let mut tab = Tab::new( + index, + position, + name, + size, + character_cell_info, + stacked_resize, + sixel_image_store, + os_api, + senders, + max_panes, + style, + mode_info, + draw_pane_frames, + auto_layout, + connected_clients, + session_is_mirrored, + Some(client_id), + copy_options, + terminal_emulator_colors, + terminal_emulator_color_codes, + (vec![], vec![]), + PathBuf::from("my_default_shell"), + debug, + arrow_fonts, + styled_underlines, + osc8_hyperlinks, + explicitly_disable_kitty_keyboard_protocol, + None, + false, + web_sharing, + current_group, + currently_marking_pane_group, + advanced_mouse_actions, + true, // mouse_hover_effects + web_server_ip, + web_server_port, + ); + tab.apply_layout( + TiledPaneLayout::default(), + vec![], + vec![(1, None)], + vec![], + HashMap::new(), + client_id, + None, + ) + .unwrap(); + (tab, mock_plugin_receiver) +} + +#[test] +fn click_on_plugin_highlight_sends_highlight_clicked() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let (mut tab, mock_plugin_receiver) = + create_new_tab_with_plugin_receiver(size, ModeInfo::default()); + + // Feed PTY bytes to pane 1 + tab.handle_pty_bytes(1, Vec::from("click here foo bar".as_bytes())) + .unwrap(); + + // Set plugin highlight on pane 1 for pattern "foo" + let highlights = vec![RegexHighlight { + pattern: "foo".into(), + style: HighlightStyle::Emphasis0, + context: BTreeMap::new(), + on_hover: false, + bold: false, + italic: false, + underline: true, + }]; + tab.set_plugin_regex_highlights_for_pane( + PaneId::Terminal(1), + 42, + highlights, + &Style::default(), + ); + + // Left-click at position where "foo" appears + // With draw_pane_frames=true, content starts at row 1, col 1 + // "click here foo bar" -> "foo" starts at offset 11, so col = 1 + 11 = 12 + let click_position = Position::new(1, 12); + let effect = tab + .handle_mouse_event(&MouseEvent::new_left_press_event(click_position), client_id) + .unwrap(); + + assert!(effect.state_changed); + + // Check that HighlightClicked was sent to the plugin channel + let mut found_highlight_clicked = false; + while let Ok((instruction, _ctx)) = mock_plugin_receiver.try_recv() { + if let PluginInstruction::HighlightClicked { + plugin_id, + pane_id, + pattern, + matched_string, + .. + } = instruction + { + assert_eq!(plugin_id, 42); + assert_eq!(pane_id, PaneId::Terminal(1)); + assert_eq!(pattern, "foo"); + assert_eq!(matched_string, "foo"); + found_highlight_clicked = true; + break; + } + } + assert!( + found_highlight_clicked, + "Expected HighlightClicked instruction to be sent" + ); +} + +#[test] +fn click_outside_highlight_starts_normal_selection() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let (mut tab, mock_plugin_receiver) = + create_new_tab_with_plugin_receiver(size, ModeInfo::default()); + + tab.handle_pty_bytes(1, Vec::from("click here foo bar".as_bytes())) + .unwrap(); + + let highlights = vec![RegexHighlight { + pattern: "foo".into(), + style: HighlightStyle::Emphasis0, + context: BTreeMap::new(), + on_hover: false, + bold: false, + italic: false, + underline: true, + }]; + tab.set_plugin_regex_highlights_for_pane( + PaneId::Terminal(1), + 42, + highlights, + &Style::default(), + ); + + // Click on "click" (col 1), not on "foo" + let click_position = Position::new(1, 1); + let _effect = tab + .handle_mouse_event(&MouseEvent::new_left_press_event(click_position), client_id) + .unwrap(); + + // No HighlightClicked should be sent + let mut found_highlight_clicked = false; + while let Ok((instruction, _ctx)) = mock_plugin_receiver.try_recv() { + if matches!(instruction, PluginInstruction::HighlightClicked { .. }) { + found_highlight_clicked = true; + } + } + assert!( + !found_highlight_clicked, + "HighlightClicked should NOT be sent for miss" + ); + + // Selection should have started + assert!(tab.selecting_with_mouse_in_pane.is_some()); +} + +#[test] +fn hover_over_highlight_shows_styling() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = create_new_tab(size, ModeInfo::default()); + let mut output = Output::default(); + + tab.handle_pty_bytes(1, Vec::from("hello link_text bar".as_bytes())) + .unwrap(); + + let highlights = vec![RegexHighlight { + pattern: "link_text".into(), + style: HighlightStyle::None, + context: BTreeMap::new(), + on_hover: true, + bold: false, + italic: true, + underline: true, + }]; + tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 1, highlights, &Style::default()); + + // Hover over the match (link_text starts at offset 6, with frame offset col = 7) + let hover_position = Position::new(1, 9); + let _effect = tab + .handle_mouse_event( + &MouseEvent::new_buttonless_motion(hover_position), + client_id, + ) + .unwrap(); + + tab.render(&mut output, None).unwrap(); + let snapshot_with_hover = take_snapshot( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + assert_snapshot!(snapshot_with_hover); + + // Move hover away from the match + let mut output2 = Output::default(); + let far_position = Position::new(1, 1); + let _effect2 = tab + .handle_mouse_event(&MouseEvent::new_buttonless_motion(far_position), client_id) + .unwrap(); + + tab.render(&mut output2, None).unwrap(); + let snapshot_without_hover = take_snapshot( + output2.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + assert_snapshot!(snapshot_without_hover); +} + +#[test] +fn hover_on_unfocused_pane_no_highlight() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = create_new_tab(size, ModeInfo::default()); + let new_pane_id = PaneId::Terminal(2); + let mut output = Output::default(); + + // Create a vertical split + tab.vertical_split(new_pane_id, None, client_id, None, None) + .unwrap(); + + tab.handle_pty_bytes(1, Vec::from("link_text".as_bytes())) + .unwrap(); + tab.handle_pty_bytes(2, Vec::from("right pane".as_bytes())) + .unwrap(); + + // Active pane is pane 2 (right) + assert_eq!(tab.get_active_pane_id(client_id), Some(PaneId::Terminal(2))); + + // Set on_hover highlight on pane 1 (left, unfocused) + let highlights = vec![RegexHighlight { + pattern: "link_text".into(), + style: HighlightStyle::None, + context: BTreeMap::new(), + on_hover: true, + bold: false, + italic: true, + underline: true, + }]; + tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 1, highlights, &Style::default()); + + // Hover over left pane (unfocused) + let hover_position = Position::new(1, 5); + let _effect = tab + .handle_mouse_event( + &MouseEvent::new_buttonless_motion(hover_position), + client_id, + ) + .unwrap(); + + tab.render(&mut output, None).unwrap(); + let snapshot = take_snapshot( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + // Hover highlight should NOT be visible on the unfocused pane + assert_snapshot!(snapshot); +} + +#[test] +fn highlight_click_in_mouse_mode_pane_suppressed() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let (mut tab, mock_plugin_receiver) = + create_new_tab_with_plugin_receiver(size, ModeInfo::default()); + + // Feed PTY bytes that enable mouse tracking (SGR mode + any-event tracking) + tab.handle_pty_bytes(1, Vec::from("\x1b[?1006h\x1b[?1003h".as_bytes())) + .unwrap(); + // Then feed text with matchable content + tab.handle_pty_bytes(1, Vec::from("foo bar baz".as_bytes())) + .unwrap(); + + // Set plugin highlight + let highlights = vec![RegexHighlight { + pattern: "foo".into(), + style: HighlightStyle::Emphasis0, + context: BTreeMap::new(), + on_hover: false, + bold: false, + italic: false, + underline: true, + }]; + tab.set_plugin_regex_highlights_for_pane( + PaneId::Terminal(1), + 42, + highlights, + &Style::default(), + ); + + // Left-click on the highlight position + // With mouse tracking, the click goes through SendToTerminal, not StartSelection + let click_position = Position::new(1, 1); + let _effect = tab + .handle_mouse_event(&MouseEvent::new_left_press_event(click_position), client_id) + .unwrap(); + + // No HighlightClicked should be sent (the click is forwarded to the terminal) + let mut found_highlight_clicked = false; + while let Ok((instruction, _ctx)) = mock_plugin_receiver.try_recv() { + if matches!(instruction, PluginInstruction::HighlightClicked { .. }) { + found_highlight_clicked = true; + } + } + assert!( + !found_highlight_clicked, + "HighlightClicked should NOT be sent when terminal has mouse tracking" + ); +} + +#[test] +fn set_and_clear_highlights_across_tiled_and_floating() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = create_new_tab(size, ModeInfo::default()); + let pane2 = PaneId::Terminal(2); + let pane3 = PaneId::Terminal(3); + + // Create a second tiled pane + tab.vertical_split(pane2, None, client_id, None, None) + .unwrap(); + + // Create a floating pane + tab.toggle_floating_panes(Some(client_id), None, None) + .unwrap(); + tab.new_pane( + pane3, + None, + None, + false, + true, + NewPanePlacement::default(), + Some(client_id), + None, + ) + .unwrap(); + + // Feed content to all panes + tab.handle_pty_bytes(1, Vec::from("aaa bbb".as_bytes())) + .unwrap(); + tab.handle_pty_bytes(2, Vec::from("aaa bbb".as_bytes())) + .unwrap(); + tab.handle_pty_bytes(3, Vec::from("aaa bbb".as_bytes())) + .unwrap(); + + // Set highlights from plugin 1 (pattern "aaa") on all panes + let h1 = vec![RegexHighlight { + pattern: "aaa".into(), + style: HighlightStyle::Emphasis0, + context: BTreeMap::new(), + on_hover: false, + bold: false, + italic: false, + underline: true, + }]; + tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 1, h1.clone(), &Style::default()); + tab.set_plugin_regex_highlights_for_pane(pane2, 1, h1.clone(), &Style::default()); + tab.set_plugin_regex_highlights_for_pane(pane3, 1, h1.clone(), &Style::default()); + + // Set highlights from plugin 2 (pattern "bbb") on all panes + let h2 = vec![RegexHighlight { + pattern: "bbb".into(), + style: HighlightStyle::Emphasis1, + context: BTreeMap::new(), + on_hover: false, + bold: false, + italic: false, + underline: false, + }]; + tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 2, h2.clone(), &Style::default()); + tab.set_plugin_regex_highlights_for_pane(pane2, 2, h2.clone(), &Style::default()); + tab.set_plugin_regex_highlights_for_pane(pane3, 2, h2.clone(), &Style::default()); + + // Clear plugin 1 highlights across all panes + tab.clear_all_plugin_highlights(1); + + // Verify plugin 2 highlights remain — we can use the tab render to snapshot, + // or directly check that plugin 2 pattern still matches on pane 1 + // Since clear_all_plugin_highlights iterates all panes, we test via + // the set_plugin_regex_highlights_for_pane / clear flow: + // Plugin 2 "bbb" highlights should still be active. + let mut output = Output::default(); + tab.render(&mut output, None).unwrap(); + let snapshot = take_snapshot( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + assert_snapshot!(snapshot); +} diff --git a/zellij-tile/src/shim.rs b/zellij-tile/src/shim.rs index 2837d08e..8c40b28a 100644 --- a/zellij-tile/src/shim.rs +++ b/zellij-tile/src/shim.rs @@ -2749,6 +2749,37 @@ pub fn hide_floating_panes(tab_id: Option<usize>) -> Result<bool, String> { } } +/// Set or update regex-based content highlights for a pane. +/// +/// Each entry in `highlights` is keyed by its `pattern` string. Calling this +/// function again with the same pattern updates its style; new patterns are +/// added; patterns not present in this call are kept. To remove all highlights +/// for this plugin on the pane, use `clear_pane_highlights`. +/// +/// Pattern matching is performed by the server against the current viewport at +/// render time. The plugin never handles coordinates, so there is no race +/// condition between content changes and highlight application. +/// +/// Requires `ChangeApplicationState` permission. +pub fn set_pane_regex_highlights(pane_id: PaneId, highlights: Vec<RegexHighlight>) { + let plugin_command = PluginCommand::SetPaneRegexHighlights(pane_id, highlights); + let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap(); + object_to_stdout(&protobuf_plugin_command.encode_to_vec()); + unsafe { host_run_plugin_command() }; +} + +/// Remove all regex highlights this plugin has set on the given pane. +/// +/// Other plugins' highlights on the same pane are not affected. +/// +/// Requires `ChangeApplicationState` permission. +pub fn clear_pane_highlights(pane_id: PaneId) { + let plugin_command = PluginCommand::ClearPaneHighlights(pane_id); + let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap(); + object_to_stdout(&protobuf_plugin_command.encode_to_vec()); + unsafe { host_run_plugin_command() }; +} + #[link(wasm_import_module = "zellij")] extern "C" { fn host_run_plugin_command(); diff --git a/zellij-utils/assets/config/default.kdl b/zellij-utils/assets/config/default.kdl index 8c6cb627..3ea42850 100644 --- a/zellij-utils/assets/config/default.kdl +++ b/zellij-utils/assets/config/default.kdl @@ -246,8 +246,9 @@ plugins { // Plugins to load in the background when a new session starts load_plugins { - // "file:/path/to/my-plugin.wasm" - // "https://example.com/my-plugin.wasm" + "zellij:link" + // "file:/path/to/my-plugin.wasm" + // "https://example.com/my-plugin.wasm" } // Choose what to do when zellij receives SIGTERM, SIGINT, SIGQUIT or SIGHUP diff --git a/zellij-utils/assets/plugins/about.wasm b/zellij-utils/assets/plugins/about.wasm Binary files differindex aab775a5..393ff56e 100755 --- a/zellij-utils/assets/plugins/about.wasm +++ b/zellij-utils/assets/plugins/about.wasm diff --git a/zellij-utils/assets/plugins/compact-bar.wasm b/zellij-utils/assets/plugins/compact-bar.wasm Binary files differindex 2719eaa4..a5ae98c5 100755 --- a/zellij-utils/assets/plugins/compact-bar.wasm +++ b/zellij-utils/assets/plugins/compact-bar.wasm diff --git a/zellij-utils/assets/plugins/configuration.wasm b/zellij-utils/assets/plugins/configuration.wasm Binary files differindex d5111f1a..bc47d8ce 100755 --- a/zellij-utils/assets/plugins/configuration.wasm +++ b/zellij-utils/assets/plugins/configuration.wasm diff --git a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm Binary files differindex 1db435ba..d84e92dd 100755 --- a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm +++ b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm diff --git a/zellij-utils/assets/plugins/layout-manager.wasm b/zellij-utils/assets/plugins/layout-manager.wasm Binary files differindex e147d179..a7f0164f 100755 --- a/zellij-utils/assets/plugins/layout-manager.wasm +++ b/zellij-utils/assets/plugins/layout-manager.wasm diff --git a/zellij-utils/assets/plugins/link.wasm b/zellij-utils/assets/plugins/link.wasm Binary files differnew file mode 100755 index 00000000..0b8be5c3 --- /dev/null +++ b/zellij-utils/assets/plugins/link.wasm diff --git a/zellij-utils/assets/plugins/multiple-select.wasm b/zellij-utils/assets/plugins/multiple-select.wasm Binary files differindex 43676c22..45bb9a2a 100755 --- a/zellij-utils/assets/plugins/multiple-select.wasm +++ b/zellij-utils/assets/plugins/multiple-select.wasm diff --git a/zellij-utils/assets/plugins/plugin-manager.wasm b/zellij-utils/assets/plugins/plugin-manager.wasm Binary files differindex ef666220..706563c1 100755 --- a/zellij-utils/assets/plugins/plugin-manager.wasm +++ b/zellij-utils/assets/plugins/plugin-manager.wasm diff --git a/zellij-utils/assets/plugins/session-manager.wasm b/zellij-utils/assets/plugins/session-manager.wasm Binary files differindex 6f556f01..04494e13 100755 --- a/zellij-utils/assets/plugins/session-manager.wasm +++ b/zellij-utils/assets/plugins/session-manager.wasm diff --git a/zellij-utils/assets/plugins/share.wasm b/zellij-utils/assets/plugins/share.wasm Binary files differindex 99606d71..9ee67571 100755 --- a/zellij-utils/assets/plugins/share.wasm +++ b/zellij-utils/assets/plugins/share.wasm diff --git a/zellij-utils/assets/plugins/status-bar.wasm b/zellij-utils/assets/plugins/status-bar.wasm Binary files differindex f5db526c..69036d5c 100755 --- a/zellij-utils/assets/plugins/status-bar.wasm +++ b/zellij-utils/assets/plugins/status-bar.wasm diff --git a/zellij-utils/assets/plugins/strider.wasm b/zellij-utils/assets/plugins/strider.wasm Binary files differindex 735f74a9..5e457f65 100755 --- a/zellij-utils/assets/plugins/strider.wasm +++ b/zellij-utils/assets/plugins/strider.wasm diff --git a/zellij-utils/assets/plugins/tab-bar.wasm b/zellij-utils/assets/plugins/tab-bar.wasm Binary files differindex fc2d6f1c..6e111f04 100755 --- a/zellij-utils/assets/plugins/tab-bar.wasm +++ b/zellij-utils/assets/plugins/tab-bar.wasm diff --git a/zellij-utils/assets/prost/api.event.rs b/zellij-utils/assets/prost/api.event.rs index 04ccac58..37702467 100644 --- a/zellij-utils/assets/prost/api.event.rs +++ b/zellij-utils/assets/prost/api.event.rs @@ -9,7 +9,7 @@ pub struct EventNameList { pub struct Event { #[prost(enumeration="EventType", tag="1")] pub name: i32, - #[prost(oneof="event::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35")] + #[prost(oneof="event::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36")] pub payload: ::core::option::Option<event::Payload>, } /// Nested message and enum types in `Event`. @@ -85,6 +85,8 @@ pub mod event { AvailableLayoutInfoPayload(super::AvailableLayoutInfoPayload), #[prost(message, tag="35")] PluginConfigurationChangedPayload(super::PluginConfigurationChangedPayload), + #[prost(message, tag="36")] + HighlightClickedPayload(super::HighlightClickedPayload), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -727,6 +729,18 @@ pub struct PluginConfigurationChangedPayload { #[prost(message, repeated, tag="1")] pub configuration: ::prost::alloc::vec::Vec<ContextItem>, } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct HighlightClickedPayload { + #[prost(message, optional, tag="1")] + pub pane_id: ::core::option::Option<PaneId>, + #[prost(string, tag="2")] + pub pattern: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub matched_string: ::prost::alloc::string::String, + #[prost(message, repeated, tag="4")] + pub context: ::prost::alloc::vec::Vec<ContextItem>, +} #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] pub enum EventType { @@ -786,6 +800,7 @@ pub enum EventType { CwdChanged = 39, AvailableLayoutInfo = 40, PluginConfigurationChanged = 41, + HighlightClicked = 42, } impl EventType { /// String value of the enum field names used in the ProtoBuf definition. @@ -835,6 +850,7 @@ impl EventType { EventType::CwdChanged => "CwdChanged", EventType::AvailableLayoutInfo => "AvailableLayoutInfo", EventType::PluginConfigurationChanged => "PluginConfigurationChanged", + EventType::HighlightClicked => "HighlightClicked", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -881,6 +897,7 @@ impl EventType { "CwdChanged" => Some(Self::CwdChanged), "AvailableLayoutInfo" => Some(Self::AvailableLayoutInfo), "PluginConfigurationChanged" => Some(Self::PluginConfigurationChanged), + "HighlightClicked" => Some(Self::HighlightClicked), _ => None, } } diff --git a/zellij-utils/assets/prost/api.plugin_command.rs b/zellij-utils/assets/prost/api.plugin_command.rs index 887e2e30..2296b2da 100644 --- a/zellij-utils/assets/prost/api.plugin_command.rs +++ b/zellij-utils/assets/prost/api.plugin_command.rs @@ -3,7 +3,7 @@ pub struct PluginCommand { #[prost(enumeration="CommandName", tag="1")] pub name: i32, - #[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157")] + #[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")] pub payload: ::core::option::Option<plugin_command::Payload>, } /// Nested message and enum types in `PluginCommand`. @@ -297,6 +297,10 @@ pub mod plugin_command { ShowFloatingPanesPayload(super::ShowFloatingPanesPayload), #[prost(message, tag="157")] SetPaneColorPayload(super::SetPaneColorPayload), + #[prost(message, tag="158")] + SetPaneRegexHighlightsPayload(super::SetPaneRegexHighlightsPayload), + #[prost(message, tag="159")] + ClearPaneHighlightsPayload(super::ClearPaneHighlightsPayload), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -1780,6 +1784,97 @@ pub struct SetPaneColorPayload { #[prost(string, optional, tag="3")] pub bg: ::core::option::Option<::prost::alloc::string::String>, } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct HighlightStyle { + #[prost(oneof="highlight_style::Style", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11")] + pub style: ::core::option::Option<highlight_style::Style>, +} +/// Nested message and enum types in `HighlightStyle`. +pub mod highlight_style { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Style { + #[prost(bool, tag="1")] + Emphasis0(bool), + #[prost(bool, tag="2")] + Emphasis1(bool), + #[prost(bool, tag="3")] + Emphasis2(bool), + #[prost(bool, tag="4")] + Emphasis3(bool), + #[prost(message, tag="5")] + CustomRgb(super::CustomRgbHighlight), + #[prost(message, tag="6")] + CustomIndex(super::CustomIndexHighlight), + #[prost(bool, tag="7")] + BackgroundEmphasis0(bool), + #[prost(bool, tag="8")] + BackgroundEmphasis1(bool), + #[prost(bool, tag="9")] + BackgroundEmphasis2(bool), + #[prost(bool, tag="10")] + BackgroundEmphasis3(bool), + #[prost(bool, tag="11")] + None(bool), + } +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CustomRgbHighlight { + #[prost(uint32, optional, tag="1")] + pub fg_r: ::core::option::Option<u32>, + #[prost(uint32, optional, tag="2")] + pub fg_g: ::core::option::Option<u32>, + #[prost(uint32, optional, tag="3")] + pub fg_b: ::core::option::Option<u32>, + #[prost(uint32, optional, tag="4")] + pub bg_r: ::core::option::Option<u32>, + #[prost(uint32, optional, tag="5")] + pub bg_g: ::core::option::Option<u32>, + #[prost(uint32, optional, tag="6")] + pub bg_b: ::core::option::Option<u32>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CustomIndexHighlight { + #[prost(uint32, optional, tag="1")] + pub fg: ::core::option::Option<u32>, + #[prost(uint32, optional, tag="2")] + pub bg: ::core::option::Option<u32>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RegexHighlight { + #[prost(string, tag="1")] + pub pattern: ::prost::alloc::string::String, + #[prost(message, optional, tag="2")] + pub style: ::core::option::Option<HighlightStyle>, + #[prost(message, repeated, tag="3")] + pub context: ::prost::alloc::vec::Vec<ContextItem>, + #[prost(bool, tag="4")] + pub on_hover: bool, + #[prost(bool, tag="5")] + pub bold: bool, + #[prost(bool, tag="6")] + pub italic: bool, + #[prost(bool, tag="7")] + pub underline: bool, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SetPaneRegexHighlightsPayload { + #[prost(message, optional, tag="1")] + pub pane_id: ::core::option::Option<PaneId>, + #[prost(message, repeated, tag="2")] + pub highlights: ::prost::alloc::vec::Vec<RegexHighlight>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ClearPaneHighlightsPayload { + #[prost(message, optional, tag="1")] + pub pane_id: ::core::option::Option<PaneId>, +} #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] pub enum CommandName { @@ -1969,6 +2064,8 @@ pub enum CommandName { HideFloatingPanes = 204, ShowFloatingPanes = 205, SetPaneColor = 206, + SetPaneRegexHighlights = 207, + ClearPaneHighlights = 208, } impl CommandName { /// String value of the enum field names used in the ProtoBuf definition. @@ -2163,6 +2260,8 @@ impl CommandName { CommandName::HideFloatingPanes => "HideFloatingPanes", CommandName::ShowFloatingPanes => "ShowFloatingPanes", CommandName::SetPaneColor => "SetPaneColor", + CommandName::SetPaneRegexHighlights => "SetPaneRegexHighlights", + CommandName::ClearPaneHighlights => "ClearPaneHighlights", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -2354,6 +2453,8 @@ impl CommandName { "HideFloatingPanes" => Some(Self::HideFloatingPanes), "ShowFloatingPanes" => Some(Self::ShowFloatingPanes), "SetPaneColor" => Some(Self::SetPaneColor), + "SetPaneRegexHighlights" => Some(Self::SetPaneRegexHighlights), + "ClearPaneHighlights" => Some(Self::ClearPaneHighlights), _ => None, } } diff --git a/zellij-utils/src/consts.rs b/zellij-utils/src/consts.rs index 1f039c72..beecb2ad 100644 --- a/zellij-utils/src/consts.rs +++ b/zellij-utils/src/consts.rs @@ -139,6 +139,7 @@ mod not_wasm { add_plugin!(assets, "share.wasm"); add_plugin!(assets, "multiple-select.wasm"); add_plugin!(assets, "layout-manager.wasm"); + add_plugin!(assets, "link.wasm"); assets }; } diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs index 4a7088c0..64f1d33c 100644 --- a/zellij-utils/src/data.rs +++ b/zellij-utils/src/data.rs @@ -1013,6 +1013,12 @@ pub enum Event { CwdChanged(PaneId, PathBuf, Vec<ClientId>), // pane_id, cwd, focused_client_ids AvailableLayoutInfo(Vec<LayoutInfo>, Vec<LayoutWithError>), PluginConfigurationChanged(BTreeMap<String, String>), + HighlightClicked { + pane_id: PaneId, + pattern: String, + matched_string: String, + context: BTreeMap<String, String>, + }, } #[derive(Debug, Clone, PartialEq, Eq, EnumDiscriminants, Display, Serialize, Deserialize)] @@ -1197,6 +1203,41 @@ impl Default for PaletteColor { } } +/// Style for a plugin-supplied regex highlight. +/// Theme-based variants reference `style.colors.text_unselected.*`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum HighlightStyle { + None, // no color override — use with bold/italic/underline for style-only highlights + Emphasis0, // fg = emphasis_0, no bg override + Emphasis1, // fg = emphasis_1, no bg override + Emphasis2, // fg = emphasis_2, no bg override + Emphasis3, // fg = emphasis_3, no bg override + BackgroundEmphasis0, // bg = emphasis_0, fg = background + BackgroundEmphasis1, // bg = emphasis_1, fg = background + BackgroundEmphasis2, // bg = emphasis_2, fg = background + BackgroundEmphasis3, // bg = emphasis_3, fg = background + CustomRgb { + fg: Option<(u8, u8, u8)>, + bg: Option<(u8, u8, u8)>, + }, + CustomIndex { + fg: Option<u8>, + bg: Option<u8>, + }, +} + +/// One pattern + style pair sent by a plugin. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RegexHighlight { + pub pattern: String, // key for upsert; also the regex source + pub style: HighlightStyle, + pub context: BTreeMap<String, String>, // arbitrary data echoed back verbatim on click + pub on_hover: bool, // if true, only rendered when the cursor overlaps this match + pub bold: bool, + pub italic: bool, + pub underline: bool, +} + // these are used for the web client impl PaletteColor { pub fn as_rgb_str(&self) -> String { @@ -3476,6 +3517,8 @@ pub enum PluginCommand { tab_id: Option<usize>, }, SetPaneColor(PaneId, Option<String>, Option<String>), // (pane_id, fg, bg) + SetPaneRegexHighlights(PaneId, Vec<RegexHighlight>), + ClearPaneHighlights(PaneId), } // Response type for plugin API methods that open a pane in a new tab diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs index 928c8901..aec1d0f0 100644 --- a/zellij-utils/src/errors.rs +++ b/zellij-utils/src/errors.rs @@ -413,6 +413,8 @@ pub enum ScreenContext { SetFollowedClient, WatcherTerminalResize, ClearMouseHelpText, + SetPluginRegexHighlights, + ClearPluginHighlights, } /// Stack call representations corresponding to the different types of [`PtyInstruction`]s. @@ -495,6 +497,7 @@ pub enum PluginContext { UpdateSessionSaveTime, GetLastSessionSaveTime, DetectPluginConfigChanges, + HighlightClicked, } /// Stack call representations corresponding to the different types of [`ClientInstruction`]s. diff --git a/zellij-utils/src/input/plugins.rs b/zellij-utils/src/input/plugins.rs index 69af52f9..31e48b43 100644 --- a/zellij-utils/src/input/plugins.rs +++ b/zellij-utils/src/input/plugins.rs @@ -68,6 +68,7 @@ impl PluginConfig { || tag == "share" || tag == "multiple-select" || tag == "layout-manager" + || tag == "link" { Some(PluginConfig { path: PathBuf::from(&tag), 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 bc5feeff..6eda59b9 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 @@ -271,6 +271,7 @@ plugins { } } load_plugins { + zellij: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 da967bcb..3dbaf47d 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 @@ -278,6 +278,7 @@ plugins { // eg. "file:/path/to/my-plugin.wasm" // eg. "https://example.com/my-plugin.wasm" load_plugins { + zellij:link } web_client { font "monospace" diff --git a/zellij-utils/src/plugin_api/event.proto b/zellij-utils/src/plugin_api/event.proto index bbee50a3..ce018a7f 100644 --- a/zellij-utils/src/plugin_api/event.proto +++ b/zellij-utils/src/plugin_api/event.proto @@ -64,6 +64,7 @@ enum EventType { CwdChanged = 39; AvailableLayoutInfo = 40; PluginConfigurationChanged = 41; + HighlightClicked = 42; } message EventNameList { @@ -107,6 +108,7 @@ message Event { CwdChangedPayload cwd_changed_payload = 33; AvailableLayoutInfoPayload available_layout_info_payload = 34; PluginConfigurationChangedPayload plugin_configuration_changed_payload = 35; + HighlightClickedPayload highlight_clicked_payload = 36; } } @@ -516,3 +518,10 @@ message PluginConfigurationChangedPayload { repeated ContextItem configuration = 1; } +message HighlightClickedPayload { + PaneId pane_id = 1; + string pattern = 2; + string matched_string = 3; + repeated ContextItem context = 4; +} + diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs index 631d5069..267e1357 100644 --- a/zellij-utils/src/plugin_api/event.rs +++ b/zellij-utils/src/plugin_api/event.rs @@ -500,6 +500,26 @@ impl TryFrom<ProtobufEvent> for Event { }, _ => Err("Malformed payload for PluginConfigurationChanged Event"), }, + Some(ProtobufEventType::HighlightClicked) => match protobuf_event.payload { + Some(ProtobufEventPayload::HighlightClickedPayload(p)) => { + let pane_id = p + .pane_id + .ok_or("Missing pane_id in HighlightClicked")? + .try_into()?; + let context = p + .context + .into_iter() + .map(|item| (item.name, item.value)) + .collect(); + Ok(Event::HighlightClicked { + pane_id, + pattern: p.pattern, + matched_string: p.matched_string, + context, + }) + }, + _ => Err("Malformed payload for HighlightClicked Event"), + }, None => Err("Unknown Protobuf Event"), } } @@ -991,6 +1011,25 @@ impl TryFrom<Event> for ProtobufEvent { payload: Some(event::Payload::PluginConfigurationChangedPayload(payload)), }) }, + Event::HighlightClicked { + pane_id, + pattern, + matched_string, + context, + } => Ok(ProtobufEvent { + name: ProtobufEventType::HighlightClicked as i32, + payload: Some(event::Payload::HighlightClickedPayload( + HighlightClickedPayload { + pane_id: pane_id.try_into().ok(), + pattern, + matched_string, + context: context + .into_iter() + .map(|(name, value)| ProtobufContextItem { name, value }) + .collect(), + }, + )), + }), } } } @@ -1901,6 +1940,7 @@ impl TryFrom<ProtobufEventType> for EventType { ProtobufEventType::CwdChanged => EventType::CwdChanged, ProtobufEventType::AvailableLayoutInfo => EventType::AvailableLayoutInfo, ProtobufEventType::PluginConfigurationChanged => EventType::PluginConfigurationChanged, + ProtobufEventType::HighlightClicked => EventType::HighlightClicked, }) } } @@ -1950,6 +1990,7 @@ impl TryFrom<EventType> for ProtobufEventType { EventType::CwdChanged => ProtobufEventType::CwdChanged, EventType::AvailableLayoutInfo => ProtobufEventType::AvailableLayoutInfo, EventType::PluginConfigurationChanged => ProtobufEventType::PluginConfigurationChanged, + EventType::HighlightClicked => ProtobufEventType::HighlightClicked, }) } } diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto index 53e9d729..c4fa949f 100644 --- a/zellij-utils/src/plugin_api/plugin_command.proto +++ b/zellij-utils/src/plugin_api/plugin_command.proto @@ -199,6 +199,8 @@ enum CommandName { HideFloatingPanes = 204; ShowFloatingPanes = 205; SetPaneColor = 206; + SetPaneRegexHighlights = 207; + ClearPaneHighlights = 208; } message PluginCommand { @@ -347,6 +349,8 @@ message PluginCommand { HideFloatingPanesPayload hide_floating_panes_payload = 155; ShowFloatingPanesPayload show_floating_panes_payload = 156; SetPaneColorPayload set_pane_color_payload = 157; + SetPaneRegexHighlightsPayload set_pane_regex_highlights_payload = 158; + ClearPaneHighlightsPayload clear_pane_highlights_payload = 159; } } @@ -1220,3 +1224,52 @@ message SetPaneColorPayload { optional string fg = 2; optional string bg = 3; } + +message HighlightStyle { + oneof style { + bool emphasis_0 = 1; + bool emphasis_1 = 2; + bool emphasis_2 = 3; + bool emphasis_3 = 4; + CustomRgbHighlight custom_rgb = 5; + CustomIndexHighlight custom_index = 6; + bool background_emphasis_0 = 7; + bool background_emphasis_1 = 8; + bool background_emphasis_2 = 9; + bool background_emphasis_3 = 10; + bool none = 11; + } +} + +message CustomRgbHighlight { + optional uint32 fg_r = 1; + optional uint32 fg_g = 2; + optional uint32 fg_b = 3; + optional uint32 bg_r = 4; + optional uint32 bg_g = 5; + optional uint32 bg_b = 6; +} + +message CustomIndexHighlight { + optional uint32 fg = 1; + optional uint32 bg = 2; +} + +message RegexHighlight { + string pattern = 1; + HighlightStyle style = 2; + repeated ContextItem context = 3; + bool on_hover = 4; + bool bold = 5; + bool italic = 6; + bool underline = 7; +} + +message SetPaneRegexHighlightsPayload { + PaneId pane_id = 1; + repeated RegexHighlight highlights = 2; +} + +message ClearPaneHighlightsPayload { + 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 abd9a994..b535e14a 100644 --- a/zellij-utils/src/plugin_api/plugin_command.rs +++ b/zellij-utils/src/plugin_api/plugin_command.rs @@ -7,7 +7,8 @@ pub use super::generated_api::api::{ break_panes_to_tab_with_index_response, delete_layout_response, dump_layout_response, dump_session_layout_response, edit_layout_response, focus_or_create_tab_response, get_focused_pane_info_response, get_pane_cwd_response, get_pane_pid_response, - get_pane_running_command_response, hide_floating_panes_response, new_tab_response, + get_pane_running_command_response, hide_floating_panes_response, + highlight_style::Style as ProtobufHighlightStyleVariant, new_tab_response, parse_layout_response, plugin_command::Payload, rename_layout_response, save_layout_response, save_session_response, show_floating_panes_response, BreakPanesToNewTabPayload, @@ -16,17 +17,19 @@ pub use super::generated_api::api::{ BreakPanesToTabWithIdResponse as ProtobufBreakPanesToTabWithIdResponse, BreakPanesToTabWithIndexPayload, BreakPanesToTabWithIndexResponse as ProtobufBreakPanesToTabWithIndexResponse, - ChangeFloatingPanesCoordinatesPayload, ChangeHostFolderPayload, + ChangeFloatingPanesCoordinatesPayload, ChangeHostFolderPayload, ClearPaneHighlightsPayload, ClearScreenForPaneIdPayload, CliPipeOutputPayload, CloseMultiplePanesPayload, CloseTabWithIdPayload, CloseTabWithIndexPayload, CommandName, ContextItem, CopyToClipboardPayload, CreateTokenResponse as ProtobufCreateTokenResponse, CreateTokenResponse, CurrentSessionLastSavedTimePayload, CurrentSessionLastSavedTimeResponse as ProtobufCurrentSessionLastSavedTimeResponse, - CursorPosition, DeleteLayoutPayload, DeleteLayoutResponse as ProtobufDeleteLayoutResponse, - DumpLayoutPayload, DumpLayoutResponse as ProtobufDumpLayoutResponse, - DumpSessionLayoutPayload, DumpSessionLayoutResponse as ProtobufDumpSessionLayoutResponse, - EditLayoutPayload, EditLayoutResponse as ProtobufEditLayoutResponse, - EditScrollbackForPaneWithIdPayload, EmbedMultiplePanesPayload, EnvVariable, ExecCmdPayload, + CursorPosition, CustomIndexHighlight as ProtobufCustomIndexHighlight, + CustomRgbHighlight as ProtobufCustomRgbHighlight, DeleteLayoutPayload, + DeleteLayoutResponse as ProtobufDeleteLayoutResponse, DumpLayoutPayload, + DumpLayoutResponse as ProtobufDumpLayoutResponse, DumpSessionLayoutPayload, + DumpSessionLayoutResponse as ProtobufDumpSessionLayoutResponse, EditLayoutPayload, + EditLayoutResponse as ProtobufEditLayoutResponse, EditScrollbackForPaneWithIdPayload, + EmbedMultiplePanesPayload, EnvVariable, ExecCmdPayload, FixedOrPercent as ProtobufFixedOrPercent, FixedOrPercentValue as ProtobufFixedOrPercentValue, FloatMultiplePanesPayload, FloatingPaneCoordinates as ProtobufFloatingPaneCoordinates, @@ -48,10 +51,11 @@ pub use super::generated_api::api::{ GetTabInfoPayload, GetTabInfoResponse as ProtobufGetTabInfoResponse, GoToTabWithIdPayload, GroupAndUngroupPanesPayload, HideFloatingPanesPayload as ProtobufHideFloatingPanesPayload, HideFloatingPanesResponse as ProtobufHideFloatingPanesResponse, HidePaneWithIdPayload, - HighlightAndUnhighlightPanesPayload, HttpVerb as ProtobufHttpVerb, IdAndNewName, - KeyToRebind, KeyToUnbind, KillSessionsPayload, ListTokensResponse, LoadNewPluginPayload, - MessageToPluginPayload, MovePaneWithPaneIdInDirectionPayload, MovePaneWithPaneIdPayload, - MovePayload, NewPluginArgs as ProtobufNewPluginArgs, NewTabPayload, + HighlightAndUnhighlightPanesPayload, HighlightStyle as ProtobufHighlightStyle, + HttpVerb as ProtobufHttpVerb, IdAndNewName, KeyToRebind, KeyToUnbind, KillSessionsPayload, + ListTokensResponse, LoadNewPluginPayload, MessageToPluginPayload, + MovePaneWithPaneIdInDirectionPayload, MovePaneWithPaneIdPayload, MovePayload, + NewPluginArgs as ProtobufNewPluginArgs, NewTabPayload, NewTabResponse as ProtobufNewTabResponse, NewTabsResponse as ProtobufNewTabsResponse, NewTabsWithLayoutInfoPayload, OpenCommandPaneBackgroundResponse as ProtobufOpenCommandPaneBackgroundResponse, @@ -92,19 +96,19 @@ pub use super::generated_api::api::{ PageScrollDownInPaneIdPayload, PageScrollUpInPaneIdPayload, PaneId as ProtobufPaneId, PaneIdAndFloatingPaneCoordinates, PaneType as ProtobufPaneType, ParseLayoutPayload, ParseLayoutResponse as ProtobufParseLayoutResponse, PluginCommand as ProtobufPluginCommand, - PluginMessagePayload, RebindKeysPayload, ReconfigurePayload, ReloadPluginPayload, - RenameLayoutPayload, RenameLayoutResponse as ProtobufRenameLayoutResponse, - RenameTabWithIdPayload, RenameWebLoginTokenPayload, RenameWebTokenResponse, - ReplacePaneWithExistingPanePayload, RequestPluginPermissionPayload, - RerunCommandPanePayload, ResizePaneIdWithDirectionPayload, ResizePayload, - RevokeAllWebTokensResponse, RevokeTokenResponse, RevokeWebLoginTokenPayload, + PluginMessagePayload, RebindKeysPayload, ReconfigurePayload, + RegexHighlight as ProtobufRegexHighlight, ReloadPluginPayload, RenameLayoutPayload, + RenameLayoutResponse as ProtobufRenameLayoutResponse, RenameTabWithIdPayload, + RenameWebLoginTokenPayload, RenameWebTokenResponse, ReplacePaneWithExistingPanePayload, + RequestPluginPermissionPayload, RerunCommandPanePayload, ResizePaneIdWithDirectionPayload, + ResizePayload, RevokeAllWebTokensResponse, RevokeTokenResponse, RevokeWebLoginTokenPayload, RunActionPayload, RunCommandPayload, RunningCommand as ProtobufRunningCommand, SaveLayoutPayload, SaveLayoutResponse as ProtobufSaveLayoutResponse, SaveSessionPayload, SaveSessionResponse as ProtobufSaveSessionResponse, ScrollDownInPaneIdPayload, ScrollToBottomInPaneIdPayload, ScrollToTopInPaneIdPayload, ScrollUpInPaneIdPayload, SetFloatingPanePinnedPayload, SetPaneBorderlessPayload, SetPaneColorPayload, - SetSelfMouseSelectionSupportPayload, SetTimeoutPayload, ShowCursorPayload, - ShowFloatingPanesPayload as ProtobufShowFloatingPanesPayload, + SetPaneRegexHighlightsPayload, SetSelfMouseSelectionSupportPayload, SetTimeoutPayload, + ShowCursorPayload, ShowFloatingPanesPayload as ProtobufShowFloatingPanesPayload, ShowFloatingPanesResponse as ProtobufShowFloatingPanesResponse, ShowPaneWithIdPayload, StackPanesPayload, SubscribePayload, SwitchSessionPayload, SwitchTabToIdPayload, SwitchTabToPayload, TogglePaneBorderlessPayload, TogglePaneEmbedOrEjectForPaneIdPayload, @@ -118,8 +122,9 @@ pub use super::generated_api::api::{ use crate::data::{ ConnectToSession, DeleteLayoutResponse, EditLayoutResponse, FloatingPaneCoordinates, GetFocusedPaneInfoResponse, GetPaneCwdResponse, GetPanePidResponse, - GetPaneRunningCommandResponse, HttpVerb, InputMode, KeyWithModifier, MessageToPlugin, - NewPluginArgs, PaneId, PermissionType, PluginCommand, RenameLayoutResponse, SaveLayoutResponse, + GetPaneRunningCommandResponse, HighlightStyle, HttpVerb, InputMode, KeyWithModifier, + MessageToPlugin, NewPluginArgs, PaneId, PermissionType, PluginCommand, RegexHighlight, + RenameLayoutResponse, SaveLayoutResponse, }; use crate::input::actions::Action; use crate::input::layout::PercentOrFixed; @@ -2434,6 +2439,100 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand { }, _ => Err("Mismatched payload for SetPaneColor"), }, + Some(CommandName::SetPaneRegexHighlights) => { + match protobuf_plugin_command.payload { + Some(Payload::SetPaneRegexHighlightsPayload(p)) => { + let pane_id: PaneId = p + .pane_id + .ok_or("Missing pane_id in SetPaneRegexHighlights")? + .try_into()?; + let highlights = p + .highlights + .into_iter() + .map(|h| { + let style = match h.style.and_then(|s| s.style) { + Some(ProtobufHighlightStyleVariant::Emphasis0(_)) => { + HighlightStyle::Emphasis0 + }, + Some(ProtobufHighlightStyleVariant::Emphasis1(_)) => { + HighlightStyle::Emphasis1 + }, + Some(ProtobufHighlightStyleVariant::Emphasis2(_)) => { + HighlightStyle::Emphasis2 + }, + Some(ProtobufHighlightStyleVariant::Emphasis3(_)) => { + HighlightStyle::Emphasis3 + }, + Some(ProtobufHighlightStyleVariant::CustomRgb(c)) => { + HighlightStyle::CustomRgb { + fg: match (c.fg_r, c.fg_g, c.fg_b) { + (Some(r), Some(g), Some(b)) => { + Some((r as u8, g as u8, b as u8)) + }, + _ => None, + }, + bg: match (c.bg_r, c.bg_g, c.bg_b) { + (Some(r), Some(g), Some(b)) => { + Some((r as u8, g as u8, b as u8)) + }, + _ => None, + }, + } + }, + Some(ProtobufHighlightStyleVariant::CustomIndex(c)) => { + HighlightStyle::CustomIndex { + fg: c.fg.map(|v| v as u8), + bg: c.bg.map(|v| v as u8), + } + }, + Some(ProtobufHighlightStyleVariant::BackgroundEmphasis0(_)) => { + HighlightStyle::BackgroundEmphasis0 + }, + Some(ProtobufHighlightStyleVariant::BackgroundEmphasis1(_)) => { + HighlightStyle::BackgroundEmphasis1 + }, + Some(ProtobufHighlightStyleVariant::BackgroundEmphasis2(_)) => { + HighlightStyle::BackgroundEmphasis2 + }, + Some(ProtobufHighlightStyleVariant::BackgroundEmphasis3(_)) => { + HighlightStyle::BackgroundEmphasis3 + }, + Some(ProtobufHighlightStyleVariant::None(_)) => { + HighlightStyle::None + }, + None => HighlightStyle::Emphasis0, // fallback + }; + let context = h + .context + .into_iter() + .map(|item| (item.name, item.value)) + .collect(); + RegexHighlight { + pattern: h.pattern, + style, + context, + on_hover: h.on_hover, + bold: h.bold, + italic: h.italic, + underline: h.underline, + } + }) + .collect(); + Ok(PluginCommand::SetPaneRegexHighlights(pane_id, highlights)) + }, + _ => Err("Mismatched payload for SetPaneRegexHighlights"), + } + }, + Some(CommandName::ClearPaneHighlights) => match protobuf_plugin_command.payload { + Some(Payload::ClearPaneHighlightsPayload(p)) => { + let pane_id: PaneId = p + .pane_id + .ok_or("Missing pane_id in ClearPaneHighlights")? + .try_into()?; + Ok(PluginCommand::ClearPaneHighlights(pane_id)) + }, + _ => Err("Mismatched payload for ClearPaneHighlights"), + }, None => Err("Unrecognized plugin command"), } } @@ -3994,6 +4093,93 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand { bg, })), }), + PluginCommand::SetPaneRegexHighlights(pane_id, highlights) => { + Ok(ProtobufPluginCommand { + name: CommandName::SetPaneRegexHighlights as i32, + payload: Some(Payload::SetPaneRegexHighlightsPayload( + SetPaneRegexHighlightsPayload { + pane_id: pane_id.try_into().ok(), + highlights: highlights + .into_iter() + .map(|h| { + let style_variant = match h.style { + HighlightStyle::Emphasis0 => { + ProtobufHighlightStyleVariant::Emphasis0(true) + }, + HighlightStyle::Emphasis1 => { + ProtobufHighlightStyleVariant::Emphasis1(true) + }, + HighlightStyle::Emphasis2 => { + ProtobufHighlightStyleVariant::Emphasis2(true) + }, + HighlightStyle::Emphasis3 => { + ProtobufHighlightStyleVariant::Emphasis3(true) + }, + HighlightStyle::CustomRgb { fg, bg } => { + ProtobufHighlightStyleVariant::CustomRgb( + ProtobufCustomRgbHighlight { + fg_r: fg.map(|(r, _, _)| r as u32), + fg_g: fg.map(|(_, g, _)| g as u32), + fg_b: fg.map(|(_, _, b)| b as u32), + bg_r: bg.map(|(r, _, _)| r as u32), + bg_g: bg.map(|(_, g, _)| g as u32), + bg_b: bg.map(|(_, _, b)| b as u32), + }, + ) + }, + HighlightStyle::CustomIndex { fg, bg } => { + ProtobufHighlightStyleVariant::CustomIndex( + ProtobufCustomIndexHighlight { + fg: fg.map(|v| v as u32), + bg: bg.map(|v| v as u32), + }, + ) + }, + HighlightStyle::BackgroundEmphasis0 => { + ProtobufHighlightStyleVariant::BackgroundEmphasis0(true) + }, + HighlightStyle::BackgroundEmphasis1 => { + ProtobufHighlightStyleVariant::BackgroundEmphasis1(true) + }, + HighlightStyle::BackgroundEmphasis2 => { + ProtobufHighlightStyleVariant::BackgroundEmphasis2(true) + }, + HighlightStyle::BackgroundEmphasis3 => { + ProtobufHighlightStyleVariant::BackgroundEmphasis3(true) + }, + HighlightStyle::None => { + ProtobufHighlightStyleVariant::None(true) + }, + }; + ProtobufRegexHighlight { + pattern: h.pattern, + style: Some(ProtobufHighlightStyle { + style: Some(style_variant), + }), + context: h + .context + .into_iter() + .map(|(name, value)| ContextItem { name, value }) + .collect(), + on_hover: h.on_hover, + bold: h.bold, + italic: h.italic, + underline: h.underline, + } + }) + .collect(), + }, + )), + }) + }, + PluginCommand::ClearPaneHighlights(pane_id) => Ok(ProtobufPluginCommand { + name: CommandName::ClearPaneHighlights as i32, + payload: Some(Payload::ClearPaneHighlightsPayload( + ClearPaneHighlightsPayload { + pane_id: pane_id.try_into().ok(), + }, + )), + }), } } } 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 1684ff37..cffcf18a 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 @@ -6152,7 +6152,22 @@ Config { }, }, env: {}, - background_plugins: {}, + background_plugins: { + RunPlugin( + RunPlugin { + _allow_exec_host_cmd: false, + location: Zellij( + PluginTag( + "link", + ), + ), + configuration: PluginUserConfiguration( + {}, + ), + initial_cwd: None, + }, + ), + }, web_client: WebClientConfig { font: "monospace", theme: 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 01603ece..cb241bca 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 @@ -6156,7 +6156,22 @@ Config { "LAYOUT_ENV_VAR": "make sure I'm also here", "MY_ENV_VAR": "from layout", }, - background_plugins: {}, + background_plugins: { + RunPlugin( + RunPlugin { + _allow_exec_host_cmd: false, + location: Zellij( + PluginTag( + "link", + ), + ), + configuration: PluginUserConfiguration( + {}, + ), + initial_cwd: None, + }, + ), + }, web_client: WebClientConfig { font: "monospace", theme: 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 3ced7620..993a67ed 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 @@ -270,7 +270,22 @@ Config { }, }, env: {}, - background_plugins: {}, + background_plugins: { + RunPlugin( + RunPlugin { + _allow_exec_host_cmd: false, + location: Zellij( + PluginTag( + "link", + ), + ), + configuration: PluginUserConfiguration( + {}, + ), + initial_cwd: None, + }, + ), + }, web_client: WebClientConfig { font: "monospace", theme: 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 ac52b53f..14e0b053 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 @@ -7923,7 +7923,22 @@ Config { }, }, env: {}, - background_plugins: {}, + background_plugins: { + RunPlugin( + RunPlugin { + _allow_exec_host_cmd: false, + location: Zellij( + PluginTag( + "link", + ), + ), + configuration: PluginUserConfiguration( + {}, + ), + initial_cwd: None, + }, + ), + }, web_client: WebClientConfig { font: "monospace", theme: 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 927928e0..57125f2d 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 @@ -6152,7 +6152,22 @@ Config { }, }, env: {}, - background_plugins: {}, + background_plugins: { + RunPlugin( + RunPlugin { + _allow_exec_host_cmd: false, + location: Zellij( + PluginTag( + "link", + ), + ), + configuration: PluginUserConfiguration( + {}, + ), + initial_cwd: None, + }, + ), + }, web_client: WebClientConfig { font: "monospace", theme: None, |
