diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 4 | ||||
| -rw-r--r-- | src/api/schema.rs | 19 | ||||
| -rw-r--r-- | src/app/actions.rs | 2 | ||||
| -rw-r--r-- | src/app/api.rs | 10 | ||||
| -rw-r--r-- | src/app/config_io.rs | 25 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 8 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 26 | ||||
| -rw-r--r-- | src/app/input/settings.rs | 8 | ||||
| -rw-r--r-- | src/app/input/sidebar.rs | 12 | ||||
| -rw-r--r-- | src/app/mod.rs | 203 | ||||
| -rw-r--r-- | src/app/state.rs | 8 | ||||
| -rw-r--r-- | src/cli.rs | 14 | ||||
| -rw-r--r-- | src/config.rs | 8 | ||||
| -rw-r--r-- | src/config/io.rs | 42 | ||||
| -rw-r--r-- | src/config/keybinds.rs | 50 | ||||
| -rw-r--r-- | src/config/model.rs | 17 | ||||
| -rw-r--r-- | src/main.rs | 15 | ||||
| -rw-r--r-- | src/server/headless.rs | 53 | ||||
| -rw-r--r-- | src/ui/keybind_help.rs | 4 |
19 files changed, 389 insertions, 139 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs index cb4f120a..33a3cd44 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -30,7 +30,8 @@ const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5); pub(crate) fn request_changes_ui(request: &Request) -> bool { matches!( &request.method, - Method::WorkspaceCreate(_) + Method::ServerReloadConfig(_) + | Method::WorkspaceCreate(_) | Method::WorkspaceFocus(_) | Method::WorkspaceRename(_) | Method::WorkspaceClose(_) @@ -317,6 +318,7 @@ fn api_method_name(method: &Method) -> &'static str { match method { Method::Ping(_) => "ping", Method::ServerStop(_) => "server.stop", + Method::ServerReloadConfig(_) => "server.reload_config", Method::WorkspaceCreate(_) => "workspace.create", Method::WorkspaceList(_) => "workspace.list", Method::WorkspaceGet(_) => "workspace.get", diff --git a/src/api/schema.rs b/src/api/schema.rs index 6cb81c12..f7665fd9 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -14,6 +14,8 @@ pub enum Method { Ping(PingParams), #[serde(rename = "server.stop")] ServerStop(EmptyParams), + #[serde(rename = "server.reload_config")] + ServerReloadConfig(EmptyParams), #[serde(rename = "workspace.create")] WorkspaceCreate(WorkspaceCreateParams), #[serde(rename = "workspace.list")] @@ -475,6 +477,10 @@ pub enum ResponseResult { target: IntegrationTarget, details: IntegrationUninstallResult, }, + ConfigReload { + status: crate::config::ConfigReloadStatus, + diagnostics: Vec<String>, + }, Ok {}, } @@ -766,6 +772,19 @@ mod tests { } #[test] + fn request_round_trips_for_server_reload_config() { + let request = Request { + id: "req_reload".into(), + method: Method::ServerReloadConfig(EmptyParams::default()), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "server.reload_config"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); + } + + #[test] fn unknown_method_is_rejected() { let json = r#"{"id":"req_1","method":"nope","params":{}}"#; let err = serde_json::from_str::<Request>(json) 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<F>(&mut self, error_context: &str, update: F) + pub(super) fn update_config_file<F>(&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<GlobalMenuAction> { 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, } @@ -422,6 +423,12 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateA return Some(NavigateAction::ToggleSidebar); } if kb + .reload_config + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::ReloadConfig); + } + if kb .detach .is_some_and(|(code, mods)| key_matches(key, code, mods)) { @@ -498,6 +505,10 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi state.sidebar_collapsed = !state.sidebar_collapsed; leave_navigate_mode(state); } + NavigateAction::ReloadConfig => { + state.request_reload_config = true; + leave_navigate_mode(state); + } NavigateAction::Detach => { state.detach_requested = true; leave_navigate_mode(state); @@ -583,6 +594,21 @@ mod tests { } #[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"]); state.selected = 0; 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<Vec<u8>>, @@ -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, @@ -49,6 +49,7 @@ fn run_server_command(args: &[String]) -> std::io::Result<Option<i32>> { match subcommand { "stop" => server_stop(&args[1..]).map(Some), + "reload-config" => server_reload_config(&args[1..]).map(Some), "help" | "--help" | "-h" => { print_server_help(); Ok(Some(0)) @@ -163,6 +164,18 @@ fn server_stop(args: &[String]) -> std::io::Result<i32> { send_ok_request(Method::ServerStop(EmptyParams::default())) } +fn server_reload_config(args: &[String]) -> std::io::Result<i32> { + if !args.is_empty() { + eprintln!("usage: herdr server reload-config"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:server:reload-config".into(), + method: Method::ServerReloadConfig(EmptyParams::default()), + })?) +} + fn workspace_list(args: &[String]) -> std::io::Result<i32> { if !args.is_empty() { eprintln!("usage: herdr workspace list"); @@ -1041,6 +1054,7 @@ fn print_server_help() { eprintln!("herdr server commands:"); eprintln!(" herdr server run as headless server"); eprintln!(" herdr server stop stop the running server via the api socket"); + eprintln!(" herdr server reload-config reload config.toml in the running server"); } fn print_workspace_help() { diff --git a/src/config.rs b/src/config.rs index 71b20f52..e96191bc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,15 +8,15 @@ mod theme; pub use self::{ io::{ - config_dir, config_path, load_live_keybinds, remove_section_key, upsert_section_bool, - upsert_section_value, + config_diagnostic_summary, config_dir, config_path, load_live_config, remove_section_key, + upsert_section_bool, upsert_section_value, }, keybinds::{ format_key_combo, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind, Keybinds, LiveKeybindConfig, }, - model::{Config, ToastConfig, ToastDelivery}, - sound::{AgentSoundSetting, SoundConfig}, + model::{Config, ConfigReloadReport, ConfigReloadStatus, ToastConfig, ToastDelivery}, + sound::SoundConfig, theme::{parse_color, CustomThemeColors, ThemeConfig}, }; diff --git a/src/config/io.rs b/src/config/io.rs index 1a3c1920..2774fc93 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use tracing::warn; -use super::{model::LoadedConfig, Config, LiveKeybindConfig, CONFIG_PATH_ENV_VAR}; +use super::{model::LoadedConfig, Config, CONFIG_PATH_ENV_VAR}; pub fn app_dir_name() -> &'static str { if cfg!(debug_assertions) { @@ -77,23 +77,37 @@ pub fn config_path() -> PathBuf { config_dir().join("config.toml") } -pub fn load_live_keybinds() -> Result<LiveKeybindConfig, Vec<String>> { +pub fn config_diagnostic_summary(diagnostics: &[String]) -> Option<String> { + if diagnostics.is_empty() { + None + } else if diagnostics.len() == 1 { + Some(diagnostics[0].clone()) + } else { + Some(format!( + "{} (and {} more)", + diagnostics[0], + diagnostics.len() - 1 + )) + } +} + +pub fn load_live_config() -> Result<LoadedConfig, Vec<String>> { let path = config_path(); if !path.exists() { - return Config::default().live_keybinds(); + return Ok(LoadedConfig { + config: Config::default(), + diagnostics: Vec::new(), + }); } - let content = std::fs::read_to_string(&path).map_err(|err| { - vec![format!( - "config read error: {err}; keeping current keybinds" - )] - })?; - let config = toml::from_str::<Config>(&content).map_err(|err| { - vec![format!( - "config parse error: {err}; keeping current keybinds" - )] - })?; - config.live_keybinds() + let content = std::fs::read_to_string(&path) + .map_err(|err| vec![format!("config read error: {err}; keeping current config")])?; + let config = toml::from_str::<Config>(&content) + .map_err(|err| vec![format!("config parse error: {err}; keeping current config")])?; + Ok(LoadedConfig { + config, + diagnostics: Vec::new(), + }) } pub(crate) fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String { diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 07c682ba..4a54dd74 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -65,6 +65,8 @@ pub struct Keybinds { pub close_workspace_label: String, pub detach: Option<(KeyCode, KeyModifiers)>, pub detach_label: Option<String>, + pub reload_config: Option<(KeyCode, KeyModifiers)>, + pub reload_config_label: Option<String>, pub previous_workspace: Option<(KeyCode, KeyModifiers)>, pub previous_workspace_label: Option<String>, pub next_workspace: Option<(KeyCode, KeyModifiers)>, @@ -337,6 +339,12 @@ impl Config { ), optional_binding( BindingScope::Navigate, + "keys.reload_config", + &self.keys.reload_config, + &mut diagnostics, + ), + optional_binding( + BindingScope::Navigate, "keys.previous_workspace", &self.keys.previous_workspace, &mut diagnostics, @@ -578,28 +586,30 @@ impl Config { close_workspace_label: bindings[2].label.clone(), detach: optional_bindings[0].value, detach_label: optional_bindings[0].label.clone(), - previous_workspace: optional_bindings[1].value, - previous_workspace_label: optional_bindings[1].label.clone(), - next_workspace: optional_bindings[2].value, - next_workspace_label: optional_bindings[2].label.clone(), + reload_config: optional_bindings[1].value, + reload_config_label: optional_bindings[1].label.clone(), + previous_workspace: optional_bindings[2].value, + previous_workspace_label: optional_bindings[2].label.clone(), + next_workspace: optional_bindings[3].value, + next_workspace_label: optional_bindings[3].label.clone(), new_tab: bindings[3].value, new_tab_label: bindings[3].label.clone(), - rename_tab: optional_bindings[3].value, - rename_tab_label: optional_bindings[3].label.clone(), - previous_tab: optional_bindings[4].value, - previous_tab_label: optional_bindings[4].label.clone(), - next_tab: optional_bindings[5].value, - next_tab_label: optional_bindings[5].label.clone(), - close_tab: optional_bindings[6].value, - close_tab_label: optional_bindings[6].label.clone(), - focus_pane_left: optional_bindings[7].value, - focus_pane_left_label: optional_bindings[7].label.clone(), - focus_pane_down: optional_bindings[8].value, - focus_pane_down_label: optional_bindings[8].label.clone(), - focus_pane_up: optional_bindings[9].value, - focus_pane_up_label: optional_bindings[9].label.clone(), - focus_pane_right: optional_bindings[10].value, - focus_pane_right_label: optional_bindings[10].label.clone(), + rename_tab: optional_bindings[4].value, + rename_tab_label: optional_bindings[4].label.clone(), + previous_tab: optional_bindings[5].value, + previous_tab_label: optional_bindings[5].label.clone(), + next_tab: optional_bindings[6].value, + next_tab_label: optional_bindings[6].label.clone(), + close_tab: optional_bindings[7].value, + close_tab_label: optional_bindings[7].label.clone(), + focus_pane_left: optional_bindings[8].value, + focus_pane_left_label: optional_bindings[8].label.clone(), + focus_pane_down: optional_bindings[9].value, + focus_pane_down_label: optional_bindings[9].label.clone(), + focus_pane_up: optional_bindings[10].value, + focus_pane_up_label: optional_bindings[10].label.clone(), + focus_pane_right: optional_bindings[11].value, + focus_pane_right_label: optional_bindings[11].label.clone(), split_vertical: bindings[4].value, split_vertical_label: bindings[4].label.clone(), split_horizontal: bindings[5].value, diff --git a/src/config/model.rs b/src/config/model.rs index e2c39f25..32d60493 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -16,6 +16,20 @@ pub struct ToastConfig { pub delivery: ToastDelivery, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConfigReloadStatus { + Applied, + Partial, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ConfigReloadReport { + pub status: ConfigReloadStatus, + pub diagnostics: Vec<String>, +} + #[derive(Debug, Default, Deserialize)] #[serde(default)] pub struct Config { @@ -45,6 +59,8 @@ pub struct KeysConfig { pub close_workspace: String, /// Optional explicit detach shortcut in server/client mode. Unset by default. pub detach: String, + /// Reload config.toml in the running app/server. Unset by default. + pub reload_config: String, /// Select the previous workspace. Unset by default. pub previous_workspace: String, /// Select the next workspace. Unset by default. @@ -116,6 +132,7 @@ impl Default for KeysConfig { rename_workspace: "shift+n".into(), close_workspace: "shift+d".into(), detach: "".into(), + reload_config: "".into(), previous_workspace: "".into(), next_workspace: "".into(), new_tab: "c".into(), diff --git a/src/main.rs b/src/main.rs index 982e787d..9b66198f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,6 +83,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # previous_workspace = "" # optional, unset by default # next_workspace = "" # optional, unset by default # detach = "" # optional explicit detach shortcut in server/client mode +# reload_config = "" # optional shortcut to reload config.toml without restarting # new_tab = "c" # rename_tab = "" # optional, unset by default # previous_tab = "" # optional, unset by default @@ -190,6 +191,7 @@ fn main() -> io::Result<()> { println!("Usage: herdr [options]"); println!(" herdr update"); println!(" herdr server stop"); + println!(" herdr server reload-config"); println!(" herdr workspace <subcommand> ..."); println!(" herdr tab <subcommand> ..."); println!(" herdr pane <subcommand> ..."); @@ -199,6 +201,7 @@ fn main() -> io::Result<()> { println!("Commands:"); println!(" server Run as headless server (no terminal, persists after client disconnect)"); println!(" server stop Stop the running server via the API socket"); + println!(" server reload-config Reload config.toml in the running server"); println!(" client Connect to a running server as a thin client"); println!( " update Download and install the latest version (run outside herdr)" @@ -322,17 +325,7 @@ fn main() -> io::Result<()> { })); let config = &loaded_config.config; - let config_diagnostic = if loaded_config.diagnostics.is_empty() { - None - } else if loaded_config.diagnostics.len() == 1 { - Some(loaded_config.diagnostics[0].clone()) - } else { - Some(format!( - "{} (and {} more)", - loaded_config.diagnostics[0], - loaded_config.diagnostics.len() - 1 - )) - }; + let config_diagnostic = config::config_diagnostic_summary(&loaded_config.diagnostics); logging::startup("app"); // Background update check (non-blocking, best-effort) diff --git a/src/server/headless.rs b/src/server/headless.rs index d5829f71..c2e814bf 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -512,9 +512,9 @@ impl HeadlessServer { needs_render = true; } - if self.app.state.request_reload_keybinds { - self.app.state.request_reload_keybinds = false; - self.app.reload_keybinds(); + if self.app.state.request_reload_config { + self.app.state.request_reload_config = false; + self.app.reload_config(); needs_render = true; } @@ -800,17 +800,11 @@ impl HeadlessServer { .unwrap_or(crate::detect::AgentState::Unknown); // Handle the state change (updates pane state, sets toast on AppState). - // Note: apply_pane_state_change inside handle_internal_event will try - // to play sounds, but sound.enabled=false in the headless server, so - // sound::play is never called. Toast may still be set on AppState if - // toast_config.enabled is true. + // Headless mode disables local sound playback separately from the + // sound policy so reloads can keep server-side notification policy live. self.app.handle_internal_event(ev); - // Forward sound notification to clients. - // We check the agent-specific sound setting but NOT sound.enabled, - // because the server sets enabled=false to prevent local playback — - // clients should still receive sound notifications and decide - // locally whether to play them based on their own config. + // Forward sound notification to clients when server-side sound policy allows it. let is_active_tab = self .app .state @@ -821,10 +815,7 @@ impl HeadlessServer { .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) }); - if !matches!( - self.app.state.sound.agents.for_agent(agent_val), - crate::config::AgentSoundSetting::Off - ) { + if self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( is_active_tab, prev_state, @@ -906,9 +897,10 @@ impl HeadlessServer { self.app.handle_internal_event(ev); - // Forward sound notification based on hook state transition. - // This ensures API-reported state changes (pane.report_agent) - // produce notifications even before fallback detection confirms. + // Forward sound notification based on hook state transition when + // server-side sound policy allows it. This ensures API-reported state + // changes (pane.report_agent) produce notifications even before + // fallback detection confirms. let is_active_tab = self .app .state @@ -919,10 +911,7 @@ impl HeadlessServer { .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) }); - if !matches!( - self.app.state.sound.agents.for_agent(agent_val), - crate::config::AgentSoundSetting::Off - ) { + if self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( is_active_tab, prev_hook_state, @@ -1261,8 +1250,8 @@ impl HeadlessServer { // forward any resulting notifications to connected clients. // API requests like pane.report_agent trigger handle_internal_event // internally, which bypasses drain_internal_events_with_forwarding. - // Since sound.enabled=false in the headless server, sounds would be - // silently dropped; toasts may be set but not forwarded. + // Headless mode disables local sound playback, so sound notifications + // need to be forwarded to clients here; toasts may be set but not forwarded. // // Note: pane.report_agent sets hook_authority on the pane, but the // effective state may not change until the fallback detector confirms @@ -1411,13 +1400,9 @@ impl HeadlessServer { } } - // Check agent-specific sound setting but NOT sound.enabled, - // because the server sets enabled=false to prevent local playback. - // Clients decide locally whether to play sounds. - if !matches!( - self.app.state.sound.agents.for_agent(agent), - crate::config::AgentSoundSetting::Off - ) { + // Forward sound notification when server-side sound policy allows it. + // Clients still decide locally whether they can execute the side effect. + if self.app.state.sound.allows(agent) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( is_active_tab, prev_state, @@ -1957,7 +1942,7 @@ pub fn run_server() -> io::Result<()> { let mut app = app::App::new( &loaded_config.config, no_session, - None, // config_diagnostic + config::config_diagnostic_summary(&loaded_config.diagnostics), None, // startup_release_notes api_rx, event_hub, @@ -1966,7 +1951,7 @@ pub fn run_server() -> io::Result<()> { // The server runs headless — disable local sound playback. // Sound notifications are forwarded to connected clients as // ServerMessage::Notify instead of played locally. - app.state.sound.enabled = false; + app.state.local_sound_playback = false; // Create the headless server. let mut server = match HeadlessServer::new(app) { diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index ccff95ab..2bb05c50 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -32,6 +32,10 @@ pub(super) fn keybind_help_groups( "navigate mode", ), ("prefix + ?".to_string(), "keybinds"), + ( + optional_keybind_label(&kb.reload_config_label), + "reload config", + ), ], )); |
