diff options
| author | CHIH,GODLEE <61595521+ChihGodlee@users.noreply.github.com> | 2026-05-18 20:27:43 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-18 15:27:43 +0300 |
| commit | 3189ede2b9225b1b930cc43368248c6b4f3a4daf (patch) | |
| tree | 24252debe6f388edfe0e621462f1bf38d5b46b20 /src/app | |
| parent | c9eb7520e66c2a941c020e96e8242de66847f29d (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/app')
| -rw-r--r-- | src/app/input/sidebar.rs | 25 | ||||
| -rw-r--r-- | src/app/mod.rs | 209 | ||||
| -rw-r--r-- | src/app/state.rs | 4 |
3 files changed, 213 insertions, 25 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, |
