aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCHIH,GODLEE <61595521+ChihGodlee@users.noreply.github.com>2026-05-18 20:27:43 +0800
committerGitHub <noreply@github.com>2026-05-18 15:27:43 +0300
commit3189ede2b9225b1b930cc43368248c6b4f3a4daf (patch)
tree24252debe6f388edfe0e621462f1bf38d5b46b20 /src
parentc9eb7520e66c2a941c020e96e8242de66847f29d (diff)
feat: configure sidebar min/max width via [ui] (#135)
* feat: configure sidebar min/max width via [ui] Expose the previously hardcoded MIN_SIDEBAR_WIDTH / MAX_SIDEBAR_WIDTH clamps as [ui] sidebar_min_width / sidebar_max_width. Defaults remain 18 / 36 so existing configs are unchanged. Hot-reload is wired through the existing [ui] block; reload re-clamps the live width to the new bounds so tightening takes effect immediately. Reject inverted bounds (min > max) at every entry point so a bad config can never reach u16::clamp(min, max), which panics: App::new falls back to defaults, and apply_live_config treats the whole [ui] section as invalid and returns a partial reload report. refs #132 * test: keep sidebar reload width within bounds refs #132 --------- Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/app/input/sidebar.rs25
-rw-r--r--src/app/mod.rs209
-rw-r--r--src/app/state.rs4
-rw-r--r--src/config.rs4
-rw-r--r--src/config/model.rs44
-rw-r--r--src/main.rs6
-rw-r--r--src/ui.rs34
7 files changed, 296 insertions, 30 deletions
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
index e8e1e094..94feaf93 100644
--- a/src/app/input/sidebar.rs
+++ b/src/app/input/sidebar.rs
@@ -244,8 +244,7 @@ impl AppState {
pub(super) fn set_manual_sidebar_width(&mut self, divider_col: u16) {
let sidebar = self.view.sidebar_rect;
let width = divider_col.saturating_sub(sidebar.x).saturating_add(1);
- self.sidebar_width =
- width.clamp(crate::ui::MIN_SIDEBAR_WIDTH, crate::ui::MAX_SIDEBAR_WIDTH);
+ self.sidebar_width = width.clamp(self.sidebar_min_width, self.sidebar_max_width);
self.sidebar_width_source = crate::app::state::SidebarWidthSource::Manual;
self.mark_session_dirty();
}
@@ -1240,6 +1239,28 @@ mod tests {
}
#[test]
+ fn dragging_past_max_clamps_to_configured_max() {
+ let mut app = app_for_mouse_test();
+ app.state.sidebar_max_width = 30;
+
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
+ app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 50, 5));
+
+ assert_eq!(app.state.sidebar_width, 30);
+ }
+
+ #[test]
+ fn dragging_below_min_clamps_to_configured_min() {
+ let mut app = app_for_mouse_test();
+ app.state.sidebar_min_width = 22;
+
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
+ app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 5, 5));
+
+ assert_eq!(app.state.sidebar_width, 22);
+ }
+
+ #[test]
fn dragging_sidebar_section_divider_sets_split_ratio() {
let mut app = app_for_mouse_test();
let divider = crate::ui::sidebar_section_divider_rect(
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 13b8ecc0..125fabed 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -288,6 +288,22 @@ impl App {
let agent_panel_scope = agent_panel_scope_from_config(config.ui.agent_panel_scope);
+ // Validate sidebar bounds before they reach any `u16::clamp(min, max)`
+ // call: `clamp` panics when `min > max`. On bad config, fall back to
+ // the built-in defaults rather than crashing on the first render.
+ let (sidebar_min_width, sidebar_max_width) = crate::config::validated_sidebar_bounds(
+ config.ui.sidebar_min_width,
+ config.ui.sidebar_max_width,
+ )
+ .unwrap_or_else(|| {
+ tracing::warn!(
+ min = config.ui.sidebar_min_width,
+ max = config.ui.sidebar_max_width,
+ "ui.sidebar_min_width is greater than sidebar_max_width; falling back to default bounds (18, 36)"
+ );
+ (18, 36)
+ });
+
info!(
pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes,
"using pane scrollback configuration"
@@ -378,6 +394,8 @@ impl App {
prefix_mods,
default_sidebar_width: config.ui.sidebar_width,
sidebar_width,
+ sidebar_min_width,
+ sidebar_max_width,
sidebar_width_source,
sidebar_width_auto: false,
sidebar_collapsed: false,
@@ -730,26 +748,50 @@ impl App {
}
if !invalid_section("ui") {
- diagnostics.extend(config.ui.sound.diagnostics());
+ // Validate sidebar bounds before they reach any `u16::clamp` call.
+ // On `min > max`, treat the entire `[ui]` section as invalid: keep
+ // the previous settings and skip the section so the re-clamp below
+ // — and every subsequent render/drag — can never panic.
+ if crate::config::validated_sidebar_bounds(
+ config.ui.sidebar_min_width,
+ config.ui.sidebar_max_width,
+ )
+ .is_none()
+ {
+ diagnostics.push(format!(
+ "ui.sidebar_min_width ({}) is greater than sidebar_max_width ({}); keeping previous [ui] settings",
+ config.ui.sidebar_min_width, config.ui.sidebar_max_width,
+ ));
+ } else {
+ diagnostics.extend(config.ui.sound.diagnostics());
- self.state.default_sidebar_width = config.ui.sidebar_width;
- if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault {
- self.state.sidebar_width = config.ui.sidebar_width;
- }
- self.state.mouse_capture = config.ui.mouse_capture;
- self.state.confirm_close = config.ui.confirm_close;
- self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name;
- self.state.show_agent_labels_on_pane_borders =
- config.ui.show_agent_labels_on_pane_borders;
- self.state.agent_panel_scope =
- agent_panel_scope_from_config(config.ui.agent_panel_scope);
- self.state.agent_panel_scroll = 0;
- self.state.accent = crate::config::parse_color(&config.ui.accent);
- if !self.state.local_sound_playback && self.state.sound != config.ui.sound {
- self.state.request_client_sound_config_reload = true;
+ self.state.default_sidebar_width = config.ui.sidebar_width;
+ if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault {
+ self.state.sidebar_width = config.ui.sidebar_width;
+ }
+ self.state.sidebar_min_width = config.ui.sidebar_min_width;
+ self.state.sidebar_max_width = config.ui.sidebar_max_width;
+ // Re-clamp the live width to the new bounds. No source guard — bounds
+ // always apply, including to widths owned by Persisted or Manual.
+ self.state.sidebar_width = self
+ .state
+ .sidebar_width
+ .clamp(self.state.sidebar_min_width, self.state.sidebar_max_width);
+ self.state.mouse_capture = config.ui.mouse_capture;
+ self.state.confirm_close = config.ui.confirm_close;
+ self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name;
+ self.state.show_agent_labels_on_pane_borders =
+ config.ui.show_agent_labels_on_pane_borders;
+ self.state.agent_panel_scope =
+ agent_panel_scope_from_config(config.ui.agent_panel_scope);
+ self.state.agent_panel_scroll = 0;
+ self.state.accent = crate::config::parse_color(&config.ui.accent);
+ if !self.state.local_sound_playback && self.state.sound != config.ui.sound {
+ self.state.request_client_sound_config_reload = true;
+ }
+ self.state.sound = config.ui.sound.clone();
+ self.state.toast_config = config.ui.toast.clone();
}
- self.state.sound = config.ui.sound.clone();
- self.state.toast_config = config.ui.toast.clone();
}
if !invalid_section("experimental") {
@@ -1170,18 +1212,18 @@ mod tests {
state::SidebarWidthSource::ConfigDefault
);
- std::fs::write(&path, "[ui]\nsidebar_width = 42\n").unwrap();
+ std::fs::write(&path, "[ui]\nsidebar_width = 34\n").unwrap();
let report = app.reload_config();
assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
- assert_eq!(app.state.default_sidebar_width, 42);
- assert_eq!(app.state.sidebar_width, 42);
+ assert_eq!(app.state.default_sidebar_width, 34);
+ assert_eq!(app.state.sidebar_width, 34);
app.state.sidebar_width = 31;
app.state.sidebar_width_source = state::SidebarWidthSource::Manual;
- std::fs::write(&path, "[ui]\nsidebar_width = 44\n").unwrap();
+ std::fs::write(&path, "[ui]\nsidebar_width = 35\n").unwrap();
let report = app.reload_config();
assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
- assert_eq!(app.state.default_sidebar_width, 44);
+ assert_eq!(app.state.default_sidebar_width, 35);
assert_eq!(app.state.sidebar_width, 31);
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
@@ -1189,6 +1231,127 @@ mod tests {
}
#[test]
+ fn reload_config_updates_sidebar_bounds_and_reclamps() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("reload-config-sidebar-bounds");
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut app = test_app();
+ // Default bounds.
+ assert_eq!(app.state.sidebar_min_width, 18);
+ assert_eq!(app.state.sidebar_max_width, 36);
+
+ // Manually set a width and flip the source so the existing
+ // sidebar_width-only-when-config-owned guard does NOT update it.
+ app.state.sidebar_width = 30;
+ app.state.sidebar_width_source = state::SidebarWidthSource::Manual;
+
+ // Tightening max below the current width must re-clamp the live width
+ // even when source is Manual — bounds always apply.
+ std::fs::write(&path, "[ui]\nsidebar_max_width = 24\n").unwrap();
+ let report = app.reload_config();
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
+ assert_eq!(app.state.sidebar_max_width, 24);
+ assert_eq!(
+ app.state.sidebar_width, 24,
+ "manual width must re-clamp to new max"
+ );
+
+ // Loosening max leaves the live width alone (it's already within bounds).
+ app.state.sidebar_width = 24;
+ std::fs::write(&path, "[ui]\nsidebar_max_width = 60\n").unwrap();
+ let report = app.reload_config();
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
+ assert_eq!(app.state.sidebar_max_width, 60);
+ assert_eq!(app.state.sidebar_width, 24);
+
+ // Raising min above the current width re-clamps upward.
+ std::fs::write(&path, "[ui]\nsidebar_min_width = 30\n").unwrap();
+ let report = app.reload_config();
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
+ assert_eq!(app.state.sidebar_min_width, 30);
+ assert_eq!(
+ app.state.sidebar_width, 30,
+ "manual width must re-clamp up to new min"
+ );
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
+ fn app_new_falls_back_to_default_bounds_on_inverted_config() {
+ let mut config = Config::default();
+ config.ui.sidebar_min_width = 50;
+ config.ui.sidebar_max_width = 30;
+
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let app = App::new(
+ &config,
+ true,
+ None,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+
+ assert_eq!(
+ app.state.sidebar_min_width, 18,
+ "App::new must fall back to default min when bounds are inverted"
+ );
+ assert_eq!(
+ app.state.sidebar_max_width, 36,
+ "App::new must fall back to default max when bounds are inverted"
+ );
+ }
+
+ #[test]
+ fn reload_config_invalid_sidebar_bounds_keeps_previous_ui_and_returns_partial() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("reload-config-invalid-sidebar-bounds");
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut app = test_app();
+ let original_min = app.state.sidebar_min_width;
+ let original_max = app.state.sidebar_max_width;
+ let original_mouse_capture = app.state.mouse_capture;
+ // Pair the bad bounds with another `[ui]` field change to confirm the
+ // entire section is treated as invalid (not just the bounds).
+ let target_mouse_capture = !original_mouse_capture;
+ std::fs::write(
+ &path,
+ format!(
+ "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\nmouse_capture = {}\n",
+ target_mouse_capture
+ ),
+ )
+ .unwrap();
+
+ let report = app.reload_config();
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial);
+ assert_eq!(app.state.sidebar_min_width, original_min);
+ assert_eq!(app.state.sidebar_max_width, original_max);
+ assert_eq!(
+ app.state.mouse_capture, original_mouse_capture,
+ "[ui] is treated as invalid on bad bounds; mouse_capture must not apply"
+ );
+ assert!(app
+ .state
+ .config_diagnostic
+ .as_deref()
+ .is_some_and(|message| {
+ message.contains("sidebar_min_width")
+ && message.contains("sidebar_max_width")
+ && message.contains("greater")
+ }));
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
fn reload_config_keeps_current_keybinds_on_invalid_binding_but_applies_other_sections() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("reload-config-invalid-keybind");
diff --git a/src/app/state.rs b/src/app/state.rs
index 72630749..c5734ec2 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -928,6 +928,8 @@ pub struct AppState {
pub prefix_mods: KeyModifiers,
pub default_sidebar_width: u16,
pub sidebar_width: u16,
+ pub sidebar_min_width: u16,
+ pub sidebar_max_width: u16,
pub sidebar_width_source: SidebarWidthSource,
pub sidebar_width_auto: bool,
pub sidebar_collapsed: bool,
@@ -1170,6 +1172,8 @@ impl AppState {
prefix_mods: KeyModifiers::CONTROL,
default_sidebar_width: 26,
sidebar_width: 26,
+ sidebar_min_width: 18,
+ sidebar_max_width: 36,
sidebar_width_source: SidebarWidthSource::ConfigDefault,
sidebar_width_auto: false,
sidebar_collapsed: false,
diff --git a/src/config.rs b/src/config.rs
index 9343321e..cf85e6cc 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -16,8 +16,8 @@ pub use self::{
Keybinds, LiveKeybindConfig,
},
model::{
- AgentPanelScopeConfig, Config, ConfigReloadReport, ConfigReloadStatus, ToastConfig,
- ToastDelivery,
+ validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport,
+ ConfigReloadStatus, ToastConfig, ToastDelivery,
},
sound::SoundConfig,
theme::{parse_color, CustomThemeColors, ThemeConfig},
diff --git a/src/config/model.rs b/src/config/model.rs
index 60a8859c..673eec02 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -48,6 +48,19 @@ pub struct ConfigReloadReport {
pub diagnostics: Vec<String>,
}
+/// Validate `[ui]` sidebar bound configuration.
+///
+/// Returns `Some((min, max))` when `min <= max`, `None` otherwise. The two
+/// values are funneled through this helper before they reach any
+/// `u16::clamp(min, max)` call site (`u16::clamp` panics when `min > max`).
+pub fn validated_sidebar_bounds(min: u16, max: u16) -> Option<(u16, u16)> {
+ if min <= max {
+ Some((min, max))
+ } else {
+ None
+ }
+}
+
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct Config {
@@ -147,6 +160,10 @@ pub struct IndexedKeysConfig {
#[serde(default)]
pub struct UiConfig {
pub sidebar_width: u16,
+ /// Minimum sidebar width (columns) when expanded. Default: 18.
+ pub sidebar_min_width: u16,
+ /// Maximum sidebar width (columns) when expanded. Default: 36.
+ pub sidebar_max_width: u16,
/// Capture mouse input for Herdr's mouse UI. Default: true.
pub mouse_capture: bool,
/// Ask for confirmation before closing a workspace. Default: true.
@@ -224,6 +241,8 @@ impl Default for UiConfig {
fn default() -> Self {
Self {
sidebar_width: 26,
+ sidebar_min_width: 18,
+ sidebar_max_width: 36,
mouse_capture: true,
confirm_close: true,
prompt_new_tab_name: true,
@@ -315,6 +334,31 @@ prompt_new_tab_name = false
}
#[test]
+ fn sidebar_bounds_default_and_parse() {
+ let default_config = Config::default();
+ assert_eq!(default_config.ui.sidebar_min_width, 18);
+ assert_eq!(default_config.ui.sidebar_max_width, 36);
+
+ let toml = r#"
+[ui]
+sidebar_min_width = 12
+sidebar_max_width = 80
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert_eq!(config.ui.sidebar_min_width, 12);
+ assert_eq!(config.ui.sidebar_max_width, 80);
+ }
+
+ #[test]
+ fn validated_sidebar_bounds_rejects_inverted() {
+ assert_eq!(validated_sidebar_bounds(18, 36), Some((18, 36)));
+ assert_eq!(validated_sidebar_bounds(20, 20), Some((20, 20)));
+ assert_eq!(validated_sidebar_bounds(0, u16::MAX), Some((0, u16::MAX)));
+ assert_eq!(validated_sidebar_bounds(50, 30), None);
+ assert_eq!(validated_sidebar_bounds(u16::MAX, 0), None);
+ }
+
+ #[test]
fn mouse_capture_default_on_and_parse() {
let default_config = Config::default();
assert!(default_config.ui.mouse_capture);
diff --git a/src/main.rs b/src/main.rs
index c1b0acd3..2c17e704 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -130,6 +130,12 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Sidebar width (auto-scaled based on workspace names, this sets the default)
# sidebar_width = 26
+# Minimum sidebar width when expanded (columns)
+# sidebar_min_width = 18
+
+# Maximum sidebar width when expanded (columns)
+# sidebar_max_width = 36
+
# Capture mouse input for Herdr's mouse UI.
# Set false to let the terminal handle normal clicks, such as Cmd-clicking URLs.
# Pane apps like lazygit and btop can still receive mouse when they request it.
diff --git a/src/ui.rs b/src/ui.rs
index a92eb1fe..846979da 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -71,8 +71,6 @@ use crate::app::state::ViewLayout;
use crate::app::{AppState, Mode};
const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator
-pub(crate) const MIN_SIDEBAR_WIDTH: u16 = 18;
-pub(crate) const MAX_SIDEBAR_WIDTH: u16 = 36;
// Braille spinner frames — smooth rotation
const SPINNERS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -147,7 +145,7 @@ fn compute_view_internal(
COLLAPSED_WIDTH
} else {
app.sidebar_width
- .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH)
+ .clamp(app.sidebar_min_width, app.sidebar_max_width)
};
let [sidebar_area, main_area] =
@@ -451,6 +449,36 @@ mod tests {
}
#[test]
+ fn compute_view_clamps_sidebar_width_to_configured_max() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.workspaces = vec![Workspace::test_new("one")];
+ app.active = Some(0);
+ app.selected = 0;
+ app.mode = Mode::Terminal;
+ app.sidebar_max_width = 30;
+ app.sidebar_width = 999;
+
+ compute_view(&mut app, Rect::new(0, 0, 100, 20));
+
+ assert_eq!(app.view.sidebar_rect.width, 30);
+ }
+
+ #[test]
+ fn compute_view_clamps_sidebar_width_to_configured_min() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.workspaces = vec![Workspace::test_new("one")];
+ app.active = Some(0);
+ app.selected = 0;
+ app.mode = Mode::Terminal;
+ app.sidebar_min_width = 22;
+ app.sidebar_width = 5;
+
+ compute_view(&mut app, Rect::new(0, 0, 100, 20));
+
+ assert_eq!(app.view.sidebar_rect.width, 22);
+ }
+
+ #[test]
fn collapsed_sidebar_keeps_active_workspace_highlight_in_terminal_mode() {
let mut app = crate::app::state::AppState::test_new();
app.sidebar_collapsed = true;