//! zj-pane-finder — a floating fuzzy finder for every pane in the session. //! //! Lists all panes across all tabs, fuzzy-filters them as you type, and focuses //! the selected one (switching tabs / unhiding floats as needed). //! //! Filtering matches pane metadata (tab, title, command, cwd) and, failing //! that, the visible CONTENTS of each pane -- so you can find a pane by //! something you can see in it rather than only by what it is called. //! //! The highlighted pane's live contents are previewed in the right-hand column //! (refreshed on selection change and once a second). //! //! Keys: type to filter · Up/Down or Ctrl-p/Ctrl-n or Tab to move //! Enter to jump · Ctrl-w clear word · Esc / Ctrl-c to close use std::collections::{BTreeMap, HashMap}; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use zellij_tile::prelude::*; /// truncate to a column budget, counting characters fn truncate(s: &str, width: usize) -> String { if s.chars().count() <= width { s.to_string() } else { s.chars().take(width.saturating_sub(1)).collect::() + "…" } } #[derive(Debug, Clone)] struct PaneEntry { id: u32, is_plugin: bool, tab_position: usize, tab_name: String, title: String, is_floating: bool, is_hidden: bool, is_focused_in_tab: bool, exited: bool, command: Option, cwd: Option, } impl PaneEntry { /// The command shown/searched next to the title, when it adds information fn extra_command(&self) -> Option<&str> { match self.command.as_deref() { Some(cmd) if !cmd.is_empty() && !self.title.contains(cmd) => Some(cmd), _ => None, } } /// The last component of the cwd, shown in the list when it adds information fn cwd_short(&self) -> Option<&str> { let cwd = self.cwd.as_deref()?; let last = cwd.rsplit('/').find(|c| !c.is_empty())?; if self.title.contains(last) { None } else { Some(last) } } /// Segments we fuzzy match against, in the order they appear in the haystack: /// tab name, title, command, full cwd. Anything matched here filters the list; /// only the segments actually drawn get match highlighting. fn search_segments(&self) -> Vec { let mut segments = vec![self.tab_name.clone(), self.title.clone()]; segments.push(self.extra_command().unwrap_or_default().to_string()); segments.push(self.cwd.clone().unwrap_or_default()); segments } fn search_text(&self) -> String { self.search_segments().join(" ") } } /// Byte-free (char-indexed) start offset of every segment inside `search_text()` fn segment_starts(segments: &[String]) -> Vec { let mut starts = vec![]; let mut at = 0; for segment in segments { starts.push(at); at += segment.chars().count() + 1; // +1 for the joining space } starts } /// Replace a leading $HOME with `~`, when we can tell what $HOME is fn tildify(path: &str, home: &Option) -> String { match home { Some(home) if !home.is_empty() && path.starts_with(home.as_str()) => { format!("~{}", &path[home.len()..]) }, _ => path.to_string(), } } #[derive(Default)] struct State { own_plugin_id: Option, tabs: Vec, panes: PaneManifest, search: String, selected: usize, permissions_granted: bool, /// cached results of the last filter pass, so render and Enter agree results: Vec<(PaneEntry, Vec, Option)>, /// contents of the currently highlighted pane: (pane id, is_plugin, lines) preview: Option<(u32, bool, Vec)>, /// cwd per terminal pane id, fetched once per pane and kept fresh via CwdChanged cwds: HashMap, home: Option, /// whether we last sized ourselves compact; only re-applied when it flips, /// so we do not fight the user moving or resizing us applied_compact: Option, /// viewport lines per pane id, used to search pane CONTENTS. Only populated /// while a search is active: pulling every pane's contents is far too much /// work to do while someone is merely browsing the list. bodies: HashMap>, } /// Where a query matched inside a pane's contents. #[derive(Debug, Clone)] struct BodyHit { /// index into the pane's cached body lines, so the preview can show it line_index: usize, /// char offsets within that line that the query matched, so the preview can /// highlight exactly what was hit indices: Vec, } /// minimum total width before we bother splitting off a preview column const MIN_COLS_FOR_PREVIEW: usize = 58; /// Below either of these we take the whole viewport and drop our frame. /// /// A centred float looks better, but it costs its margins (16% of the columns, /// 28% of the rows) plus a border, and on this fork also the pane gap -- which /// on an 80x24 terminal leaves about 63x14 to work with instead of 80x24. Rows /// are the scarce resource in a list, so that is most of the results gone to /// decoration. const COMPACT_BELOW_COLS: usize = 100; const COMPACT_BELOW_ROWS: usize = 30; const PREVIEW_REFRESH_SECS: f64 = 1.0; /// shown in the pane frame instead of the plugin's wasm URL const PANE_TITLE: &str = "pane finder"; /// Emphasis slot used to highlight the matched characters, in both the list and /// the preview. Maps to the theme's `emphasis_3` -- the brightest accent, and /// what this theme already uses to mean "this is the bit that matters". Plugin /// text is bold by default, so matches come out bold as well. const MATCH_COLOR: usize = 3; register_plugin!(State); impl ZellijPlugin for State { fn load(&mut self, _configuration: BTreeMap) { self.own_plugin_id = Some(get_plugin_ids().plugin_id); self.home = std::env::var("HOME").ok(); // rename early so we never flash the wasm URL as our title; this is a // fire-and-forget command, so it simply no-ops if permissions aren't // granted yet (we retry in configure_self once they are) if let Some(plugin_id) = self.own_plugin_id { rename_plugin_pane(plugin_id, PANE_TITLE); } request_permission(&[ PermissionType::ReadApplicationState, PermissionType::ChangeApplicationState, PermissionType::ReadPaneContents, ]); subscribe(&[ EventType::PermissionRequestResult, EventType::PaneUpdate, EventType::TabUpdate, EventType::Key, EventType::Timer, EventType::CwdChanged, ]); set_timeout(PREVIEW_REFRESH_SECS); } fn update(&mut self, event: Event) -> bool { match event { Event::PermissionRequestResult(status) => { self.permissions_granted = matches!(status, PermissionStatus::Granted); if self.permissions_granted { self.configure_self(); } self.refresh_preview(); true }, Event::Timer(_) => { // keep the preview live (eg. watch a build run while you pick) set_timeout(PREVIEW_REFRESH_SECS); let had_preview = self.preview.is_some(); self.refresh_preview(); had_preview || self.preview.is_some() }, Event::TabUpdate(tabs) => { self.tabs = tabs; // the viewport is only known from here, so this is also where a // terminal resize reaches us self.apply_self_geometry(); self.recompute(); true }, Event::PaneUpdate(manifest) => { // receiving this proves ReadApplicationState was granted self.permissions_granted = true; self.panes = manifest; self.refresh_cwds(); self.recompute(); true }, Event::CwdChanged(pane_id, new_cwd, _clients) => { if let PaneId::Terminal(id) = pane_id { self.cwds .insert(id, new_cwd.to_string_lossy().to_string()); self.recompute(); } true }, Event::Key(key) => self.handle_key(key), _ => false, } } fn render(&mut self, rows: usize, cols: usize) { // split off a preview column if we have the room for it let total_cols = cols; let (list_cols, preview_x, preview_cols) = if cols >= MIN_COLS_FOR_PREVIEW { let list_cols = (cols * 2 / 5).clamp(22, 52); (list_cols, list_cols + 2, cols.saturating_sub(list_cols + 2)) } else { (cols, 0, 0) }; let cols = list_cols; let prompt = format!(" {}", self.search); let mut prompt_text = Text::new(&prompt).color_range(3, 0..1); if self.search.is_empty() { prompt_text = Text::new(" type to filter panes…").dim_all(); } print_text_with_coordinates(prompt_text, 0, 0, Some(cols), None); let list_rows = rows.saturating_sub(3); if preview_cols > 0 { for row in 0..rows.saturating_sub(1) { print_text_with_coordinates( Text::new("│").dim_all(), list_cols + 1, row, Some(1), None, ); } self.render_preview(preview_x, 0, preview_cols, rows.saturating_sub(1)); } if self.results.is_empty() { print_text_with_coordinates( Text::new(" no matching panes").dim_all(), 0, 2, Some(cols), None, ); return; } // keep the selection inside the visible window let first_visible = self.selected.saturating_sub(list_rows.saturating_sub(1)); for (row, (index, (entry, match_indices, body_hit))) in self .results .iter() .enumerate() .skip(first_visible) .take(list_rows) .enumerate() { let marker = if entry.is_focused_in_tab { "*" } else { " " }; // build the line and remember where each searchable segment landed, so // fuzzy-match highlights map onto the right columns let mut line = format!(" {}[", marker); let mut render_starts: Vec> = vec![None; 4]; render_starts[0] = Some(line.chars().count()); line.push_str(&entry.tab_name); line.push_str("] "); render_starts[1] = Some(line.chars().count()); line.push_str(&entry.title); if let Some(cmd) = entry.extra_command() { line.push_str(" "); render_starts[2] = Some(line.chars().count()); line.push_str(cmd); } if let Some(cwd_short) = entry.cwd_short() { line.push_str(" "); line.push_str(cwd_short); } if entry.is_floating { line.push_str(" (floating)"); } if entry.is_hidden { // stacked behind another pane, or otherwise not on screen line.push_str(" (hidden)"); } if entry.exited { line.push_str(" (exited)"); } if entry.is_plugin { line.push_str(" (plugin)"); } if body_hit.is_some() { // matched on contents rather than on any of the names, so say so // -- otherwise the row looks like it has nothing to do with the // query. The preview jumps to the matching line. line.push_str(" ≡"); } let segments = entry.search_segments(); let starts = segment_starts(&segments); let highlight: Vec = match_indices .iter() .filter_map(|i| { let i = *i; // find the segment this match index falls into let (seg, offset) = segments.iter().enumerate().find_map(|(n, segment)| { let len = segment.chars().count(); (i >= starts[n] && i < starts[n] + len).then(|| (n, i - starts[n])) })?; // segments not drawn in the list (eg. the full cwd) filter but // don't highlight render_starts.get(seg).copied().flatten().map(|s| s + offset) }) .collect(); let mut text = Text::new(&line).color_indices(MATCH_COLOR, highlight); if index == self.selected { text = text.selected(); } if entry.exited { text = text.dim_all(); } print_text_with_coordinates(text, 0, row + 2, Some(cols), None); } let footer = format!( " {}/{} panes · Enter jump · Esc close", self.results.len(), self.all_entries().len() ); print_text_with_coordinates( Text::new(&footer).dim_all(), 0, rows.saturating_sub(1), Some(total_cols), None, ); } } impl State { fn all_entries(&self) -> Vec { let mut entries = vec![]; for (tab_position, panes) in &self.panes.panes { let tab_name = self .tabs .iter() .find(|t| t.position == *tab_position) .map(|t| t.name.clone()) .unwrap_or_else(|| format!("tab {}", tab_position + 1)); for pane in panes { // never list ourselves if pane.is_plugin && Some(pane.id) == self.own_plugin_id { continue; } // unselectable panes are UI furniture (status-bar, tab-bar, …) if !pane.is_selectable { continue; } // A suppressed PLUGIN is infrastructure -- zellij:link and // friends are deliberately invisible, and focusing one shows an // empty pane. Suppressed terminals are the opposite: in a // collapsed/stacked layout every stack member except the visible // one is suppressed, so skipping those left the finder able to // find only the pane already on screen. Focusing one swaps it // into view, switching tab if needed, via the host. if pane.is_suppressed && pane.is_plugin { continue; } entries.push(PaneEntry { id: pane.id, is_plugin: pane.is_plugin, tab_position: *tab_position, tab_name: tab_name.clone(), title: pane.title.clone(), is_floating: pane.is_floating, is_hidden: pane.is_suppressed, is_focused_in_tab: pane.is_focused, exited: pane.exited, command: pane.terminal_command.clone(), cwd: if pane.is_plugin { None } else { self.cwds.get(&pane.id).cloned() }, }); } } entries.sort_by(|a, b| { a.tab_position .cmp(&b.tab_position) .then(a.title.cmp(&b.title)) }); entries } fn recompute(&mut self) { let entries = self.all_entries(); if self.search.is_empty() { self.bodies.clear(); // stop paying to keep contents fresh self.results = entries.into_iter().map(|e| (e, vec![], None)).collect(); } else { self.refresh_bodies(); let matcher = SkimMatcherV2::default().use_cache(true); // (is_metadata_match, score) so that a pane whose NAME matches always // outranks one that merely contains the text somewhere let mut scored: Vec<(bool, i64, PaneEntry, Vec, Option)> = entries .into_iter() .filter_map(|entry| { if let Some((score, indices)) = matcher.fuzzy_indices(&entry.search_text(), &self.search) { return Some((true, score, entry, indices, None)); } let (score, hit) = self.match_body(&matcher, entry.id)?; Some((false, score, entry, vec![], Some(hit))) }) .collect(); scored.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1))); self.results = scored .into_iter() .map(|(_is_meta, _score, entry, indices, hit)| (entry, indices, hit)) .collect(); } if self.selected >= self.results.len() { self.selected = self.results.len().saturating_sub(1); } self.refresh_preview(); } /// Best-matching line of a pane's contents. /// /// Matched per LINE rather than against the whole viewport joined together: /// a fuzzy match over one big blob scores almost anything as a hit and the /// scores stop meaning much, whereas a line is a unit a human would /// recognise. It also tells us WHICH line matched, so the preview can show /// it instead of the usual tail. fn match_body(&self, matcher: &SkimMatcherV2, pane_id: u32) -> Option<(i64, BodyHit)> { let lines = self.bodies.get(&pane_id)?; lines .iter() .enumerate() .filter_map(|(line_index, line)| { matcher .fuzzy_indices(line, &self.search) .map(|(score, indices)| { ( score, BodyHit { line_index, indices, }, ) }) }) .max_by_key(|(score, _)| *score) } /// Pull every listed pane's viewport for content search. /// /// One host call per pane, so it only runs while a search is active, and is /// refreshed on the same timer that keeps the preview live. fn refresh_bodies(&mut self) { if !self.permissions_granted { return; } let targets: Vec<(u32, bool)> = self .panes .panes .values() .flatten() .filter(|p| !p.is_suppressed && p.is_selectable) .filter(|p| !(p.is_plugin && Some(p.id) == self.own_plugin_id)) .map(|p| (p.id, p.is_plugin)) .collect(); // Only fetch what we do not already have. This runs on every keystroke, // and re-pulling every pane's viewport each time is a host call per pane // per character. Contents may go slightly stale during one search, which // is a fair trade -- the preview is refreshed separately and stays live. let alive: Vec = targets.iter().map(|(id, _)| *id).collect(); for (id, is_plugin) in targets { if self.bodies.contains_key(&id) { continue; } let pane_id = if is_plugin { PaneId::Plugin(id) } else { PaneId::Terminal(id) }; if let Ok(contents) = get_pane_scrollback(pane_id, false) { let lines: Vec = contents .viewport .into_iter() .map(|l| l.trim_end().to_string()) .filter(|l| !l.is_empty()) .collect(); self.bodies.insert(id, lines); } } self.bodies.retain(|id, _| alive.contains(id)); } /// Learn the cwd of any terminal pane we haven't seen yet (one cheap call per /// new pane; kept up to date afterwards by CwdChanged events). fn refresh_cwds(&mut self) { if !self.permissions_granted { return; } let unknown: Vec = self .panes .panes .values() .flatten() .filter(|p| !p.is_plugin && !p.is_suppressed && p.is_selectable) .map(|p| p.id) .filter(|id| !self.cwds.contains_key(id)) .collect(); for id in unknown { if let Ok(cwd) = get_pane_cwd(PaneId::Terminal(id)) { self.cwds.insert(id, cwd.to_string_lossy().to_string()); } } // forget panes that no longer exist let alive: Vec = self .panes .panes .values() .flatten() .filter(|p| !p.is_plugin) .map(|p| p.id) .collect(); self.cwds.retain(|id, _| alive.contains(id)); } /// Pull the highlighted pane's viewport contents for the preview column. fn refresh_preview(&mut self) { if !self.permissions_granted { return; } let Some((entry, _, _)) = self.results.get(self.selected) else { self.preview = None; return; }; let (id, is_plugin) = (entry.id, entry.is_plugin); let pane_id = if is_plugin { PaneId::Plugin(id) } else { PaneId::Terminal(id) }; // never preview ourselves (would recurse into our own render) if is_plugin && Some(id) == self.own_plugin_id { self.preview = None; return; } match get_pane_scrollback(pane_id, false) { Ok(contents) => { // viewport lines are space-padded to the source pane's width let mut lines: Vec = contents .viewport .into_iter() .map(|l| l.trim_end().to_string()) .collect(); while lines.last().map(|l| l.is_empty()).unwrap_or(false) { lines.pop(); } self.preview = Some((id, is_plugin, lines)); }, Err(e) => { self.preview = Some((id, is_plugin, vec![format!("(no preview: {})", e)])); }, } } /// Which preview line to centre on, if the selected pane matched on contents. /// /// The hit indexes the SEARCH copy of the body, which drops blank lines, /// while the preview keeps them -- so match the text back rather than /// trusting the index across two different line lists. fn body_hit_line(&self, preview_lines: &[String]) -> Option<(usize, &[usize])> { let (entry, _, hit) = self.results.get(self.selected)?; let hit = hit.as_ref()?; let matched = self.bodies.get(&entry.id)?.get(hit.line_index)?; let row = preview_lines.iter().position(|l| l.trim_end() == matched)?; Some((row, &hit.indices)) } fn render_preview(&self, x: usize, y: usize, width: usize, height: usize) { let Some((_id, _is_plugin, lines)) = &self.preview else { return; }; let entry = self.results.get(self.selected).map(|(e, _, _)| e); let title = entry.map(|e| e.title.clone()).unwrap_or_default(); let cwd = entry .and_then(|e| e.cwd.as_deref()) .map(|c| tildify(c, &self.home)); let header = match &cwd { Some(cwd) => format!("preview · {} — {}", title, cwd), None => format!("preview · {}", title), }; print_text_with_coordinates( Text::new(&truncate(&header, width)).dim_all(), x, y, Some(width), None, ); let body_height = height.saturating_sub(1); if lines.is_empty() { print_text_with_coordinates( Text::new("(empty)").dim_all(), x, y + 1, Some(width), None, ); return; } // Normally show the tail (the most recent output), but when this pane // matched on its CONTENTS, show the matching line instead -- otherwise // the preview proves nothing about why the pane is in the list. let hit = self.body_hit_line(lines); let first = match hit { Some((line_index, _)) => line_index .saturating_sub(body_height / 2) .min(lines.len().saturating_sub(body_height)), None => lines.len().saturating_sub(body_height), }; for (row, line) in lines[first..].iter().enumerate() { let shown = truncate(line, width); let mut text = Text::new(&shown); if let Some((line_index, indices)) = hit { if first + row == line_index { // Truncated lines can drop columns off the end, so discard // any index past what is actually on screen -- colouring a // column that is not there is either lost or lands on the // ellipsis. let visible = shown.chars().count(); let highlight: Vec = indices.iter().copied().filter(|i| *i < visible).collect(); text = text.color_indices(MATCH_COLOR, highlight); } } print_text_with_coordinates(text, x, y + 1 + row, Some(width), None); } } /// Give ourselves a readable pane title and a generous size: the default title /// is the plugin's wasm URL and the default floating geometry is too narrow to /// fit the list plus a preview column. Both need ChangeApplicationState, so /// this runs once permissions are granted rather than at load time. fn configure_self(&mut self) { let Some(plugin_id) = self.own_plugin_id else { return; }; rename_plugin_pane(plugin_id, PANE_TITLE); self.apply_self_geometry(); } /// Usable size of the tab we are floating over. fn viewport(&self) -> Option<(usize, usize)> { let tab = self.tabs.iter().find(|tab| tab.active)?; Some((tab.viewport_columns, tab.viewport_rows)) } /// Size ourselves to the terminal: a centred float when there is room to /// spare, the whole viewport with no frame when there is not. /// /// Re-applied only when the decision actually flips, so resizing the /// terminal adapts but moving or resizing this pane by hand is left alone. fn apply_self_geometry(&mut self) { let Some(plugin_id) = self.own_plugin_id else { return; }; let Some((cols, rows)) = self.viewport() else { return; }; let compact = cols < COMPACT_BELOW_COLS || rows < COMPACT_BELOW_ROWS; if self.applied_compact == Some(compact) { return; } let coords = if compact { // borderless as well: a frame around something covering the whole // viewport is pure overhead, and this fork drops the pane gap for // frameless panes too FloatingPaneCoordinates::new( Some("0".to_owned()), Some("0".to_owned()), Some("100%".to_owned()), Some("100%".to_owned()), None, Some(true), ) } else { FloatingPaneCoordinates::new( Some("8%".to_owned()), Some("12%".to_owned()), Some("84%".to_owned()), Some("72%".to_owned()), None, Some(false), ) }; if let Some(coords) = coords { change_floating_panes_coordinates(vec![(PaneId::Plugin(plugin_id), coords)]); self.applied_compact = Some(compact); } } fn select_previous(&mut self) { let before = self.selected; self.selected = self.selected.saturating_sub(1); if self.selected != before { self.refresh_preview(); } } fn select_next(&mut self) { if self.selected + 1 < self.results.len() { self.selected += 1; self.refresh_preview(); } } fn handle_key(&mut self, key: KeyWithModifier) -> bool { let ctrl = key.key_modifiers.contains(&KeyModifier::Ctrl); match key.bare_key { BareKey::Esc => { close_self(); false }, BareKey::Char('c') if ctrl => { close_self(); false }, BareKey::Enter => { if let Some((entry, _, _)) = self.results.get(self.selected).cloned() { if entry.is_plugin { focus_plugin_pane(entry.id, false, false); } else { focus_terminal_pane(entry.id, false, false); } } close_self(); false }, BareKey::Up => { self.select_previous(); true }, BareKey::Down | BareKey::Tab => { self.select_next(); true }, BareKey::Char('p') if ctrl => { self.select_previous(); true }, BareKey::Char('n') if ctrl => { self.select_next(); true }, BareKey::Char('w') if ctrl => { while self.search.pop().map(|c| c != ' ').unwrap_or(false) {} self.recompute(); true }, BareKey::Char('u') if ctrl => { self.search.clear(); self.recompute(); true }, BareKey::Backspace => { self.search.pop(); self.recompute(); true }, BareKey::Char(c) if !ctrl => { self.search.push(c); self.selected = 0; self.recompute(); true }, _ => false, } } }