diff options
| author | Dave Willett <willett@hey.com> | 2026-07-08 09:47:15 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-08 18:47:15 +0200 |
| commit | 01412c2e0999de5b40b68a9d1a731eea52844338 (patch) | |
| tree | 8e57345f32224f9c087648f62e67436fe65b5d0a | |
| parent | chore(deps): bump cached from 1.1.0 to 2.0.2 (#269) (diff) | |
| download | sl0pstatus-01412c2e0999de5b40b68a9d1a731eea52844338.tar.gz sl0pstatus-01412c2e0999de5b40b68a9d1a731eea52844338.tar.xz sl0pstatus-01412c2e0999de5b40b68a9d1a731eea52844338.zip | |
feat(command): add {focused_pane_cwd} to run commands in focused cwd (#273)
* feat(command): add {focused_pane_cwd} to run commands in the focused pane's directory
Command widgets configured with command_<name>_cwd "{focused_pane_cwd}"
now execute in the current working directory of the focused terminal pane,
following it as the user changes directories.
This relies on zellij's CwdChanged event and get_pane_cwd API. The focused
terminal pane of the active tab is tracked via PaneUpdate (seeding the cwd
on focus change) and updated on CwdChanged. Affected command results are
invalidated so they re-run, and stale results from a previous cwd are
discarded to avoid showing the wrong directory.
Closes #92
* fix(command): release focused cwd command locks
Focused-cwd commands can be invalidated while their first run is still in flight. If that result is later discarded as stale, no command result is stored but the per-command lock remains, preventing future reruns.
Release the command lock when invalidating focused-cwd commands so the branch badge can recover on subsequent renders or cwd updates.
Add a regression test for rerunning a command with no stored result after its lock is released.
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | src/bin/zjstatus.rs | 120 | ||||
| -rw-r--r-- | src/config.rs | 2 | ||||
| -rw-r--r-- | src/pipe.rs | 6 | ||||
| -rw-r--r-- | src/widgets/command.rs | 116 |
5 files changed, 236 insertions, 9 deletions
@@ -146,6 +146,7 @@ layout { command_git_branch_format "#[fg=blue] {stdout} " command_git_branch_interval "10" command_git_branch_rendermode "static" + command_git_branch_cwd "{focused_pane_cwd}" datetime "#[fg=#6C7086,bold] {format} " datetime_format "%A, %d %b %Y %H:%M" diff --git a/src/bin/zjstatus.rs b/src/bin/zjstatus.rs index 9a9ae7b..f9d95c7 100644 --- a/src/bin/zjstatus.rs +++ b/src/bin/zjstatus.rs @@ -1,7 +1,7 @@ use zellij_tile::prelude::*; use chrono::Local; -use std::{collections::BTreeMap, sync::Arc}; +use std::{collections::BTreeMap, path::PathBuf, sync::Arc}; use uuid::Uuid; use zjstatus::{ @@ -31,6 +31,7 @@ struct State { userspace_configuration: BTreeMap<String, String>, module_config: config::ModuleConfig, widget_map: BTreeMap<String, Arc<dyn Widget>>, + focus_cwd_commands: Vec<String>, err: Option<anyhow::Error>, } @@ -77,6 +78,7 @@ impl ZellijPlugin for State { EventType::TabUpdate, EventType::SessionUpdate, EventType::RunCommandResult, + EventType::CwdChanged, ]); set_timeout(REFRESH_INTERVAL_SECONDS); @@ -88,6 +90,8 @@ impl ZellijPlugin for State { } }; self.widget_map = register_widgets(&configuration); + self.focus_cwd_commands = + zjstatus::widgets::command::focus_cwd_command_names(&configuration); self.userspace_configuration = configuration; self.pending_events = Vec::new(); self.got_permissions = false; @@ -105,6 +109,8 @@ impl ZellijPlugin for State { start_time: Local::now(), cache_mask: 0, incoming_notification: None, + focused_pane_id: None, + focused_pane_cwd: None, }; } @@ -180,6 +186,56 @@ impl ZellijPlugin for State { } impl State { + fn update_focused_pane(&mut self) { + let active_tab = self.state.tabs.iter().find(|t| t.active); + + let new_id = active_tab + .and_then(|tab| self.state.panes.panes.get(&tab.position)) + .and_then(|panes| panes.iter().find(|p| p.is_focused && !p.is_plugin)) + .map(|p| PaneId::Terminal(p.id)); + + if new_id == self.state.focused_pane_id { + return; + } + + self.state.focused_pane_id = new_id; + + let new_cwd = match new_id { + Some(pane_id) => match get_pane_cwd(pane_id) { + Ok(cwd) => Some(cwd), + Err(e) => { + tracing::debug!("could not get pane cwd: {e}"); + None + } + }, + None => None, + }; + + self.set_focused_pane_cwd(new_cwd); + } + + fn set_focused_pane_cwd(&mut self, new_cwd: Option<PathBuf>) -> bool { + if new_cwd == self.state.focused_pane_cwd { + return false; + } + + self.state.focused_pane_cwd = new_cwd; + + if self.focus_cwd_commands.is_empty() { + return false; + } + + self.invalidate_focus_cwd_commands(); + true + } + + fn invalidate_focus_cwd_commands(&mut self) { + for name in &self.focus_cwd_commands { + pipe::invalidate_command_result(&mut self.state, name); + zjstatus::widgets::command::release_command_lock(&self.state, name); + } + } + fn handle_event(&mut self, event: Event) -> bool { let mut should_render = false; match event { @@ -224,8 +280,21 @@ impl State { self.state.panes = pane_info; self.state.cache_mask = UpdateEventMask::Tab as u8; + self.update_focused_pane(); + should_render = true; } + Event::CwdChanged(pane_id, cwd, _clients) => { + tracing::Span::current().record("event_type", "Event::CwdChanged"); + tracing::debug!(pane_id = ?pane_id, cwd = ?cwd); + + if Some(pane_id) == self.state.focused_pane_id + && self.set_focused_pane_cwd(Some(cwd)) + { + self.state.cache_mask = UpdateEventMask::Command as u8; + should_render = true; + } + } Event::PermissionRequestResult(result) => { tracing::Span::current().record("event_type", "Event::PermissionRequestResult"); tracing::debug!(result = ?result); @@ -243,6 +312,13 @@ impl State { self.state.cache_mask = UpdateEventMask::Command as u8; if let Some(name) = context.get("name") { + if self.focus_cwd_commands.iter().any(|n| n == name) + && context.get("cwd").map(PathBuf::from) != self.state.focused_pane_cwd + { + tracing::debug!("discarding stale command result for {name}"); + return false; + } + let stdout = match String::from_utf8(stdout) { Ok(s) => s, Err(_) => "".to_owned(), @@ -343,3 +419,45 @@ fn register_widgets(configuration: &BTreeMap<String, String>) -> BTreeMap<String widget_map } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn set_focused_pane_cwd_only_invalidates_on_change() { + let mut state = State { + focus_cwd_commands: vec!["command_branch".to_owned()], + ..State::default() + }; + state.state.focused_pane_cwd = Some(PathBuf::from("/tmp")); + state.state.command_results.insert( + "command_branch".to_owned(), + CommandResult { + context: BTreeMap::from([( + "timestamp".to_owned(), + Local::now() + .format(zjstatus::widgets::command::TIMESTAMP_FORMAT) + .to_string(), + )]), + ..CommandResult::default() + }, + ); + + let original_timestamp = + state.state.command_results["command_branch"].context["timestamp"].clone(); + + assert!(!state.set_focused_pane_cwd(Some(PathBuf::from("/tmp")))); + assert_eq!( + state.state.command_results["command_branch"].context["timestamp"], + original_timestamp + ); + + assert!(state.set_focused_pane_cwd(Some(PathBuf::from("/var")))); + assert_eq!(state.state.focused_pane_cwd, Some(PathBuf::from("/var"))); + assert_ne!( + state.state.command_results["command_branch"].context["timestamp"], + original_timestamp + ); + } +} diff --git a/src/config.rs b/src/config.rs index 458ab7a..093d15e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,6 +24,8 @@ pub struct ZellijState { pub start_time: DateTime<Local>, pub incoming_notification: Option<notification::Message>, pub cache_mask: u8, + pub focused_pane_id: Option<PaneId>, + pub focused_pane_cwd: Option<std::path::PathBuf>, } #[derive(Clone, Debug, Ord, Eq, PartialEq, PartialOrd, Copy)] diff --git a/src/pipe.rs b/src/pipe.rs index 38fea5d..87fe48e 100644 --- a/src/pipe.rs +++ b/src/pipe.rs @@ -97,6 +97,12 @@ fn notify(state: &mut ZellijState, message: &str) { } fn rerun_command(state: &mut ZellijState, command_name: &str) { + invalidate_command_result(state, command_name); +} + +/// Backdates the stored timestamp of a command result so the next render +/// re-runs the command. +pub fn invalidate_command_result(state: &mut ZellijState, command_name: &str) { let command_result = state.command_results.get(command_name); if command_result.is_none() { diff --git a/src/widgets/command.rs b/src/widgets/command.rs index 0a8d019..304bf23 100644 --- a/src/widgets/command.rs +++ b/src/widgets/command.rs @@ -22,6 +22,8 @@ lazy_static! { static ref COMMAND_REGEX: Regex = Regex::new("_[a-zA-Z0-9]+$").unwrap(); } +const FOCUSED_PANE_CWD: &str = "{focused_pane_cwd}"; + #[derive(Clone, Debug, PartialEq)] enum RenderMode { Static, @@ -35,6 +37,7 @@ struct CommandConfig { format: Vec<FormattedPart>, env: Option<BTreeMap<String, String>>, cwd: Option<PathBuf>, + follow_focus_cwd: bool, interval: i64, render_mode: RenderMode, click_action: String, @@ -183,6 +186,15 @@ fn run_command_if_needed(command_config: CommandConfig, name: &str, state: &Zell let last_run = get_timestamp_from_event_or_default(name, state, command_config.interval); if ts.timestamp() - last_run.timestamp() >= command_config.interval { + let cwd = if command_config.follow_focus_cwd { + match &state.focused_pane_cwd { + Some(cwd) => Some(cwd.clone()), + None => return false, + } + } else { + command_config.cwd.clone() + }; + let mut context = BTreeMap::new(); context.insert("name".to_owned(), name.to_owned()); context.insert( @@ -190,16 +202,20 @@ fn run_command_if_needed(command_config: CommandConfig, name: &str, state: &Zell ts.format(TIMESTAMP_FORMAT).to_string(), ); + if let Some(cwd) = &cwd { + context.insert("cwd".to_owned(), cwd.to_string_lossy().into_owned()); + } + #[allow(unused_variables)] let command = commandline_parser(&command_config.command); tracing::debug!("Running command: {:?}", command); - if command_config.env.is_some() || command_config.cwd.is_some() { + if command_config.env.is_some() || cwd.is_some() { #[cfg(all(not(feature = "bench"), not(test)))] run_command_with_env_variables_and_cwd( &command.iter().map(|x| x.as_str()).collect::<Vec<&str>>(), - command_config.env.unwrap(), - command_config.cwd.unwrap(), + command_config.env.unwrap_or_default(), + cwd.unwrap_or_default(), context, ); @@ -218,6 +234,14 @@ fn run_command_if_needed(command_config: CommandConfig, name: &str, state: &Zell false } +pub fn focus_cwd_command_names(zj_conf: &BTreeMap<String, String>) -> Vec<String> { + parse_config(zj_conf) + .into_iter() + .filter(|(_, conf)| conf.follow_focus_cwd) + .map(|(name, _)| name) + .collect() +} + fn parse_config(zj_conf: &BTreeMap<String, String>) -> BTreeMap<String, CommandConfig> { let mut keys: Vec<String> = zj_conf .keys() @@ -235,6 +259,7 @@ fn parse_config(zj_conf: &BTreeMap<String, String>) -> BTreeMap<String, CommandC command: "".to_owned(), format: Vec::new(), cwd: None, + follow_focus_cwd: false, env: None, interval: 1, render_mode: RenderMode::Static, @@ -267,10 +292,18 @@ fn parse_config(zj_conf: &BTreeMap<String, String>) -> BTreeMap<String, CommandC } if key.ends_with("cwd") { - let mut cwd = PathBuf::new(); - cwd.push(zj_conf.get(&key).unwrap().to_owned().clone()); + let value = zj_conf.get(&key).unwrap().to_owned(); + + if value == FOCUSED_PANE_CWD { + command_conf.follow_focus_cwd = true; + command_conf.cwd = None; + } else { + let mut cwd = PathBuf::new(); + cwd.push(value); - command_conf.cwd = Some(cwd); + command_conf.cwd = Some(cwd); + command_conf.follow_focus_cwd = false; + } } if key.ends_with("format") { @@ -349,7 +382,7 @@ fn get_timestamp_from_event_or_default( let ts_context = ts_context.unwrap(); if Local::now().timestamp() - state.start_time.timestamp() < interval { - release(name, state.clone()); + release_command_lock(state, name); } match DateTime::parse_from_str(ts_context, TIMESTAMP_FORMAT) { @@ -370,7 +403,7 @@ fn lock(name: &str, state: ZellijState) -> bool { true } -fn release(name: &str, state: ZellijState) { +pub fn release_command_lock(state: &ZellijState, name: &str) { let path = format!("/tmp/{}.{}.lock", state.plugin_uuid, name); if Path::new(&path).exists() { @@ -436,6 +469,30 @@ mod test { use rstest::rstest; #[test] + pub fn test_focused_pane_cwd_parsing() { + let conf = BTreeMap::from([ + ("command_branch_command".to_owned(), "git branch".to_owned()), + ( + "command_branch_cwd".to_owned(), + "{focused_pane_cwd}".to_owned(), + ), + ("command_static_command".to_owned(), "ls".to_owned()), + ("command_static_cwd".to_owned(), "/tmp".to_owned()), + ]); + + let config = parse_config(&conf); + assert!(config.get("command_branch").unwrap().follow_focus_cwd); + assert_eq!(config.get("command_branch").unwrap().cwd, None); + assert!(!config.get("command_static").unwrap().follow_focus_cwd); + assert_eq!( + config.get("command_static").unwrap().cwd, + Some(PathBuf::from("/tmp")) + ); + + assert_eq!(focus_cwd_command_names(&conf), vec!["command_branch"]); + } + + #[test] pub fn test_commandline_parser() { let input = "pwd"; let result = commandline_parser(input); @@ -453,6 +510,48 @@ mod test { assert_eq!(result, expected); } + #[test] + pub fn test_release_command_lock_allows_rerun_without_result() { + let state = ZellijState { + plugin_uuid: "release_command_lock_test".to_owned(), + ..ZellijState::default() + }; + let command_config = CommandConfig { + command: "echo test".to_owned(), + format: Vec::new(), + env: None, + cwd: None, + follow_focus_cwd: false, + interval: 1, + render_mode: RenderMode::Static, + click_action: "".to_owned(), + hide_on_empty_stdout: false, + }; + + release_command_lock(&state, "test_release"); + + assert!(run_command_if_needed( + command_config.clone(), + "test_release", + &state + )); + assert!(!run_command_if_needed( + command_config.clone(), + "test_release", + &state + )); + + release_command_lock(&state, "test_release"); + + assert!(run_command_if_needed( + command_config, + "test_release", + &state + )); + + release_command_lock(&state, "test_release"); + } + #[rstest] // no result, interval 1 second #[case(1, &ZellijState::default(), true)] @@ -499,6 +598,7 @@ mod test { format: Vec::new(), env: None, cwd: None, + follow_focus_cwd: false, interval, render_mode: RenderMode::Static, click_action: "".to_owned(), |
