From 3d1ef155bf9846f39725bf22c54666da2bc42330 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 26 Apr 2026 02:07:33 +0300 Subject: feat: add live config reload --- src/app/actions.rs | 2 +- src/app/api.rs | 10 +++ src/app/config_io.rs | 25 ++++-- src/app/input/modal.rs | 8 +- src/app/input/navigate.rs | 26 ++++++ src/app/input/settings.rs | 8 +- src/app/input/sidebar.rs | 12 +-- src/app/mod.rs | 203 ++++++++++++++++++++++++++++++++++++++++------ src/app/state.rs | 8 +- 9 files changed, 247 insertions(+), 55 deletions(-) (limited to 'src/app') diff --git a/src/app/actions.rs b/src/app/actions.rs index b1bdfc63..210b6ccf 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -587,7 +587,7 @@ impl AppState { pane.seen = false; } - if self.sound.allows(change.known_agent) { + if self.local_sound_playback && self.sound.allows(change.known_agent) { if let Some(sound) = notification_sound_for_state_change( is_active_tab, change.previous_state, diff --git a/src/app/api.rs b/src/app/api.rs index 364097dc..d7893d6d 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -282,6 +282,16 @@ impl App { result: ResponseResult::Ok {}, } } + Method::ServerReloadConfig(_) => { + let report = self.reload_config(); + SuccessResponse { + id: request.id, + result: ResponseResult::ConfigReload { + status: report.status, + diagnostics: report.diagnostics, + }, + } + } Method::WorkspaceList(_) => SuccessResponse { id: request.id, result: ResponseResult::WorkspaceList { diff --git a/src/app/config_io.rs b/src/app/config_io.rs index 6770d61e..6c51ad9f 100644 --- a/src/app/config_io.rs +++ b/src/app/config_io.rs @@ -1,7 +1,7 @@ use super::App; impl App { - pub(super) fn update_config_file(&mut self, error_context: &str, update: F) + pub(super) fn update_config_file(&mut self, error_context: &str, update: F) -> bool where F: FnOnce(&str) -> String, { @@ -13,7 +13,7 @@ impl App { Some(format!("failed to save {error_context}: {err}")); self.config_diagnostic_deadline = Some(std::time::Instant::now() + std::time::Duration::from_secs(5)); - return; + return false; } } @@ -24,7 +24,10 @@ impl App { self.state.config_diagnostic = Some(format!("failed to save {error_context}: {err}")); self.config_diagnostic_deadline = Some(std::time::Instant::now() + std::time::Duration::from_secs(5)); + return false; } + + true } pub(super) fn mark_onboarding_complete(&mut self) { @@ -34,15 +37,19 @@ impl App { } pub(super) fn save_theme(&mut self, name: &str) { - self.update_config_file("theme", |content| { + if self.update_config_file("theme", |content| { crate::config::upsert_section_value(content, "theme", "name", &format!("\"{name}\"")) - }); + }) { + self.apply_config_from_disk(false); + } } pub(super) fn save_sound(&mut self, enabled: bool) { - self.update_config_file("sound setting", |content| { + if self.update_config_file("sound setting", |content| { crate::config::upsert_section_bool(content, "ui.sound", "enabled", enabled) - }); + }) { + self.apply_config_from_disk(false); + } } pub(super) fn save_toast_delivery(&mut self, delivery: crate::config::ToastDelivery) { @@ -51,10 +58,12 @@ impl App { crate::config::ToastDelivery::Herdr => "\"herdr\"", crate::config::ToastDelivery::Terminal => "\"terminal\"", }; - self.update_config_file("toast setting", |content| { + if self.update_config_file("toast setting", |content| { let content = crate::config::upsert_section_value(content, "ui.toast", "delivery", value); crate::config::remove_section_key(&content, "ui.toast", "enabled") - }); + }) { + self.apply_config_from_disk(false); + } } } diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index aa25ea1e..998bb69b 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -69,7 +69,7 @@ pub(crate) enum GlobalMenuAction { Quit, WhatsNew, Keybinds, - ReloadKeybinds, + ReloadConfig, Settings, } @@ -77,7 +77,7 @@ pub(super) fn global_menu_actions(state: &AppState) -> Vec { let mut actions = vec![ GlobalMenuAction::Settings, GlobalMenuAction::Keybinds, - GlobalMenuAction::ReloadKeybinds, + GlobalMenuAction::ReloadConfig, ]; if state.update_available.is_some() || state.latest_release_notes_available { actions.push(GlobalMenuAction::WhatsNew); @@ -126,8 +126,8 @@ pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuA } GlobalMenuAction::WhatsNew => open_update_release_notes(state), GlobalMenuAction::Keybinds => open_keybind_help(state), - GlobalMenuAction::ReloadKeybinds => { - state.request_reload_keybinds = true; + GlobalMenuAction::ReloadConfig => { + state.request_reload_config = true; leave_modal(state); } GlobalMenuAction::Settings => super::settings::open_settings(state), diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 23b93379..b961c6d3 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -350,6 +350,7 @@ pub(crate) enum NavigateAction { Fullscreen, EnterResizeMode, ToggleSidebar, + ReloadConfig, Detach, } @@ -421,6 +422,12 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option { + state.request_reload_config = true; + leave_navigate_mode(state); + } NavigateAction::Detach => { state.detach_requested = true; leave_navigate_mode(state); @@ -582,6 +593,21 @@ mod tests { assert_eq!(state.mode, Mode::Resize); } + #[test] + fn custom_reload_config_key_requests_reload_and_exits_navigate() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.reload_config = Some((KeyCode::Char('g'), KeyModifiers::empty())); + state.keybinds.reload_config_label = Some("g".into()); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert!(state.request_reload_config); + assert_eq!(state.mode, Mode::Terminal); + } + #[test] fn movement_action_stays_in_navigate_mode() { let mut state = state_with_workspaces(&["a", "b"]); diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs index 8bf06dc6..2c92bc63 100644 --- a/src/app/input/settings.rs +++ b/src/app/input/settings.rs @@ -125,7 +125,6 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti } KeyCode::Enter | KeyCode::Char(' ') => { let enabled = state.settings.list.selected == 0; - state.sound.enabled = enabled; return Some(SettingsAction::SaveSound(enabled)); } KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { @@ -146,8 +145,6 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti KeyCode::Down | KeyCode::Char('j') => state.settings.list.move_next(3), KeyCode::Enter | KeyCode::Char(' ') => { let delivery = toast_delivery_for_index(state.settings.list.selected); - state.toast_config.delivery = delivery; - state.toast = None; return Some(SettingsAction::SaveToastDelivery(delivery)); } KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { @@ -271,13 +268,10 @@ impl AppState { } SettingsSection::Sound => { let enabled = idx == 0; - self.sound.enabled = enabled; Some(SettingsAction::SaveSound(enabled)) } SettingsSection::Toast => { let delivery = toast_delivery_for_index(idx); - self.toast_config.delivery = delivery; - self.toast = None; Some(SettingsAction::SaveToastDelivery(delivery)) } }; @@ -362,7 +356,7 @@ mod tests { ); assert_eq!(action, Some(SettingsAction::SaveSound(true))); - assert!(state.sound.enabled); + assert!(!state.sound.enabled); assert_eq!(state.mode, Mode::Settings); } diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index 9de8868b..0abfd446 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -182,7 +182,7 @@ impl AppState { } pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> { - let mut labels = vec!["settings", "keybinds", "reload keybinds"]; + let mut labels = vec!["settings", "keybinds", "reload config"]; if self.update_available.is_some() { labels.push("update ready"); } else if self.latest_release_notes_available { @@ -530,7 +530,7 @@ mod tests { } #[test] - fn clicking_reload_keybinds_menu_item_requests_reload() { + fn clicking_reload_config_menu_item_requests_reload() { let mut app = app_for_mouse_test(); let launcher = app.state.global_launcher_rect(); app.handle_mouse(mouse( @@ -546,7 +546,7 @@ mod tests { menu.y + 3, )); - assert!(app.state.request_reload_keybinds); + assert!(app.state.request_reload_config); assert_eq!(app.state.mode, Mode::Navigate); } @@ -568,7 +568,7 @@ mod tests { vec![ "settings", "keybinds", - "reload keybinds", + "reload config", "update ready", "quit" ] @@ -590,7 +590,7 @@ mod tests { assert_eq!( app.state.global_menu_labels(), - vec!["settings", "keybinds", "reload keybinds", "detach"] + vec!["settings", "keybinds", "reload config", "detach"] ); let menu = app.state.global_menu_rect(); @@ -615,7 +615,7 @@ mod tests { vec![ "settings", "keybinds", - "reload keybinds", + "reload config", "what's new", "quit" ] diff --git a/src/app/mod.rs b/src/app/mod.rs index 14c50f7f..c4806a77 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -247,7 +247,7 @@ impl App { detach_requested: false, request_new_workspace: false, request_new_tab: false, - request_reload_keybinds: false, + request_reload_config: false, request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, @@ -299,6 +299,7 @@ impl App { pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes, accent: crate::config::parse_color(&config.ui.accent), sound: config.ui.sound.clone(), + local_sound_playback: true, toast_config: config.ui.toast.clone(), keybinds: config.keybinds(), spinner_tick: 0, @@ -415,9 +416,9 @@ impl App { needs_render = true; } - if self.state.request_reload_keybinds { - self.state.request_reload_keybinds = false; - self.reload_keybinds(); + if self.state.request_reload_config { + self.state.request_reload_config = false; + self.reload_config(); needs_render = true; } @@ -531,32 +532,105 @@ impl App { crate::app::input::open_settings(&mut self.state); } - pub(crate) fn reload_keybinds(&mut self) { + pub(crate) fn reload_config(&mut self) -> crate::config::ConfigReloadReport { + self.apply_config_from_disk(true) + } + + pub(crate) fn apply_config_from_disk( + &mut self, + notify_success: bool, + ) -> crate::config::ConfigReloadReport { let previous_toast = self.state.toast.clone(); - match crate::config::load_live_keybinds() { + let report = match crate::config::load_live_config() { + Ok(loaded) => self.apply_live_config(&loaded.config, notify_success), + Err(diagnostics) => { + self.state.toast = None; + self.state.config_diagnostic = + crate::config::config_diagnostic_summary(&diagnostics); + self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8)); + crate::config::ConfigReloadReport { + status: crate::config::ConfigReloadStatus::Failed, + diagnostics, + } + } + }; + self.sync_toast_deadline(previous_toast); + report + } + + fn apply_live_config( + &mut self, + config: &crate::config::Config, + notify_success: bool, + ) -> crate::config::ConfigReloadReport { + let mut diagnostics = Vec::new(); + + match config.live_keybinds() { Ok(live) => { self.state.prefix_code = live.prefix.0; self.state.prefix_mods = live.prefix.1; self.state.keybinds = live.keybinds; - self.state.config_diagnostic = None; - self.config_diagnostic_deadline = None; + } + Err(keybind_diagnostics) => { + let mut message = keybind_diagnostics.join("; "); + if !message.contains("keeping current keybinds") { + message.push_str("; keeping current keybinds"); + } + diagnostics.push(message); + } + } + + diagnostics.extend(config.ui.sound.diagnostics()); + + let previous_default_sidebar_width = self.state.default_sidebar_width; + self.state.default_sidebar_width = config.ui.sidebar_width; + if self.state.sidebar_width == previous_default_sidebar_width { + self.state.sidebar_width = config.ui.sidebar_width; + } + self.state.confirm_close = config.ui.confirm_close; + self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes; + self.state.accent = crate::config::parse_color(&config.ui.accent); + self.state.sound = config.ui.sound.clone(); + self.state.toast_config = config.ui.toast.clone(); + self.state.palette = resolve_palette(config); + self.state.theme_name = config + .theme + .name + .clone() + .unwrap_or_else(|| "catppuccin".to_string()); + + let status = if diagnostics.is_empty() { + crate::config::ConfigReloadStatus::Applied + } else { + crate::config::ConfigReloadStatus::Partial + }; + + if diagnostics.is_empty() { + self.state.config_diagnostic = None; + self.config_diagnostic_deadline = None; + if notify_success { self.state.toast = Some(crate::app::state::ToastNotification { kind: crate::app::state::ToastKind::UpdateInstalled, - title: "reloaded keybinds".to_string(), + title: "reloaded config".to_string(), context: "using config.toml".to_string(), }); } - Err(diagnostics) => { - let mut message = diagnostics.join("; "); - if !message.contains("keeping current keybinds") { - message.push_str("; keeping current keybinds"); - } - self.state.toast = None; - self.state.config_diagnostic = Some(message); - self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8)); + } else { + self.state.config_diagnostic = crate::config::config_diagnostic_summary(&diagnostics); + self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8)); + if notify_success { + self.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::UpdateInstalled, + title: "reloaded config".to_string(), + context: "with warnings".to_string(), + }); } } - self.sync_toast_deadline(previous_toast); + + crate::config::ConfigReloadReport { + status, + diagnostics, + } } } @@ -794,30 +868,35 @@ mod tests { } #[test] - fn reload_keybinds_updates_live_state() { + fn reload_config_updates_live_state() { let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-keybinds-success"); + let path = temp_config_path("reload-config-success"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n", + "[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n[ui.toast]\ndelivery = \"herdr\"\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - app.reload_keybinds(); + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); assert_eq!(app.state.prefix_code, KeyCode::Char('a')); assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL); assert_eq!( app.state.keybinds.new_workspace, (KeyCode::Char('g'), KeyModifiers::empty()) ); + assert_eq!( + app.state.toast_config.delivery, + crate::config::ToastDelivery::Herdr + ); assert!(app.state.config_diagnostic.is_none()); let toast = app.state.toast.as_ref().unwrap(); assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled); - assert_eq!(toast.title, "reloaded keybinds"); + assert_eq!(toast.title, "reloaded config"); assert_eq!(toast.context, "using config.toml"); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); @@ -825,23 +904,32 @@ mod tests { } #[test] - fn reload_keybinds_keeps_current_state_on_invalid_binding() { + 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-keybinds-invalid"); + let path = temp_config_path("reload-config-invalid-keybind"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "[keys]\nnew_workspace = \"wat\"\n").unwrap(); + std::fs::write( + &path, + "[keys]\nnew_workspace = \"wat\"\n[ui.toast]\ndelivery = \"terminal\"\n", + ) + .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); let original_prefix = (app.state.prefix_code, app.state.prefix_mods); let original_keybinds = app.state.keybinds.new_workspace; - app.reload_keybinds(); + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); assert_eq!( (app.state.prefix_code, app.state.prefix_mods), original_prefix ); assert_eq!(app.state.keybinds.new_workspace, original_keybinds); + assert_eq!( + app.state.toast_config.delivery, + crate::config::ToastDelivery::Terminal + ); assert!(app .state .config_diagnostic @@ -850,6 +938,67 @@ mod tests { message.contains("keys.new_workspace") && message.contains("keeping current keybinds") })); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn settings_save_toast_delivery_persists_then_applies_live_config() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("settings-save-toast-delivery"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "onboarding = false\n").unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let mut app = test_app(); + assert_eq!( + app.state.toast_config.delivery, + crate::config::ToastDelivery::Off + ); + + app.save_toast_delivery(crate::config::ToastDelivery::Terminal); + + assert_eq!( + app.state.toast_config.delivery, + crate::config::ToastDelivery::Terminal + ); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("delivery = \"terminal\"")); + assert!(app.state.config_diagnostic.is_none()); + + 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_state_on_invalid_toml() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("reload-config-invalid-toml"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "[keys\nnew_workspace = \"g\"\n").unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let mut app = test_app(); + let original_prefix = (app.state.prefix_code, app.state.prefix_mods); + let original_keybinds = app.state.keybinds.new_workspace; + let original_toast_delivery = app.state.toast_config.delivery; + let report = app.reload_config(); + + assert_eq!(report.status, crate::config::ConfigReloadStatus::Failed); + assert_eq!( + (app.state.prefix_code, app.state.prefix_mods), + original_prefix + ); + assert_eq!(app.state.keybinds.new_workspace, original_keybinds); + assert_eq!(app.state.toast_config.delivery, original_toast_delivery); + assert!(app + .state + .config_diagnostic + .as_deref() + .is_some_and(|message| { + message.contains("config parse error") && message.contains("keeping current config") + })); assert!(app.state.toast.is_none()); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); diff --git a/src/app/state.rs b/src/app/state.rs index e54b431a..a51a72e7 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -593,7 +593,7 @@ pub struct AppState { pub detach_requested: bool, pub request_new_workspace: bool, pub request_new_tab: bool, - pub request_reload_keybinds: bool, + pub request_reload_config: bool, /// Set when UI interaction requested a clipboard write that must be /// handled by the outer App/event loop instead of directly from AppState. pub request_clipboard_write: Option>, @@ -636,6 +636,7 @@ pub struct AppState { #[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth pub accent: Color, pub sound: SoundConfig, + pub local_sound_playback: bool, pub toast_config: ToastConfig, pub keybinds: Keybinds, /// Frame counter for spinner animations (wraps around). @@ -717,7 +718,7 @@ impl AppState { detach_requested: false, request_new_workspace: false, request_new_tab: false, - request_reload_keybinds: false, + request_reload_config: false, request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, @@ -767,6 +768,7 @@ impl AppState { enabled: false, ..SoundConfig::default() }, + local_sound_playback: false, toast_config: ToastConfig::default(), keybinds: Keybinds { new_workspace: (KeyCode::Char('n'), KeyModifiers::empty()), @@ -777,6 +779,8 @@ impl AppState { close_workspace_label: "shift+d".into(), detach: None, detach_label: None, + reload_config: None, + reload_config_label: None, previous_workspace: None, previous_workspace_label: None, next_workspace: None, -- cgit v1.2.3