aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs3
-rw-r--r--src/app/api.rs15
-rw-r--r--src/app/input/navigate.rs2
-rw-r--r--src/app/mod.rs55
-rw-r--r--src/app/runtime.rs2
-rw-r--r--src/app/state.rs10
-rw-r--r--src/app/tab_bar_status.rs590
7 files changed, 673 insertions, 4 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 20deab2a..5d8051fd 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -1748,7 +1748,7 @@ impl AppState {
let layout = crate::ui::compute_tab_bar_view(
ws,
- area,
+ crate::ui::tab_bar_content_area(self, area),
self.tab_scroll,
self.tab_scroll_follow_active,
self.mouse_capture,
@@ -2935,6 +2935,7 @@ impl AppState {
}
AppEvent::WorktreeAddFinished(_) => Vec::new(),
AppEvent::WorktreeRemoveFinished(_) => Vec::new(),
+ AppEvent::TabBarCommandFinished { .. } => Vec::new(),
AppEvent::PluginCommandFinished { .. } => Vec::new(),
}
}
diff --git a/src/app/api.rs b/src/app/api.rs
index 0ccd9b21..379369ae 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -64,6 +64,11 @@ impl App {
results,
cache_updates,
} => self.handle_git_status_refreshed(results, cache_updates),
+ AppEvent::TabBarCommandFinished {
+ generation,
+ segment_index,
+ result,
+ } => self.handle_tab_bar_command_finished(generation, segment_index, result),
ev @ AppEvent::TerminalBell { .. } => {
self.handle_internal_event(ev);
false
@@ -144,6 +149,16 @@ impl App {
return;
}
+ if let AppEvent::TabBarCommandFinished {
+ generation,
+ segment_index,
+ result,
+ } = ev
+ {
+ let _ = self.handle_tab_bar_command_finished(generation, segment_index, result);
+ return;
+ }
+
if let AppEvent::PluginCommandFinished {
log_id,
finished_unix_ms,
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 73dc5299..d4d714a9 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -858,7 +858,7 @@ impl App {
)
}
- fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) {
+ pub(crate) fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) {
let mut env = vec![(
crate::api::SOCKET_PATH_ENV_VAR.to_string(),
crate::api::socket_path().display().to_string(),
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 15f352af..cca530ce 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -22,6 +22,7 @@ mod runtime;
mod runtime_mutations;
mod session;
pub mod state;
+mod tab_bar_status;
mod terminal_targets;
mod terminal_titles;
mod theme_sync;
@@ -139,6 +140,10 @@ pub struct App {
pub(crate) session_save_deadline: Option<Instant>,
pub(crate) session_save_thread: Option<std::thread::JoinHandle<()>>,
pub(crate) detached_custom_command_children: Vec<std::process::Child>,
+ tab_bar_status_generation: u64,
+ tab_bar_datetimes: Vec<tab_bar_status::TabBarDatetimeRuntime>,
+ tab_bar_commands: Vec<tab_bar_status::TabBarCommandRuntime>,
+ next_tab_bar_datetime_refresh: Option<Instant>,
pub(crate) persist_pane_history: bool,
pub(crate) last_render_at: Option<Instant>,
pub(crate) input_leases: input::InputLeaseTable,
@@ -642,6 +647,8 @@ impl App {
show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders,
hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab,
tab_bar_position: config.ui.tab_bar_position,
+ tab_bar_right: Vec::new(),
+ tab_bar_right_separator: String::new(),
pane_history_persistence: config.experimental.pane_history,
reveal_hidden_cursor_for_cjk_ime: config.experimental.reveal_hidden_cursor_for_cjk_ime,
cjk_ime_agent_filter_configured: !config.experimental.cjk_ime_agents.is_empty(),
@@ -723,7 +730,7 @@ impl App {
.and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id)))
});
- Self {
+ let mut app = Self {
config_diagnostic_deadline: None,
toast_deadline: None,
copy_feedback_deadline: None,
@@ -762,6 +769,10 @@ impl App {
session_save_deadline: None,
session_save_thread: None,
detached_custom_command_children: Vec::new(),
+ tab_bar_status_generation: 0,
+ tab_bar_datetimes: Vec::new(),
+ tab_bar_commands: Vec::new(),
+ next_tab_bar_datetime_refresh: None,
selection_autoscroll_deadline: None,
selection_highlight_clear_deadline: None,
persist_pane_history: config.experimental.pane_history,
@@ -781,7 +792,9 @@ impl App {
local_input_source_switch: true,
config_reloaded_from_disk: false,
prefix_input_source: Box::new(crate::platform::RealPrefixInputSource::default()),
- }
+ };
+ app.configure_tab_bar_status(&config.ui.tab_bar_right, &config.ui.tab_bar_right_separator);
+ app
}
#[cfg(unix)]
@@ -1421,6 +1434,9 @@ impl App {
diagnostics.push(format!("{diagnostic}; keeping previous [ui] settings"));
} else {
diagnostics.extend(config.ui.sound.diagnostics());
+ diagnostics.extend(crate::config::tab_bar_right_diagnostics(
+ &config.ui.tab_bar_right,
+ ));
self.state.default_sidebar_width = config.ui.sidebar_width;
if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault {
@@ -1460,6 +1476,10 @@ impl App {
config.ui.show_agent_labels_on_pane_borders;
self.state.hide_tab_bar_when_single_tab = config.ui.hide_tab_bar_when_single_tab;
self.state.tab_bar_position = config.ui.tab_bar_position;
+ self.configure_tab_bar_status(
+ &config.ui.tab_bar_right,
+ &config.ui.tab_bar_right_separator,
+ );
self.state.agent_panel_sort =
agent_panel_sort_from_config(config.ui.agent_panel_sort);
self.state.status_indicators = config.ui.status_indicators;
@@ -2332,6 +2352,37 @@ mod tests {
}
#[test]
+ fn tab_bar_command_events_render_only_when_visible_output_changes() {
+ if !crate::platform::status_commands_supported() {
+ return;
+ }
+
+ let mut app = test_app();
+ app.configure_tab_bar_status(
+ &[crate::config::TabBarRightEntryConfig::Command {
+ command: "status".into(),
+ interval_seconds: 5,
+ timeout_seconds: 2,
+ }],
+ " ",
+ );
+ let generation = app.tab_bar_status_generation;
+ let event = |generation, output: Option<&str>| AppEvent::TabBarCommandFinished {
+ generation,
+ segment_index: 0,
+ result: Ok(output.map(str::to_string)),
+ };
+
+ assert!(!app.handle_internal_event_with_prefix_sync(event(generation, None)));
+ assert!(app.handle_internal_event_with_prefix_sync(event(generation, Some("ready"))));
+ assert!(!app.handle_internal_event_with_prefix_sync(event(generation, Some("ready"))));
+ assert!(!app.handle_internal_event_with_prefix_sync(event(
+ generation.wrapping_add(1),
+ Some("stale"),
+ )));
+ }
+
+ #[test]
fn git_status_event_clears_in_flight_refresh() {
let mut app = test_app();
app.git_refresh_in_flight = true;
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 31426f87..9551055e 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -385,6 +385,7 @@ impl App {
}
changed |= self.expire_due_metadata(now);
+ changed |= self.handle_tab_bar_status_tasks(now);
if geometry_dirty || resized {
self.pending_agent_resume_deadline = None;
@@ -610,6 +611,7 @@ impl App {
self.session_save_deadline,
self.selection_autoscroll_deadline,
self.selection_highlight_clear_deadline,
+ self.next_tab_bar_status_deadline(),
render_deadline,
]
.into_iter()
diff --git a/src/app/state.rs b/src/app/state.rs
index e460f62b..cbecef00 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1317,6 +1317,12 @@ pub(crate) struct PaneFocusTarget {
/// All application state — pure data, no channels or async runtime.
/// Testable without PTYs or a tokio runtime.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum TabBarStatusSegment {
+ Zoom,
+ Text(Option<String>),
+}
+
pub struct AppState {
pub terminals:
std::collections::HashMap<crate::terminal::TerminalId, crate::terminal::TerminalState>,
@@ -1432,6 +1438,8 @@ pub struct AppState {
pub show_agent_labels_on_pane_borders: bool,
pub hide_tab_bar_when_single_tab: bool,
pub tab_bar_position: TabBarPositionConfig,
+ pub tab_bar_right: Vec<TabBarStatusSegment>,
+ pub tab_bar_right_separator: String,
pub pane_history_persistence: bool,
/// Expose the focused pane's cursor anchor to the outer terminal even when
/// the pane requested `?25l`. See `[experimental] reveal_hidden_cursor_for_cjk_ime`.
@@ -1798,6 +1806,8 @@ impl AppState {
show_agent_labels_on_pane_borders: false,
hide_tab_bar_when_single_tab: false,
tab_bar_position: TabBarPositionConfig::Top,
+ tab_bar_right: Vec::new(),
+ tab_bar_right_separator: " ".into(),
pane_history_persistence: false,
reveal_hidden_cursor_for_cjk_ime: false,
cjk_ime_agent_filter_configured: false,
diff --git a/src/app/tab_bar_status.rs b/src/app/tab_bar_status.rs
new file mode 100644
index 00000000..54903db6
--- /dev/null
+++ b/src/app/tab_bar_status.rs
@@ -0,0 +1,590 @@
+use std::{process::Stdio, time::Duration};
+
+use tokio::io::AsyncReadExt;
+
+use super::{state::TabBarStatusSegment, App};
+use crate::config::TabBarRightEntryConfig;
+
+const DATETIME_REFRESH_INTERVAL: Duration = Duration::from_secs(1);
+const MAX_COMMAND_LINE_BYTES: usize = 4096;
+const MAX_STATUS_TEXT_CHARS: usize = 80;
+
+pub(super) struct TabBarDatetimeRuntime {
+ segment_index: usize,
+ format: time::format_description::OwnedFormatItem,
+}
+
+pub(super) struct TabBarCommandRuntime {
+ segment_index: usize,
+ command: String,
+ interval: Duration,
+ timeout: Duration,
+ next_run_at: std::time::Instant,
+ task: Option<tokio::task::AbortHandle>,
+}
+
+impl Drop for TabBarCommandRuntime {
+ fn drop(&mut self) {
+ if let Some(task) = self.task.take() {
+ task.abort();
+ }
+ }
+}
+
+impl App {
+ pub(super) fn configure_tab_bar_status(
+ &mut self,
+ entries: &[TabBarRightEntryConfig],
+ separator: &str,
+ ) {
+ self.tab_bar_status_generation = self.tab_bar_status_generation.wrapping_add(1);
+ self.tab_bar_datetimes.clear();
+ self.tab_bar_commands.clear();
+ self.state.tab_bar_right.clear();
+ self.state.tab_bar_right_separator = sanitize_separator(separator);
+
+ let now = std::time::Instant::now();
+ for entry in entries
+ .iter()
+ .take(crate::config::MAX_TAB_BAR_RIGHT_ENTRIES)
+ {
+ match entry {
+ TabBarRightEntryConfig::Zoom => {
+ self.state.tab_bar_right.push(TabBarStatusSegment::Zoom);
+ }
+ TabBarRightEntryConfig::Hostname => {
+ self.state
+ .tab_bar_right
+ .push(TabBarStatusSegment::Text(sanitize_status_text(
+ crate::platform::hostname().as_deref().unwrap_or_default(),
+ )));
+ }
+ TabBarRightEntryConfig::Datetime { format } => {
+ let Ok(format) = crate::config::parse_tab_bar_datetime_format(format) else {
+ continue;
+ };
+ let value = format_local_datetime(&format);
+ let segment_index = self.state.tab_bar_right.len();
+ self.state
+ .tab_bar_right
+ .push(TabBarStatusSegment::Text(value));
+ self.tab_bar_datetimes.push(TabBarDatetimeRuntime {
+ segment_index,
+ format,
+ });
+ }
+ TabBarRightEntryConfig::Text { text } => {
+ self.state
+ .tab_bar_right
+ .push(TabBarStatusSegment::Text(sanitize_literal_text(text)));
+ }
+ TabBarRightEntryConfig::Command {
+ command,
+ interval_seconds,
+ timeout_seconds,
+ } => {
+ if !crate::platform::status_commands_supported()
+ || command.trim().is_empty()
+ || *interval_seconds == 0
+ || *interval_seconds > crate::config::MAX_TAB_BAR_COMMAND_INTERVAL_SECONDS
+ || *timeout_seconds == 0
+ || *timeout_seconds > crate::config::MAX_TAB_BAR_COMMAND_TIMEOUT_SECONDS
+ {
+ continue;
+ }
+ let segment_index = self.state.tab_bar_right.len();
+ self.state
+ .tab_bar_right
+ .push(TabBarStatusSegment::Text(None));
+ self.tab_bar_commands.push(TabBarCommandRuntime {
+ segment_index,
+ command: command.clone(),
+ interval: Duration::from_secs(*interval_seconds),
+ timeout: Duration::from_secs(*timeout_seconds),
+ next_run_at: now,
+ task: None,
+ });
+ }
+ }
+ }
+
+ self.next_tab_bar_datetime_refresh =
+ (!self.tab_bar_datetimes.is_empty()).then_some(now + DATETIME_REFRESH_INTERVAL);
+ }
+
+ pub(crate) fn handle_tab_bar_status_tasks(&mut self, now: std::time::Instant) -> bool {
+ let mut changed = false;
+
+ if self
+ .next_tab_bar_datetime_refresh
+ .is_some_and(|deadline| now >= deadline)
+ {
+ for runtime in &self.tab_bar_datetimes {
+ let value = format_local_datetime(&runtime.format);
+ if let Some(TabBarStatusSegment::Text(current)) =
+ self.state.tab_bar_right.get_mut(runtime.segment_index)
+ {
+ changed |= *current != value;
+ *current = value;
+ }
+ }
+ self.next_tab_bar_datetime_refresh = Some(now + DATETIME_REFRESH_INTERVAL);
+ }
+
+ let command_due = self
+ .tab_bar_commands
+ .iter()
+ .any(|runtime| runtime.task.is_none() && now >= runtime.next_run_at);
+ if !command_due {
+ return changed;
+ }
+
+ let generation = self.tab_bar_status_generation;
+ let (environment, cwd) = self.custom_command_env();
+ for runtime in &mut self.tab_bar_commands {
+ if runtime.task.is_some() || now < runtime.next_run_at {
+ continue;
+ }
+ runtime.next_run_at = now.checked_add(runtime.interval).unwrap_or(now);
+ runtime.task = Some(spawn_status_command(
+ self.event_tx.clone(),
+ generation,
+ runtime.segment_index,
+ runtime.command.clone(),
+ runtime.timeout,
+ environment.clone(),
+ cwd.clone(),
+ ));
+ }
+
+ changed
+ }
+
+ pub(crate) fn next_tab_bar_status_deadline(&self) -> Option<std::time::Instant> {
+ self.tab_bar_commands
+ .iter()
+ .filter(|runtime| runtime.task.is_none())
+ .map(|runtime| runtime.next_run_at)
+ .chain(self.next_tab_bar_datetime_refresh)
+ .min()
+ }
+
+ pub(super) fn handle_tab_bar_command_finished(
+ &mut self,
+ generation: u64,
+ segment_index: usize,
+ result: Result<Option<String>, String>,
+ ) -> bool {
+ if generation != self.tab_bar_status_generation {
+ return false;
+ }
+ let Some(runtime) = self
+ .tab_bar_commands
+ .iter_mut()
+ .find(|runtime| runtime.segment_index == segment_index)
+ else {
+ return false;
+ };
+ runtime.task = None;
+
+ let output = match result {
+ Ok(output) => output,
+ Err(error) => {
+ tracing::warn!(command = %runtime.command, error, "tab bar status command failed");
+ None
+ }
+ };
+ let Some(TabBarStatusSegment::Text(current)) =
+ self.state.tab_bar_right.get_mut(segment_index)
+ else {
+ return false;
+ };
+ let changed = *current != output;
+ *current = output;
+ changed
+ }
+}
+
+fn format_local_datetime(format: &time::format_description::OwnedFormatItem) -> Option<String> {
+ let datetime = crate::platform::local_datetime()?;
+ datetime
+ .format(format)
+ .ok()
+ .and_then(|value| sanitize_status_text(&value))
+}
+
+fn sanitize_separator(value: &str) -> String {
+ value
+ .chars()
+ .filter(|character| !character.is_control())
+ .collect()
+}
+
+fn sanitize_literal_text(value: &str) -> Option<String> {
+ let value: String = value
+ .chars()
+ .filter(|character| !character.is_control())
+ .collect();
+ (!value.is_empty()).then_some(value)
+}
+
+fn sanitize_status_text(value: &str) -> Option<String> {
+ let value: String = value
+ .trim()
+ .chars()
+ .filter(|character| !character.is_control() && !is_unicode_format_control(*character))
+ .take(MAX_STATUS_TEXT_CHARS)
+ .collect();
+ (!value.is_empty()).then_some(value)
+}
+
+fn is_unicode_format_control(character: char) -> bool {
+ matches!(
+ character,
+ '\u{00ad}'
+ | '\u{0600}'..='\u{0605}'
+ | '\u{061c}'
+ | '\u{06dd}'
+ | '\u{070f}'
+ | '\u{0890}'..='\u{0891}'
+ | '\u{08e2}'
+ | '\u{17b4}'..='\u{17b5}'
+ | '\u{180e}'
+ | '\u{200b}'..='\u{200f}'
+ | '\u{202a}'..='\u{202e}'
+ | '\u{2060}'..='\u{206f}'
+ | '\u{feff}'
+ | '\u{fff9}'..='\u{fffb}'
+ | '\u{110bd}'
+ | '\u{110cd}'
+ | '\u{13430}'..='\u{1343f}'
+ | '\u{1bca0}'..='\u{1bca3}'
+ | '\u{1d173}'..='\u{1d17a}'
+ | '\u{e0001}'
+ | '\u{e0020}'..='\u{e007f}'
+ )
+}
+
+fn command_output_text(output: &[u8]) -> Option<String> {
+ let output = String::from_utf8_lossy(output);
+ output.lines().next_back().and_then(sanitize_status_text)
+}
+
+async fn read_last_output_line(
+ mut stdout: tokio::process::ChildStdout,
+) -> std::io::Result<Vec<u8>> {
+ let mut current_line = Vec::new();
+ let mut last_line = Vec::new();
+ let mut ended_with_newline = false;
+ let mut buffer = [0_u8; 1024];
+
+ loop {
+ let count = stdout.read(&mut buffer).await?;
+ if count == 0 {
+ break;
+ }
+ for &byte in &buffer[..count] {
+ if byte == b'\n' {
+ last_line = std::mem::take(&mut current_line);
+ ended_with_newline = true;
+ } else {
+ if current_line.len() < MAX_COMMAND_LINE_BYTES {
+ current_line.push(byte);
+ }
+ ended_with_newline = false;
+ }
+ }
+ }
+
+ Ok(if ended_with_newline {
+ last_line
+ } else {
+ current_line
+ })
+}
+
+fn spawn_status_command(
+ event_tx: tokio::sync::mpsc::Sender<crate::events::AppEvent>,
+ generation: u64,
+ segment_index: usize,
+ command: String,
+ timeout: Duration,
+ environment: Vec<(String, String)>,
+ cwd: Option<std::path::PathBuf>,
+) -> tokio::task::AbortHandle {
+ let task = tokio::spawn(async move {
+ let mut process = crate::platform::detached_custom_command_process(&command);
+ process
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::null())
+ .envs(environment);
+ if let Some(cwd) = cwd {
+ process.current_dir(cwd);
+ }
+ crate::platform::configure_status_command(&mut process);
+
+ let mut process = tokio::process::Command::from(process);
+ process.kill_on_drop(true);
+ let operation = async move {
+ let mut child = process.spawn().map_err(|error| error.to_string())?;
+ let _guard = crate::platform::StatusCommandGuard::new(&child)
+ .map_err(|error| error.to_string())?;
+ let stdout = child.stdout.take();
+ let read_output = async {
+ let Some(stdout) = stdout else {
+ return std::io::Result::Ok(Vec::new());
+ };
+ read_last_output_line(stdout).await
+ };
+ let (status, output) = tokio::join!(child.wait(), read_output);
+ let status = status.map_err(|error| error.to_string())?;
+ let output = output.map_err(|error| error.to_string())?;
+ if status.success() {
+ Ok(command_output_text(&output))
+ } else {
+ Err(format!("exited with {status}"))
+ }
+ };
+ let result = match tokio::time::timeout(timeout, operation).await {
+ Ok(result) => result,
+ Err(_) => Err(format!("timed out after {}s", timeout.as_secs())),
+ };
+ let _ = event_tx
+ .send(crate::events::AppEvent::TabBarCommandFinished {
+ generation,
+ segment_index,
+ result,
+ })
+ .await;
+ });
+ task.abort_handle()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{config::Config, events::AppEvent};
+
+ fn test_app() -> App {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ )
+ }
+
+ #[cfg(unix)]
+ const MULTILINE_COMMAND: &str = "printf 'old\\nfinal\\n'";
+ #[cfg(windows)]
+ const MULTILINE_COMMAND: &str = "echo old & echo final";
+
+ #[cfg(unix)]
+ const OVER_CAP_COMMAND: &str = "head -c 5000 /dev/zero | tr '\\0' x; printf '\\nREADY\\n'";
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ fn unique_temp_path(name: &str) -> std::path::PathBuf {
+ let stamp = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock after epoch")
+ .as_nanos();
+ std::path::PathBuf::from("/var/tmp").join(format!(
+ "herdr-tab-status-{name}-{}-{stamp}",
+ std::process::id()
+ ))
+ }
+
+ #[tokio::test]
+ async fn status_command_reports_its_sanitized_last_line() {
+ let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(1);
+ spawn_status_command(
+ event_tx,
+ 7,
+ 3,
+ MULTILINE_COMMAND.into(),
+ Duration::from_secs(2),
+ Vec::new(),
+ None,
+ );
+
+ let event = tokio::time::timeout(Duration::from_secs(3), event_rx.recv())
+ .await
+ .expect("status command timed out")
+ .expect("status command event channel closed");
+ assert!(matches!(
+ event,
+ AppEvent::TabBarCommandFinished {
+ generation: 7,
+ segment_index: 3,
+ result: Ok(Some(ref output)),
+ } if output == "final"
+ ));
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn status_command_drains_large_output_and_keeps_the_last_line() {
+ let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(1);
+ spawn_status_command(
+ event_tx,
+ 7,
+ 3,
+ OVER_CAP_COMMAND.into(),
+ Duration::from_secs(2),
+ Vec::new(),
+ None,
+ );
+
+ let event = tokio::time::timeout(Duration::from_secs(3), event_rx.recv())
+ .await
+ .expect("status command timed out")
+ .expect("status command event channel closed");
+ assert!(matches!(
+ event,
+ AppEvent::TabBarCommandFinished {
+ result: Ok(Some(ref output)),
+ ..
+ } if output == "READY"
+ ));
+ }
+
+ #[test]
+ fn stale_command_result_does_not_replace_reloaded_status() {
+ let mut app = test_app();
+ app.configure_tab_bar_status(
+ &[TabBarRightEntryConfig::Command {
+ command: MULTILINE_COMMAND.into(),
+ interval_seconds: 5,
+ timeout_seconds: 2,
+ }],
+ " ",
+ );
+ let stale_generation = app.tab_bar_status_generation;
+ app.configure_tab_bar_status(
+ &[TabBarRightEntryConfig::Text {
+ text: "fresh".into(),
+ }],
+ " ",
+ );
+
+ app.handle_tab_bar_command_finished(stale_generation, 0, Ok(Some("stale".into())));
+
+ assert_eq!(
+ app.state.tab_bar_right,
+ vec![TabBarStatusSegment::Text(Some("fresh".into()))]
+ );
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[tokio::test]
+ async fn reload_aborts_an_in_flight_command_task_and_its_descendants() {
+ let started = unique_temp_path("started");
+ let survived = unique_temp_path("survived");
+ let command = format!(
+ "printf started > {}; (sleep 0.3; printf survived > {}) & wait",
+ started.display(),
+ survived.display()
+ );
+ let mut app = test_app();
+ app.configure_tab_bar_status(
+ &[TabBarRightEntryConfig::Command {
+ command,
+ interval_seconds: 5,
+ timeout_seconds: 20,
+ }],
+ " ",
+ );
+ app.handle_tab_bar_status_tasks(std::time::Instant::now());
+ for _ in 0..50 {
+ if started.exists() {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ assert!(started.exists(), "status command did not start");
+
+ app.configure_tab_bar_status(
+ &[TabBarRightEntryConfig::Text {
+ text: "reloaded".into(),
+ }],
+ " ",
+ );
+
+ assert!(
+ tokio::time::timeout(Duration::from_millis(100), app.event_rx.recv())
+ .await
+ .is_err()
+ );
+ tokio::time::sleep(Duration::from_millis(400)).await;
+ assert!(!survived.exists(), "status command descendant survived");
+ let _ = std::fs::remove_file(started);
+ let _ = std::fs::remove_file(survived);
+ }
+
+ #[tokio::test]
+ async fn in_flight_command_has_no_second_deadline() {
+ let mut app = test_app();
+ app.configure_tab_bar_status(
+ &[TabBarRightEntryConfig::Command {
+ command: MULTILINE_COMMAND.into(),
+ interval_seconds: 5,
+ timeout_seconds: 2,
+ }],
+ " ",
+ );
+
+ let now = std::time::Instant::now();
+ assert!(app.next_tab_bar_status_deadline().is_some());
+ app.handle_tab_bar_status_tasks(now);
+
+ assert!(app.tab_bar_commands[0].task.is_some());
+ assert_eq!(app.next_tab_bar_status_deadline(), None);
+ }
+
+ #[test]
+ fn datetime_refresh_updates_its_segment_once_per_deadline() {
+ let mut app = test_app();
+ app.configure_tab_bar_status(
+ &[TabBarRightEntryConfig::Datetime {
+ format: "%Y-%m-%d %H:%M:%S".into(),
+ }],
+ " ",
+ );
+ app.state.tab_bar_right[0] = TabBarStatusSegment::Text(None);
+ let deadline = app
+ .next_tab_bar_datetime_refresh
+ .expect("datetime refresh deadline");
+
+ assert!(app.handle_tab_bar_status_tasks(deadline));
+ assert!(matches!(
+ &app.state.tab_bar_right[0],
+ TabBarStatusSegment::Text(Some(value)) if !value.is_empty()
+ ));
+ assert!(!app.handle_tab_bar_status_tasks(deadline));
+ }
+
+ #[test]
+ fn command_output_uses_sanitized_last_line() {
+ assert_eq!(
+ command_output_text(b"old\n win\x1b[31mter\r\n"),
+ Some("win[31mter".into())
+ );
+ assert_eq!(command_output_text(b"\r\n"), None);
+ }
+
+ #[test]
+ fn status_text_strips_bidi_and_zero_width_format_controls() {
+ assert_eq!(
+ sanitize_status_text("safe\u{202e}evil\u{200b}"),
+ Some("safeevil".into())
+ );
+ }
+
+ #[test]
+ fn separator_preserves_printable_spacing_and_drops_controls() {
+ assert_eq!(sanitize_separator(" \x1b|\n "), " | ");
+ }
+}