aboutsummaryrefslogtreecommitdiff
path: root/src/config/tab_bar.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-10 01:20:09 +0300
committerGitHub <noreply@github.com>2026-08-10 01:20:09 +0300
commite48d83067a5a9b05dfd0a82761068866ab2d294d (patch)
tree159b92f857f85e40037879c7f32da4957093597b /src/config/tab_bar.rs
parente2aa86a9e8b6445488ef11b2c0a8b4c22a6f79ce (diff)
feat: add configurable tab bar status (#2586)
* feat: show a zoom indicator in the desktop tab bar Reserve the right edge of the tab row for a ZOOM pill while the focused pane is zoomed, matching the accent style of the mode bars. The per-tab Z suffix stays; the pill makes the zoomed state visible at a glance like tmux's status-right flag. * feat: optionally show the hostname in the desktop tab bar Add ui.tab_bar_hostname to display the machine's hostname at the right edge of the tab row, like tmux's #h in status-right. The value resolves where the server renders, so remote sessions show the remote host. Off by default. * fix: strip control characters from the tab bar hostname * fix: hide the hostname when it would squeeze out the tab strip * feat: add configurable tab bar status * fix: harden tab bar status updates * fix: terminate tab bar status process trees * fix: disable status commands on unsupported platforms * fix: skip unchanged status command renders * fix: keep tab bar status opt-in by default --------- Co-authored-by: David Heinemeier Hansson <david@hey.com>
Diffstat (limited to 'src/config/tab_bar.rs')
-rw-r--r--src/config/tab_bar.rs181
1 files changed, 181 insertions, 0 deletions
diff --git a/src/config/tab_bar.rs b/src/config/tab_bar.rs
new file mode 100644
index 00000000..253b1b74
--- /dev/null
+++ b/src/config/tab_bar.rs
@@ -0,0 +1,181 @@
+use serde::{Deserialize, Serialize};
+
+pub(crate) const DEFAULT_TAB_BAR_COMMAND_INTERVAL_SECONDS: u64 = 5;
+pub(crate) const DEFAULT_TAB_BAR_COMMAND_TIMEOUT_SECONDS: u64 = 2;
+pub(crate) const MAX_TAB_BAR_COMMAND_INTERVAL_SECONDS: u64 = 31_536_000;
+pub(crate) const MAX_TAB_BAR_COMMAND_TIMEOUT_SECONDS: u64 = 3_600;
+pub(crate) const MAX_TAB_BAR_RIGHT_ENTRIES: usize = 16;
+
+fn default_datetime_format() -> String {
+ "%H:%M".to_string()
+}
+
+fn default_command_interval_seconds() -> u64 {
+ DEFAULT_TAB_BAR_COMMAND_INTERVAL_SECONDS
+}
+
+fn default_command_timeout_seconds() -> u64 {
+ DEFAULT_TAB_BAR_COMMAND_TIMEOUT_SECONDS
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
+#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
+pub enum TabBarRightEntryConfig {
+ Zoom,
+ Hostname,
+ Datetime {
+ #[serde(default = "default_datetime_format")]
+ format: String,
+ },
+ Text {
+ text: String,
+ },
+ Command {
+ command: String,
+ #[serde(default = "default_command_interval_seconds")]
+ interval_seconds: u64,
+ #[serde(default = "default_command_timeout_seconds")]
+ timeout_seconds: u64,
+ },
+}
+
+pub(crate) fn parse_tab_bar_datetime_format(
+ value: &str,
+) -> Result<time::format_description::OwnedFormatItem, String> {
+ if value.is_empty() {
+ return Err("datetime format is empty".into());
+ }
+ let format = time::format_description::parse_strftime_owned(value)
+ .map_err(|err| format!("invalid datetime format: {err}"))?;
+ time::PrimitiveDateTime::MIN
+ .format(&format)
+ .map_err(|err| format!("unsupported datetime format: {err}"))?;
+ Ok(format)
+}
+
+pub(crate) fn tab_bar_right_diagnostics(entries: &[TabBarRightEntryConfig]) -> Vec<String> {
+ let mut diagnostics = Vec::new();
+ if entries.len() > MAX_TAB_BAR_RIGHT_ENTRIES {
+ diagnostics.push(format!(
+ "ui.tab_bar_right may contain at most {MAX_TAB_BAR_RIGHT_ENTRIES} entries; ignoring extras"
+ ));
+ }
+
+ for (index, entry) in entries.iter().enumerate().take(MAX_TAB_BAR_RIGHT_ENTRIES) {
+ match entry {
+ TabBarRightEntryConfig::Datetime { format } => {
+ if format.is_empty() {
+ diagnostics.push(format!(
+ "ui.tab_bar_right[{index}] datetime format is empty; hiding entry"
+ ));
+ } else if let Err(err) = parse_tab_bar_datetime_format(format) {
+ diagnostics.push(format!("ui.tab_bar_right[{index}] has {err}; hiding entry"));
+ }
+ }
+ TabBarRightEntryConfig::Command {
+ command,
+ interval_seconds,
+ timeout_seconds,
+ } => {
+ if command.trim().is_empty() {
+ diagnostics.push(format!(
+ "ui.tab_bar_right[{index}] command is empty; hiding entry"
+ ));
+ }
+ if *interval_seconds == 0 {
+ diagnostics.push(format!(
+ "ui.tab_bar_right[{index}] interval_seconds must be at least 1; hiding entry"
+ ));
+ }
+ if *interval_seconds > MAX_TAB_BAR_COMMAND_INTERVAL_SECONDS {
+ diagnostics.push(format!(
+ "ui.tab_bar_right[{index}] interval_seconds may be at most {MAX_TAB_BAR_COMMAND_INTERVAL_SECONDS}; hiding entry"
+ ));
+ }
+ if *timeout_seconds == 0 {
+ diagnostics.push(format!(
+ "ui.tab_bar_right[{index}] timeout_seconds must be at least 1; hiding entry"
+ ));
+ }
+ if *timeout_seconds > MAX_TAB_BAR_COMMAND_TIMEOUT_SECONDS {
+ diagnostics.push(format!(
+ "ui.tab_bar_right[{index}] timeout_seconds may be at most {MAX_TAB_BAR_COMMAND_TIMEOUT_SECONDS}; hiding entry"
+ ));
+ }
+ }
+ TabBarRightEntryConfig::Zoom
+ | TabBarRightEntryConfig::Hostname
+ | TabBarRightEntryConfig::Text { .. } => {}
+ }
+ }
+
+ diagnostics
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn tab_bar_entries_parse_with_command_defaults() {
+ #[derive(Deserialize)]
+ struct Wrapper {
+ entries: Vec<TabBarRightEntryConfig>,
+ }
+
+ let parsed: Wrapper = toml::from_str(
+ r#"
+entries = [
+ { type = "zoom" },
+ { type = "hostname" },
+ { type = "datetime", format = "%H:%M" },
+ { type = "text", text = "prod" },
+ { type = "command", command = "status.sh" },
+]
+"#,
+ )
+ .expect("parse tab bar entries");
+
+ assert_eq!(parsed.entries.len(), 5);
+ assert!(matches!(
+ &parsed.entries[4],
+ TabBarRightEntryConfig::Command {
+ interval_seconds: DEFAULT_TAB_BAR_COMMAND_INTERVAL_SECONDS,
+ timeout_seconds: DEFAULT_TAB_BAR_COMMAND_TIMEOUT_SECONDS,
+ ..
+ }
+ ));
+ }
+
+ #[test]
+ fn diagnostics_reject_invalid_datetime_and_command_schedules() {
+ let entries = vec![
+ TabBarRightEntryConfig::Datetime {
+ format: "%Q".into(),
+ },
+ TabBarRightEntryConfig::Datetime {
+ format: "%z".into(),
+ },
+ TabBarRightEntryConfig::Command {
+ command: String::new(),
+ interval_seconds: 0,
+ timeout_seconds: 0,
+ },
+ TabBarRightEntryConfig::Command {
+ command: "status.sh".into(),
+ interval_seconds: MAX_TAB_BAR_COMMAND_INTERVAL_SECONDS + 1,
+ timeout_seconds: MAX_TAB_BAR_COMMAND_TIMEOUT_SECONDS + 1,
+ },
+ ];
+
+ let diagnostics = tab_bar_right_diagnostics(&entries).join("\n");
+ assert!(diagnostics.contains("invalid datetime format"));
+ assert!(diagnostics.contains("unsupported datetime format"));
+ assert!(diagnostics.contains("command is empty"));
+ assert!(diagnostics.contains("interval_seconds must be at least 1"));
+ assert!(diagnostics.contains("interval_seconds may be at most"));
+ assert!(diagnostics.contains("timeout_seconds must be at least 1"));
+ assert!(diagnostics.contains("timeout_seconds may be at most"));
+ assert!(parse_tab_bar_datetime_format("").is_err());
+ }
+}