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 /default-plugins | |
| 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
Diffstat (limited to 'default-plugins')
| -rw-r--r-- | default-plugins/fixture-plugin-for-tests/src/main.rs | 18 | ||||
| -rw-r--r-- | default-plugins/link/.cargo/config.toml | 2 | ||||
| -rw-r--r-- | default-plugins/link/Cargo.toml | 9 | ||||
| -rw-r--r-- | default-plugins/link/src/main.rs | 459 | ||||
| -rw-r--r-- | default-plugins/strider/src/main.rs | 21 |
5 files changed, 505 insertions, 4 deletions
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 |
