aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDave Willett <willett@hey.com>2026-07-08 09:47:15 -0700
committerGitHub <noreply@github.com>2026-07-08 18:47:15 +0200
commit01412c2e0999de5b40b68a9d1a731eea52844338 (patch)
tree8e57345f32224f9c087648f62e67436fe65b5d0a
parentchore(deps): bump cached from 1.1.0 to 2.0.2 (#269) (diff)
downloadsl0pstatus-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.md1
-rw-r--r--src/bin/zjstatus.rs120
-rw-r--r--src/config.rs2
-rw-r--r--src/pipe.rs6
-rw-r--r--src/widgets/command.rs116
5 files changed, 236 insertions, 9 deletions
diff --git a/README.md b/README.md
index 9941f90..dc0768f 100644
--- a/README.md
+++ b/README.md
@@ -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(),