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>
This commit is contained in:
parent
e2aa86a9e8
commit
e48d83067a
|
|
@ -692,6 +692,7 @@ dependencies = [
|
|||
"serde_ignored",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"time",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
|
|
@ -1852,12 +1853,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1866,14 +1869,29 @@ version = "0.1.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ serde = { version = "1", features = ["derive"] }
|
|||
serde_ignored = "0.1.14"
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
time = { version = "0.3.47", features = ["formatting"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util"] }
|
||||
toml = "0.8"
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
## Unreleased
|
||||
|
||||
### Added
|
||||
- The desktop tab bar now has configurable right-aligned status entries for zoom state, hostname, date/time, literal text, and asynchronously refreshed command output.
|
||||
- Optional `keys.resize_pane_left`, `keys.resize_pane_down`, `keys.resize_pane_up`, and `keys.resize_pane_right` bindings now resize the focused pane in one keystroke without entering resize mode.
|
||||
- Devin CLI, Cursor Agent CLI, MastraCode, Hermes Agent, and Grok CLI integrations now install and run natively on Windows.
|
||||
- Panes can now route normal right-click gestures to mouse-reporting applications through the pane menu, `herdr pane input`, `pane.input.set`, or the `pane split --right-click pane` launch option.
|
||||
|
|
|
|||
|
|
@ -269,6 +269,26 @@ The sidebar is the main Herdr dashboard. Search `ui.` in the [Config reference](
|
|||
|
||||
Set `tab_bar_position = "bottom"` under `[ui]` to place the desktop tab row below the terminal panes. Prefix, Navigate, Copy, and Resize mode bars temporarily replace the bottom tab row while active. The default is `"top"`.
|
||||
|
||||
Configure an ordered tmux-style status area at the right edge of the tab row:
|
||||
|
||||
```toml
|
||||
[ui]
|
||||
tab_bar_right = [
|
||||
{ type = "zoom" },
|
||||
{ type = "hostname" },
|
||||
{ type = "datetime", format = "%H:%M" },
|
||||
{ type = "text", text = "prod" },
|
||||
{ type = "command", command = "~/.config/herdr/status.sh", interval_seconds = 5, timeout_seconds = 2 },
|
||||
]
|
||||
tab_bar_right_separator = " · "
|
||||
```
|
||||
|
||||
The status area is empty by default. Add `zoom` to show a fixed `ZOOM` pill while the active tab is zoomed; the existing per-tab `Z` markers remain independent. `hostname`, `datetime`, and `command` resolve on the Herdr server, so `herdr --remote` shows the remote machine's values. Datetime entries use `strftime` formatting; directives that require a UTC offset or Unix timestamp, such as `%z` and `%s`, are rejected because the value is server-local wall-clock time.
|
||||
|
||||
Command entries run immediately and then at `interval_seconds` without blocking rendering or overlapping a previous run. The interval can be 1–31,536,000 seconds and the timeout can be 1–3,600 seconds. Herdr uses the last line of successful output, clears it after failure, empty output, or `timeout_seconds`, and provides the same active workspace, tab, pane, socket, binary, and working-directory context as custom command keybindings. Commands are supported on Linux, macOS, and Windows, using `/bin/sh -lc` on Linux and macOS and `cmd.exe /d /c` on Windows.
|
||||
|
||||
Separators appear only between visible entries. Set `tab_bar_right_separator = ""` for direct concatenation. On a narrow tab row, the complete status area yields to the tabs and their controls.
|
||||
|
||||
Agent status uses compact colored dots by default. To distinguish blocked, working, done, idle, and unknown states by shape as well as color, choose **distinct symbols** in Settings or configure:
|
||||
|
||||
```toml
|
||||
|
|
|
|||
|
|
@ -722,6 +722,25 @@
|
|||
"bottom"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ui.tab_bar_right",
|
||||
"type": "array",
|
||||
"default": "[]",
|
||||
"description": "Configure ordered right-aligned tab bar entries. Supported types are zoom, hostname, datetime, text, and command.",
|
||||
"values": [
|
||||
"zoom",
|
||||
"hostname",
|
||||
"datetime",
|
||||
"text",
|
||||
"command"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ui.tab_bar_right_separator",
|
||||
"type": "string",
|
||||
"default": "\" \"",
|
||||
"description": "Text inserted between visible right-aligned tab bar entries."
|
||||
},
|
||||
{
|
||||
"key": "ui.agent_panel_sort",
|
||||
"type": "enum",
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ SKIPPED_SUBTREES = ("keys.command",)
|
|||
FIELD_RE = re.compile(r"^\s*pub ([a-z_][a-z0-9_]*):\s*(.+?),?\s*$")
|
||||
STRUCT_RE = re.compile(r"^\s*pub(?:\(crate\))? struct ([A-Za-z0-9_]+)\s*\{\s*$")
|
||||
ENUM_RE = re.compile(r"^\s*pub(?:\(crate\))? enum ([A-Za-z0-9_]+)\s*\{\s*$")
|
||||
VARIANT_RE = re.compile(r"^\s*([A-Z][A-Za-z0-9_]*)\s*(?:\(.*\))?\s*,?\s*$")
|
||||
VARIANT_RE = re.compile(
|
||||
r"^\s*([A-Z][A-Za-z0-9_]*)\s*(?:\(.*\)|\{)?\s*,?\s*$"
|
||||
)
|
||||
RENAME_ALL_RE = re.compile(r'rename_all\s*=\s*"([^"]+)"')
|
||||
RENAME_RE = re.compile(r'rename\s*=\s*"([^"]+)"')
|
||||
|
||||
|
|
@ -190,11 +192,11 @@ def parse_enum_body(
|
|||
index += 1
|
||||
break
|
||||
|
||||
depth += stripped.count("{") - stripped.count("}")
|
||||
if depth == 0 and not stripped.startswith(("#[", "///")):
|
||||
match = VARIANT_RE.match(stripped)
|
||||
if match:
|
||||
variants.append(apply_rename_all(match.group(1), rename_all or "lowercase"))
|
||||
depth += stripped.count("{") - stripped.count("}")
|
||||
index += 1
|
||||
|
||||
model.enums[name] = variants
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ pub struct UiConfig {
|
|||
pub sidebar_width: u16,
|
||||
/// Host cursor policy. Default: auto.
|
||||
pub host_cursor: HostCursorModeConfig,
|
||||
/// Tab bar status entries.
|
||||
pub tab_bar_right: Vec<TabBarRightEntryConfig>,
|
||||
#[serde(rename = "accent_color")]
|
||||
pub accent: String,
|
||||
#[serde(skip)]
|
||||
|
|
@ -63,6 +65,19 @@ pub enum HostCursorModeConfig {
|
|||
Drawn,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum TabBarRightEntryConfig {
|
||||
Hostname,
|
||||
Datetime {
|
||||
format: String,
|
||||
},
|
||||
Command {
|
||||
command: String,
|
||||
interval_seconds: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum BindingConfig {
|
||||
|
|
@ -129,6 +144,10 @@ class CollectKeysTests(unittest.TestCase):
|
|||
self.assertEqual(
|
||||
entries["ui.host_cursor"]["values"], ["auto", "native-cursor", "drawn"]
|
||||
)
|
||||
self.assertEqual(
|
||||
entries["ui.tab_bar_right"]["values"],
|
||||
["hostname", "datetime", "command"],
|
||||
)
|
||||
self.assertNotIn("values", entries["keys.zoom"])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -2331,6 +2351,37 @@ mod tests {
|
|||
assert!(!app.git_refresh_in_flight);
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 "), " | ");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ mod keybinds;
|
|||
mod model;
|
||||
mod sidebar;
|
||||
mod sound;
|
||||
mod tab_bar;
|
||||
mod theme;
|
||||
|
||||
pub use self::{
|
||||
|
|
@ -30,11 +31,20 @@ pub use self::{
|
|||
SpaceSidebarToken, SpacesSidebarConfig,
|
||||
},
|
||||
sound::SoundConfig,
|
||||
tab_bar::TabBarRightEntryConfig,
|
||||
theme::{parse_color, CustomThemeColors, ThemeConfig, THEME_NAMES},
|
||||
};
|
||||
|
||||
pub(crate) use self::keybinds::parse_key_combo;
|
||||
pub(crate) use self::{io::upsert_top_level_bool, theme::canonical_theme_name};
|
||||
pub(crate) use self::{
|
||||
io::upsert_top_level_bool,
|
||||
tab_bar::{
|
||||
parse_tab_bar_datetime_format, tab_bar_right_diagnostics,
|
||||
MAX_TAB_BAR_COMMAND_INTERVAL_SECONDS, MAX_TAB_BAR_COMMAND_TIMEOUT_SECONDS,
|
||||
MAX_TAB_BAR_RIGHT_ENTRIES,
|
||||
},
|
||||
theme::canonical_theme_name,
|
||||
};
|
||||
|
||||
pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH";
|
||||
pub const DEFAULT_SCROLLBACK_LIMIT_BYTES: usize = 10_000_000;
|
||||
|
|
@ -74,6 +84,7 @@ impl Config {
|
|||
.chain(self.remote_image_paste_key().err())
|
||||
.chain(self.theme.diagnostics())
|
||||
.chain(self.ui.sound.diagnostics())
|
||||
.chain(tab_bar_right_diagnostics(&self.ui.tab_bar_right))
|
||||
.chain(self.invalid_sidebar_bounds_diagnostic())
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ use serde::{de, Deserialize, Deserializer, Serialize};
|
|||
|
||||
use super::{
|
||||
ActionKeybinds, BindingConfig, CommandKeybindConfig, IndexedKeybind, Keybinds, SidebarConfig,
|
||||
SoundConfig, ThemeConfig, DEFAULT_MOBILE_WIDTH_THRESHOLD, DEFAULT_MOUSE_SCROLL_LINES,
|
||||
DEFAULT_SCROLLBACK_LIMIT_BYTES,
|
||||
SoundConfig, TabBarRightEntryConfig, ThemeConfig, DEFAULT_MOBILE_WIDTH_THRESHOLD,
|
||||
DEFAULT_MOUSE_SCROLL_LINES, DEFAULT_SCROLLBACK_LIMIT_BYTES,
|
||||
};
|
||||
|
||||
pub const MAX_TOAST_DELAY_SECONDS: u64 = 3600;
|
||||
|
|
@ -874,6 +874,10 @@ pub struct UiConfig {
|
|||
pub hide_tab_bar_when_single_tab: bool,
|
||||
/// Desktop tab row placement. Default: top.
|
||||
pub tab_bar_position: TabBarPositionConfig,
|
||||
/// Ordered entries shown at the right edge of the desktop tab row. Empty by default.
|
||||
pub tab_bar_right: Vec<TabBarRightEntryConfig>,
|
||||
/// Text inserted between visible right-side tab bar entries. Default: one space.
|
||||
pub tab_bar_right_separator: String,
|
||||
/// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces".
|
||||
pub agent_panel_sort: AgentPanelSortConfig,
|
||||
/// Retired setting that Herdr wrote before the workspace filter was removed.
|
||||
|
|
@ -1088,6 +1092,8 @@ impl Default for UiConfig {
|
|||
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(),
|
||||
agent_panel_sort: AgentPanelSortConfig::Spaces,
|
||||
_legacy_agent_panel_scope: None,
|
||||
status_indicators: StatusIndicatorStyle::Dots,
|
||||
|
|
@ -1344,6 +1350,8 @@ status_indicators = "symbols"
|
|||
default_config.ui.tab_bar_position,
|
||||
TabBarPositionConfig::Top
|
||||
);
|
||||
assert!(default_config.ui.tab_bar_right.is_empty());
|
||||
assert_eq!(default_config.ui.tab_bar_right_separator, " ");
|
||||
|
||||
let toml = r#"
|
||||
[ui]
|
||||
|
|
@ -1354,6 +1362,14 @@ pane_gaps = true
|
|||
show_agent_labels_on_pane_borders = true
|
||||
hide_tab_bar_when_single_tab = true
|
||||
tab_bar_position = "bottom"
|
||||
tab_bar_right = [
|
||||
{ type = "zoom" },
|
||||
{ type = "hostname" },
|
||||
{ type = "datetime", format = "%H:%M" },
|
||||
{ type = "text", text = "prod" },
|
||||
{ type = "command", command = "status.sh", interval_seconds = 10, timeout_seconds = 3 },
|
||||
]
|
||||
tab_bar_right_separator = " · "
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
assert!(!config.ui.pane_borders);
|
||||
|
|
@ -1363,6 +1379,12 @@ tab_bar_position = "bottom"
|
|||
assert!(config.ui.show_agent_labels_on_pane_borders);
|
||||
assert!(config.ui.hide_tab_bar_when_single_tab);
|
||||
assert_eq!(config.ui.tab_bar_position, TabBarPositionConfig::Bottom);
|
||||
assert_eq!(config.ui.tab_bar_right.len(), 5);
|
||||
assert!(matches!(
|
||||
config.ui.tab_bar_right[1],
|
||||
TabBarRightEntryConfig::Hostname
|
||||
));
|
||||
assert_eq!(config.ui.tab_bar_right_separator, " · ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -146,6 +146,12 @@ pub enum AppEvent {
|
|||
results: Vec<WorkspaceGitStatus>,
|
||||
cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>,
|
||||
},
|
||||
/// A configured tab bar status command finished.
|
||||
TabBarCommandFinished {
|
||||
generation: u64,
|
||||
segment_index: usize,
|
||||
result: Result<Option<String>, String>,
|
||||
},
|
||||
/// A plugin action or event command finished.
|
||||
PluginCommandFinished {
|
||||
log_id: String,
|
||||
|
|
|
|||
|
|
@ -332,6 +332,12 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
|
|||
# Desktop tab row placement: "top" or "bottom".
|
||||
# tab_bar_position = "top"
|
||||
|
||||
# Ordered status entries at the right edge of the desktop tab bar.
|
||||
# Supported types: zoom, hostname, datetime, text, and command.
|
||||
# Hostname, datetime, and command entries resolve on the Herdr server.
|
||||
# tab_bar_right = []
|
||||
# tab_bar_right_separator = " "
|
||||
|
||||
# Agent panel ordering: "spaces" (grouped by space) or "priority" (attention queue).
|
||||
# "workspaces" is accepted as an alias for "spaces".
|
||||
# agent_panel_sort = "spaces"
|
||||
|
|
|
|||
|
|
@ -89,6 +89,28 @@ pub(crate) fn should_draw_host_cursor_by_default() -> bool {
|
|||
false
|
||||
}
|
||||
|
||||
pub(crate) fn hostname() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn local_datetime() -> Option<time::PrimitiveDateTime> {
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn status_commands_supported() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn configure_status_command(_process: &mut std::process::Command) {}
|
||||
|
||||
pub(crate) struct StatusCommandGuard;
|
||||
|
||||
impl StatusCommandGuard {
|
||||
pub(crate) fn new(_child: &tokio::process::Child) -> std::io::Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_command_argv(command: &str, flag: &str) -> Vec<std::ffi::OsString> {
|
||||
vec!["/bin/sh".into(), flag.into(), command.into()]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ use super::{
|
|||
};
|
||||
|
||||
pub(crate) use super::unix_common::{
|
||||
create_remote_private_dir, create_remote_ssh_config_dir, create_remote_ssh_config_file,
|
||||
remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument,
|
||||
remote_reattach_program, remote_ssh_config_paths,
|
||||
configure_status_command, create_remote_private_dir, create_remote_ssh_config_dir,
|
||||
create_remote_ssh_config_file, hostname, local_datetime, remote_bridge_endpoint_path,
|
||||
remote_private_temp_base, remote_reattach_argument, remote_reattach_program,
|
||||
remote_ssh_config_paths, status_commands_supported, StatusCommandGuard,
|
||||
};
|
||||
|
||||
const WSL_MARKER_ENV_VARS: &[&str] = &["WSL_DISTRO_NAME", "WSL_INTEROP"];
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ use super::{
|
|||
};
|
||||
|
||||
pub(crate) use super::unix_common::{
|
||||
create_remote_private_dir, create_remote_ssh_config_dir, create_remote_ssh_config_file,
|
||||
remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument,
|
||||
remote_reattach_program, remote_ssh_config_paths,
|
||||
configure_status_command, create_remote_private_dir, create_remote_ssh_config_dir,
|
||||
create_remote_ssh_config_file, hostname, local_datetime, remote_bridge_endpoint_path,
|
||||
remote_private_temp_base, remote_reattach_argument, remote_reattach_program,
|
||||
remote_ssh_config_paths, status_commands_supported, StatusCommandGuard,
|
||||
};
|
||||
|
||||
const PROC_PGRP_ONLY: u32 = 2;
|
||||
|
|
|
|||
|
|
@ -119,6 +119,90 @@ fn fits_unix_socket_path(path: &Path) -> bool {
|
|||
path.as_os_str().as_bytes().len() <= 103
|
||||
}
|
||||
|
||||
/// The machine's node name, as shown by tmux's `#h`.
|
||||
pub(crate) fn hostname() -> Option<String> {
|
||||
let mut buffer = [0_u8; 256];
|
||||
let result =
|
||||
unsafe { libc::gethostname(buffer.as_mut_ptr().cast::<libc::c_char>(), buffer.len()) };
|
||||
if result != 0 {
|
||||
return None;
|
||||
}
|
||||
let end = buffer
|
||||
.iter()
|
||||
.position(|&byte| byte == 0)
|
||||
.unwrap_or(buffer.len());
|
||||
let name = String::from_utf8_lossy(&buffer[..end]).into_owned();
|
||||
(!name.is_empty()).then_some(name)
|
||||
}
|
||||
|
||||
pub(crate) fn local_datetime() -> Option<time::PrimitiveDateTime> {
|
||||
let mut timestamp: libc::time_t = 0;
|
||||
if unsafe { libc::time(&mut timestamp) } == -1 {
|
||||
return None;
|
||||
}
|
||||
let mut local: libc::tm = unsafe { std::mem::zeroed() };
|
||||
if unsafe { libc::localtime_r(×tamp, &mut local) }.is_null() {
|
||||
return None;
|
||||
}
|
||||
datetime_from_tm(&local)
|
||||
}
|
||||
|
||||
pub(crate) fn status_commands_supported() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn configure_status_command(process: &mut std::process::Command) {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
process.process_group(0);
|
||||
}
|
||||
|
||||
pub(crate) struct StatusCommandGuard {
|
||||
process_group_id: Option<i32>,
|
||||
}
|
||||
|
||||
impl StatusCommandGuard {
|
||||
pub(crate) fn new(child: &tokio::process::Child) -> std::io::Result<Self> {
|
||||
let process_id = child
|
||||
.id()
|
||||
.ok_or_else(|| std::io::Error::other("status command has no process id"))?;
|
||||
let process_group_id = i32::try_from(process_id)
|
||||
.map_err(|_| std::io::Error::other("status command process id exceeds i32"))?;
|
||||
Ok(Self {
|
||||
process_group_id: Some(process_group_id),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StatusCommandGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(process_group_id) = self.process_group_id.take() {
|
||||
// The command was spawned as this process group's leader. Killing the
|
||||
// group also cleans up background descendants on completion/cancellation.
|
||||
unsafe {
|
||||
libc::kill(-process_group_id, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn datetime_from_tm(value: &libc::tm) -> Option<time::PrimitiveDateTime> {
|
||||
let month = time::Month::try_from(u8::try_from(value.tm_mon + 1).ok()?).ok()?;
|
||||
let date = time::Date::from_calendar_date(
|
||||
value.tm_year + 1900,
|
||||
month,
|
||||
u8::try_from(value.tm_mday).ok()?,
|
||||
)
|
||||
.ok()?;
|
||||
let time = time::Time::from_hms(
|
||||
u8::try_from(value.tm_hour).ok()?,
|
||||
u8::try_from(value.tm_min).ok()?,
|
||||
u8::try_from(value.tm_sec).ok()?,
|
||||
)
|
||||
.ok()?;
|
||||
Some(time::PrimitiveDateTime::new(date, time))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -33,13 +33,16 @@ use windows_sys::{
|
|||
Diagnostics::{
|
||||
Debug::ReadProcessMemory,
|
||||
ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
||||
TH32CS_SNAPPROCESS,
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, Thread32First,
|
||||
Thread32Next, PROCESSENTRY32W, TH32CS_SNAPPROCESS, TH32CS_SNAPTHREAD,
|
||||
THREADENTRY32,
|
||||
},
|
||||
},
|
||||
JobObjects::{
|
||||
IsProcessInJob, JobObjectExtendedLimitInformation, QueryInformationJobObject,
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
AssignProcessToJobObject, CreateJobObjectW, IsProcessInJob,
|
||||
JobObjectExtendedLimitInformation, QueryInformationJobObject,
|
||||
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
},
|
||||
Memory::{
|
||||
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, VirtualQueryEx, GMEM_MOVEABLE,
|
||||
|
|
@ -47,10 +50,11 @@ use windows_sys::{
|
|||
},
|
||||
Ole::{CF_DIB, CF_DIBV5, CF_UNICODETEXT},
|
||||
Threading::{
|
||||
GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, OpenProcess,
|
||||
QueryFullProcessImageNameW, TerminateProcess, CREATE_NO_WINDOW, DETACHED_PROCESS,
|
||||
PROCESS_BASIC_INFORMATION, PROCESS_QUERY_INFORMATION,
|
||||
PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
|
||||
GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, OpenProcess, OpenThread,
|
||||
QueryFullProcessImageNameW, ResumeThread, TerminateProcess, CREATE_NO_WINDOW,
|
||||
CREATE_SUSPENDED, DETACHED_PROCESS, PROCESS_BASIC_INFORMATION,
|
||||
PROCESS_QUERY_INFORMATION, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
|
||||
THREAD_SUSPEND_RESUME,
|
||||
},
|
||||
},
|
||||
UI::{
|
||||
|
|
@ -263,6 +267,38 @@ pub(crate) fn should_draw_host_cursor_by_default() -> bool {
|
|||
true
|
||||
}
|
||||
|
||||
/// The machine's node name, as shown by tmux's `#h`.
|
||||
pub(crate) fn hostname() -> Option<String> {
|
||||
std::env::var("COMPUTERNAME")
|
||||
.ok()
|
||||
.filter(|name| !name.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn local_datetime() -> Option<time::PrimitiveDateTime> {
|
||||
let mut timestamp: libc::time_t = 0;
|
||||
if unsafe { libc::time(&mut timestamp) } == -1 {
|
||||
return None;
|
||||
}
|
||||
let mut local: libc::tm = unsafe { std::mem::zeroed() };
|
||||
if unsafe { libc::localtime_s(&mut local, ×tamp) } != 0 {
|
||||
return None;
|
||||
}
|
||||
let month = time::Month::try_from(u8::try_from(local.tm_mon + 1).ok()?).ok()?;
|
||||
let date = time::Date::from_calendar_date(
|
||||
local.tm_year + 1900,
|
||||
month,
|
||||
u8::try_from(local.tm_mday).ok()?,
|
||||
)
|
||||
.ok()?;
|
||||
let time = time::Time::from_hms(
|
||||
u8::try_from(local.tm_hour).ok()?,
|
||||
u8::try_from(local.tm_min).ok()?,
|
||||
u8::try_from(local.tm_sec).ok()?,
|
||||
)
|
||||
.ok()?;
|
||||
Some(time::PrimitiveDateTime::new(date, time))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct WindowsProcessEntry {
|
||||
pid: u32,
|
||||
|
|
@ -394,6 +430,137 @@ pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::pr
|
|||
detached_custom_command_process_with_comspec(command, std::env::var_os("ComSpec"))
|
||||
}
|
||||
|
||||
pub(crate) fn status_commands_supported() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn configure_status_command(process: &mut std::process::Command) {
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
// The process must not run before it is assigned to the kill-on-close job.
|
||||
process.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
|
||||
}
|
||||
|
||||
pub(crate) struct StatusCommandGuard {
|
||||
job: usize,
|
||||
}
|
||||
|
||||
impl StatusCommandGuard {
|
||||
pub(crate) fn new(child: &tokio::process::Child) -> std::io::Result<Self> {
|
||||
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
|
||||
if job.is_null() {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
|
||||
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
let limits_size = match u32::try_from(size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>()) {
|
||||
Ok(size) => size,
|
||||
Err(_) => {
|
||||
unsafe {
|
||||
CloseHandle(job);
|
||||
}
|
||||
return Err(std::io::Error::other("job limits size exceeds u32"));
|
||||
}
|
||||
};
|
||||
if unsafe {
|
||||
SetInformationJobObject(
|
||||
job,
|
||||
JobObjectExtendedLimitInformation,
|
||||
std::ptr::from_ref(&limits).cast(),
|
||||
limits_size,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
let error = std::io::Error::last_os_error();
|
||||
unsafe {
|
||||
CloseHandle(job);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let Some(process) = child.raw_handle() else {
|
||||
unsafe {
|
||||
CloseHandle(job);
|
||||
}
|
||||
return Err(std::io::Error::other(
|
||||
"status command has no process handle",
|
||||
));
|
||||
};
|
||||
if unsafe { AssignProcessToJobObject(job, process.cast()) } == 0 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
unsafe {
|
||||
CloseHandle(job);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = resume_suspended_process(child.id()) {
|
||||
unsafe {
|
||||
CloseHandle(job);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
Ok(Self { job: job as usize })
|
||||
}
|
||||
}
|
||||
|
||||
fn resume_suspended_process(process_id: Option<u32>) -> std::io::Result<()> {
|
||||
let process_id =
|
||||
process_id.ok_or_else(|| std::io::Error::other("status command has no process id"))?;
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
|
||||
if snapshot == INVALID_HANDLE_VALUE {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let result = (|| {
|
||||
let mut entry: THREADENTRY32 = unsafe { std::mem::zeroed() };
|
||||
entry.dwSize = u32::try_from(size_of::<THREADENTRY32>())
|
||||
.map_err(|_| std::io::Error::other("thread entry size exceeds u32"))?;
|
||||
if unsafe { Thread32First(snapshot, &mut entry) } == 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
loop {
|
||||
if entry.th32OwnerProcessID == process_id {
|
||||
let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
|
||||
if thread.is_null() {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
let resume_result = unsafe { ResumeThread(thread) };
|
||||
let resume_error = (resume_result == u32::MAX).then(std::io::Error::last_os_error);
|
||||
unsafe {
|
||||
CloseHandle(thread);
|
||||
}
|
||||
if let Some(error) = resume_error {
|
||||
return Err(error);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if unsafe { Thread32Next(snapshot, &mut entry) } == 0 {
|
||||
return Err(std::io::Error::other(
|
||||
"status command primary thread was not found",
|
||||
));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
unsafe {
|
||||
CloseHandle(snapshot);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
impl Drop for StatusCommandGuard {
|
||||
fn drop(&mut self) {
|
||||
// KILL_ON_JOB_CLOSE terminates the shell and every descendant still in
|
||||
// the job, including on task cancellation and config reload.
|
||||
unsafe {
|
||||
CloseHandle(self.job as HANDLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detached_custom_command_process_with_comspec(
|
||||
command: &str,
|
||||
comspec: Option<std::ffi::OsString>,
|
||||
|
|
|
|||
|
|
@ -4554,6 +4554,8 @@ impl HeadlessServer {
|
|||
changed = true;
|
||||
}
|
||||
|
||||
changed |= self.app.handle_tab_bar_status_tasks(now);
|
||||
|
||||
if geometry_dirty {
|
||||
self.app.pending_agent_resume_deadline = None;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ pub(crate) use self::{
|
|||
},
|
||||
panes::{apply_pane_chrome, pane_inner_rect, pane_is_scrolled_back},
|
||||
tab_surface::{tab_surface_cursor, tab_surface_hyperlinks, TabSurfaceView},
|
||||
tabs::compute_tab_bar_view,
|
||||
tabs::{compute_tab_bar_view, tab_bar_content_area},
|
||||
widgets::{centered_popup_rect, modal_stack_areas},
|
||||
};
|
||||
use crate::app::state::ViewLayout;
|
||||
|
|
@ -267,7 +267,7 @@ fn compute_view_internal(
|
|||
.map(|ws| {
|
||||
compute_tab_bar_view(
|
||||
ws,
|
||||
tab_bar_rect,
|
||||
tab_bar_content_area(app, tab_bar_rect),
|
||||
app.tab_scroll,
|
||||
app.tab_scroll_follow_active,
|
||||
app.mouse_capture,
|
||||
|
|
|
|||
220
src/ui/tabs.rs
220
src/ui/tabs.rs
|
|
@ -12,6 +12,11 @@ use crate::app::AppState;
|
|||
const MIN_TAB_WIDTH: u16 = 8;
|
||||
const NEW_TAB_WIDTH: u16 = 3;
|
||||
const TAB_SCROLL_BUTTON_WIDTH: u16 = 3;
|
||||
const ZOOM_INDICATOR: &str = "ZOOM";
|
||||
// The narrowest overflowing tab strip worth keeping interactive: one
|
||||
// minimum-width tab, both scroll controls, and the new-tab control.
|
||||
const MIN_TAB_STRIP_WIDTH: u16 =
|
||||
MIN_TAB_WIDTH + NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2);
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct TabBarView {
|
||||
|
|
@ -39,6 +44,70 @@ fn tab_chrome_label(ws: &crate::workspace::Workspace, tab_idx: usize) -> String
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct VisibleStatusSegment<'a> {
|
||||
text: &'a str,
|
||||
accent: bool,
|
||||
}
|
||||
|
||||
fn visible_status_segments(app: &AppState) -> Vec<VisibleStatusSegment<'_>> {
|
||||
let zoomed = app
|
||||
.active
|
||||
.and_then(|index| app.workspaces.get(index))
|
||||
.is_some_and(|workspace| workspace.zoomed);
|
||||
app.tab_bar_right
|
||||
.iter()
|
||||
.filter_map(|segment| match segment {
|
||||
crate::app::state::TabBarStatusSegment::Zoom if zoomed => Some(VisibleStatusSegment {
|
||||
text: ZOOM_INDICATOR,
|
||||
accent: true,
|
||||
}),
|
||||
crate::app::state::TabBarStatusSegment::Text(Some(text))
|
||||
if display_width_u16(text) > 0 =>
|
||||
{
|
||||
Some(VisibleStatusSegment {
|
||||
text,
|
||||
accent: false,
|
||||
})
|
||||
}
|
||||
crate::app::state::TabBarStatusSegment::Zoom
|
||||
| crate::app::state::TabBarStatusSegment::Text(_) => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tab_bar_status_width(app: &AppState) -> u16 {
|
||||
let segments = visible_status_segments(app);
|
||||
let content_width = segments.iter().fold(0_u16, |width, segment| {
|
||||
width.saturating_add(display_width_u16(segment.text))
|
||||
});
|
||||
let separators = u16::try_from(segments.len().saturating_sub(1)).unwrap_or(u16::MAX);
|
||||
content_width
|
||||
.saturating_add(display_width_u16(&app.tab_bar_right_separator).saturating_mul(separators))
|
||||
}
|
||||
|
||||
fn tab_bar_status_area(app: &AppState, area: Rect) -> Option<Rect> {
|
||||
let width = tab_bar_status_width(app);
|
||||
if width == 0 {
|
||||
return None;
|
||||
}
|
||||
let reserved = width.saturating_add(1);
|
||||
(area.width.saturating_sub(reserved) >= MIN_TAB_STRIP_WIDTH)
|
||||
.then(|| Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1))
|
||||
}
|
||||
|
||||
// Tabs win over status decoration on narrow rows. The extra reserved cell is
|
||||
// the gap between the interactive strip and the right-aligned status entries.
|
||||
pub(crate) fn tab_bar_content_area(app: &AppState, area: Rect) -> Rect {
|
||||
let reserved = tab_bar_status_area(app, area)
|
||||
.map(|status| status.width.saturating_add(1))
|
||||
.unwrap_or(0);
|
||||
Rect {
|
||||
width: area.width.saturating_sub(reserved),
|
||||
..area
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect, scroll: usize) -> Vec<Rect> {
|
||||
let mut rects = vec![Rect::default(); ws.tabs.len()];
|
||||
if area.width == 0 || area.height == 0 {
|
||||
|
|
@ -380,10 +449,12 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
|
|||
}
|
||||
}
|
||||
if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) {
|
||||
let content = tab_bar_content_area(app, area);
|
||||
let content_right = content.x + content.width;
|
||||
let x = if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 {
|
||||
app.view.tab_scroll_right_hit_area.x.saturating_sub(1)
|
||||
} else {
|
||||
area.x + area.width.saturating_sub(1)
|
||||
content_right.saturating_sub(1)
|
||||
};
|
||||
if x >= area.x && x < area.x + area.width {
|
||||
frame.buffer_mut()[(x, area.y)]
|
||||
|
|
@ -391,6 +462,36 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
|
|||
.set_style(Style::default().fg(p.overlay0));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(status_area) = tab_bar_status_area(app, area) {
|
||||
let segments = visible_status_segments(app);
|
||||
let separator_width = display_width_u16(&app.tab_bar_right_separator);
|
||||
let mut x = status_area.x;
|
||||
for (index, segment) in segments.iter().enumerate() {
|
||||
if index > 0 && separator_width > 0 {
|
||||
let rect = Rect::new(x, area.y, separator_width, 1);
|
||||
frame.render_widget(
|
||||
Paragraph::new(app.tab_bar_right_separator.as_str())
|
||||
.style(Style::default().fg(p.overlay0).bg(p.panel_bg)),
|
||||
rect,
|
||||
);
|
||||
x = x.saturating_add(separator_width);
|
||||
}
|
||||
|
||||
let width = display_width_u16(segment.text);
|
||||
let rect = Rect::new(x, area.y, width, 1);
|
||||
let style = if segment.accent {
|
||||
Style::default()
|
||||
.fg(panel_contrast_fg(p))
|
||||
.bg(p.accent)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(p.overlay1).bg(p.panel_bg)
|
||||
};
|
||||
frame.render_widget(Paragraph::new(segment.text).style(style), rect);
|
||||
x = x.saturating_add(width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -438,6 +539,123 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_bar_renders_ordered_status_entries_with_separator() {
|
||||
let mut app = AppState::test_new();
|
||||
let mut ws = Workspace::test_new("test");
|
||||
ws.tabs[0].zoomed = true;
|
||||
app.tab_bar_right = vec![
|
||||
crate::app::state::TabBarStatusSegment::Zoom,
|
||||
crate::app::state::TabBarStatusSegment::Text(Some("wintermute".into())),
|
||||
crate::app::state::TabBarStatusSegment::Text(Some("14:30".into())),
|
||||
];
|
||||
app.tab_bar_right_separator = " · ".into();
|
||||
|
||||
app.workspaces = vec![ws];
|
||||
app.active = Some(0);
|
||||
app.view.tab_bar_rect = Rect::new(0, 0, 60, 1);
|
||||
let content = tab_bar_content_area(&app, app.view.tab_bar_rect);
|
||||
let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, false);
|
||||
app.view.tab_hit_areas = view.tab_hit_areas.clone();
|
||||
|
||||
let backend = TestBackend::new(60, 1);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect))
|
||||
.unwrap();
|
||||
|
||||
let buffer = terminal.backend().buffer();
|
||||
let row = buffer_row_text(buffer, app.view.tab_bar_rect, 0);
|
||||
assert!(
|
||||
row.ends_with("ZOOM · wintermute · 14:30"),
|
||||
"tab row: {row:?}"
|
||||
);
|
||||
let status_x = 60 - display_width_u16("ZOOM · wintermute · 14:30");
|
||||
assert_eq!(buffer[(status_x, 0)].style().bg, Some(app.palette.accent));
|
||||
for rect in &view.tab_hit_areas {
|
||||
assert!(rect.x + rect.width <= content.x + content.width);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_status_entries_do_not_leave_dangling_separators() {
|
||||
let mut app = AppState::test_new();
|
||||
app.tab_bar_right = vec![
|
||||
crate::app::state::TabBarStatusSegment::Zoom,
|
||||
crate::app::state::TabBarStatusSegment::Text(None),
|
||||
crate::app::state::TabBarStatusSegment::Text(Some("wintermute".into())),
|
||||
];
|
||||
app.tab_bar_right_separator = " | ".into();
|
||||
app.workspaces = vec![Workspace::test_new("test")];
|
||||
app.active = Some(0);
|
||||
app.view.tab_bar_rect = Rect::new(0, 0, 40, 1);
|
||||
let content = tab_bar_content_area(&app, app.view.tab_bar_rect);
|
||||
let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, false);
|
||||
app.view.tab_hit_areas = view.tab_hit_areas;
|
||||
|
||||
let backend = TestBackend::new(40, 1);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect))
|
||||
.unwrap();
|
||||
|
||||
let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0);
|
||||
assert!(row.ends_with("wintermute"), "tab row: {row:?}");
|
||||
assert!(!row.contains(" | "), "tab row: {row:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_reservation_keeps_a_minimum_width_tab_between_scroll_controls() {
|
||||
let mut app = AppState::test_new();
|
||||
app.tab_bar_right = vec![crate::app::state::TabBarStatusSegment::Text(Some(
|
||||
"x".into(),
|
||||
))];
|
||||
let mut workspace = Workspace::test_new("test");
|
||||
workspace.test_add_tab(None);
|
||||
workspace.test_add_tab(None);
|
||||
app.workspaces = vec![workspace];
|
||||
app.active = Some(0);
|
||||
|
||||
let too_narrow = Rect::new(0, 0, MIN_TAB_STRIP_WIDTH + 1, 1);
|
||||
assert_eq!(tab_bar_content_area(&app, too_narrow), too_narrow);
|
||||
|
||||
let wide_enough = Rect::new(0, 0, MIN_TAB_STRIP_WIDTH + 2, 1);
|
||||
let content = tab_bar_content_area(&app, wide_enough);
|
||||
assert_eq!(content.width, MIN_TAB_STRIP_WIDTH);
|
||||
let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, true);
|
||||
assert!(view.tab_hit_areas[0].width >= MIN_TAB_WIDTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_status_entries_yield_to_tab_controls_on_narrow_rows() {
|
||||
let mut app = AppState::test_new();
|
||||
app.tab_bar_right = vec![
|
||||
crate::app::state::TabBarStatusSegment::Text(Some(
|
||||
"a-hostname-wider-than-the-whole-bar".into(),
|
||||
)),
|
||||
crate::app::state::TabBarStatusSegment::Text(Some("14:30".into())),
|
||||
];
|
||||
app.workspaces = vec![Workspace::test_new("test")];
|
||||
app.active = Some(0);
|
||||
app.view.tab_bar_rect = Rect::new(0, 0, 30, 1);
|
||||
|
||||
assert_eq!(
|
||||
tab_bar_content_area(&app, app.view.tab_bar_rect),
|
||||
app.view.tab_bar_rect
|
||||
);
|
||||
assert_eq!(tab_bar_status_area(&app, app.view.tab_bar_rect), None);
|
||||
|
||||
let view = compute_tab_bar_view(
|
||||
&app.workspaces[0],
|
||||
tab_bar_content_area(&app, app.view.tab_bar_rect),
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
assert!(view.tab_hit_areas[0].width > 0);
|
||||
assert!(view.new_tab_hit_area.width > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_auto_named_tab_keeps_readable_weight() {
|
||||
let mut app = AppState::test_new();
|
||||
|
|
|
|||
Loading…
Reference in New Issue