aboutsummaryrefslogtreecommitdiffstats
path: root/default-plugins
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-03-05 21:13:02 +0100
committerGitHub <noreply@github.com>2026-03-05 21:13:02 +0100
commit903cdf5d32476a8d6cc208d2c418a1f2c67e5a53 (patch)
tree9a4647d9dc3efcdea8149fd78fdfa956b3c8d8cb /default-plugins
parentbuild: make test mirror build command for plugins (#4801) (diff)
downloadsl0ppij-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.rs18
-rw-r--r--default-plugins/link/.cargo/config.toml2
-rw-r--r--default-plugins/link/Cargo.toml9
-rw-r--r--default-plugins/link/src/main.rs459
-rw-r--r--default-plugins/strider/src/main.rs21
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 &current_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