From 74a6161bff998c311d0d43bf6c44de5e193c23e6 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Thu, 9 Apr 2026 04:06:02 +0300 Subject: feat: add manual reload keybinds action --- src/app/input.rs | 46 ++++++++++++++++++++--- src/app/mod.rs | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/app/state.rs | 2 + src/config.rs | 65 +++++++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/app/input.rs b/src/app/input.rs index c7874bbc..ada8e6d9 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -478,11 +478,16 @@ enum GlobalMenuAction { ApplyUpdate, WhatsNew, Keybinds, + ReloadKeybinds, Settings, } fn global_menu_actions(state: &AppState) -> Vec { - let mut actions = vec![GlobalMenuAction::Settings, GlobalMenuAction::Keybinds]; + let mut actions = vec![ + GlobalMenuAction::Settings, + GlobalMenuAction::Keybinds, + GlobalMenuAction::ReloadKeybinds, + ]; if state.latest_release_notes_available || state.update_available.is_some() { actions.push(GlobalMenuAction::WhatsNew); } @@ -652,6 +657,10 @@ fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) { GlobalMenuAction::ApplyUpdate => state.should_quit = true, GlobalMenuAction::WhatsNew => open_update_release_notes(state), GlobalMenuAction::Keybinds => open_keybind_help(state), + GlobalMenuAction::ReloadKeybinds => { + state.request_reload_keybinds = true; + leave_modal(state); + } GlobalMenuAction::Settings => open_settings(state), } } @@ -1594,7 +1603,7 @@ impl AppState { } pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> { - let mut labels = vec!["settings", "keybinds"]; + let mut labels = vec!["settings", "keybinds", "reload keybinds"]; if self.latest_release_notes_available || self.update_available.is_some() { labels.push("what's new"); } @@ -3508,6 +3517,27 @@ mod tests { assert_eq!(app.state.mode, Mode::Settings); } + #[test] + fn clicking_reload_keybinds_menu_item_requests_reload() { + let mut app = app_for_mouse_test(); + let launcher = app.state.global_launcher_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + launcher.x, + launcher.y, + )); + + let menu = app.state.global_menu_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + menu.x + 2, + menu.y + 3, + )); + + assert!(app.state.request_reload_keybinds); + assert_eq!(app.state.mode, Mode::Navigate); + } + #[test] fn update_pending_menu_surfaces_apply_update_action() { let mut app = app_for_mouse_test(); @@ -3523,14 +3553,20 @@ mod tests { assert_eq!( app.state.global_menu_labels(), - vec!["settings", "keybinds", "what's new", "quit to apply update"] + vec![ + "settings", + "keybinds", + "reload keybinds", + "what's new", + "quit to apply update" + ] ); let menu = app.state.global_menu_rect(); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), menu.x + 2, - menu.y + 4, + menu.y + 5, )); assert!(app.state.should_quit); @@ -3543,7 +3579,7 @@ mod tests { assert_eq!( app.state.global_menu_labels(), - vec!["settings", "keybinds", "what's new"] + vec!["settings", "keybinds", "reload keybinds", "what's new"] ); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 22e4eef6..842c91f9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -238,6 +238,7 @@ impl App { should_quit: false, request_new_workspace: false, request_new_tab: false, + request_reload_keybinds: false, creating_new_tab: false, requested_new_tab_name: None, request_complete_onboarding: false, @@ -435,6 +436,12 @@ impl App { needs_render = true; } + if self.state.request_reload_keybinds { + self.state.request_reload_keybinds = false; + self.reload_keybinds(); + needs_render = true; + } + let now = Instant::now(); self.sync_animation_timer(now); @@ -2027,6 +2034,34 @@ impl App { } } + fn reload_keybinds(&mut self) { + let previous_toast = self.state.toast.clone(); + match crate::config::load_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; + self.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::UpdateInstalled, + title: "reloaded keybinds".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)); + } + } + self.sync_toast_deadline(previous_toast); + } + fn save_theme(&mut self, name: &str) { self.update_config_file("theme", |content| { crate::config::upsert_section_value(content, "theme", "name", &format!("\"{name}\"")) @@ -2399,6 +2434,7 @@ mod tests { use crate::detect::{Agent, AgentState}; use crate::workspace::Workspace; use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; + use std::sync::{Mutex, OnceLock}; fn raw_key( code: KeyCode, @@ -2431,6 +2467,79 @@ mod tests { ) } + fn config_env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn temp_config_path(name: &str) -> std::path::PathBuf { + let unique = format!( + "herdr-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + std::env::temp_dir().join(unique).join("config.toml") + } + + #[test] + fn reload_keybinds_updates_live_state() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("reload-keybinds-success"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n").unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let mut app = test_app(); + app.reload_keybinds(); + + 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!(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.context, "using config.toml"); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn reload_keybinds_keeps_current_state_on_invalid_binding() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("reload-keybinds-invalid"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "[keys]\nnew_workspace = \"wat\"\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(); + + assert_eq!((app.state.prefix_code, app.state.prefix_mods), original_prefix); + assert_eq!(app.state.keybinds.new_workspace, original_keybinds); + assert!(app + .state + .config_diagnostic + .as_deref() + .is_some_and(|message| { + message.contains("keys.new_workspace") + && message.contains("keeping current keybinds") + })); + assert!(app.state.toast.is_none()); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + #[tokio::test] async fn raw_input_waits_when_reader_is_gone() { let result = diff --git a/src/app/state.rs b/src/app/state.rs index ea388daa..008d45c8 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -574,6 +574,7 @@ pub struct AppState { pub should_quit: bool, pub request_new_workspace: bool, pub request_new_tab: bool, + pub request_reload_keybinds: bool, pub creating_new_tab: bool, pub requested_new_tab_name: Option, pub request_complete_onboarding: bool, @@ -687,6 +688,7 @@ impl AppState { should_quit: false, request_new_workspace: false, request_new_tab: false, + request_reload_keybinds: false, creating_new_tab: false, requested_new_tab_name: None, request_complete_onboarding: false, diff --git a/src/config.rs b/src/config.rs index bd6b93b9..9992db28 100644 --- a/src/config.rs +++ b/src/config.rs @@ -90,6 +90,12 @@ pub struct LoadedConfig { pub diagnostics: Vec, } +#[derive(Debug)] +pub struct LiveKeybindConfig { + pub prefix: (KeyCode, KeyModifiers), + pub keybinds: Keybinds, +} + #[derive(Debug, Deserialize)] #[serde(default)] pub struct KeysConfig { @@ -425,6 +431,16 @@ impl Config { .chain(self.ui.sound.diagnostics()) .collect() } + + pub fn live_keybinds(&self) -> Result> { + let (prefix_diag, prefix, keybind_diags, keybinds) = self.validated_keybinds(); + let diagnostics: Vec = prefix_diag.into_iter().chain(keybind_diags).collect(); + if diagnostics.is_empty() { + Ok(LiveKeybindConfig { prefix, keybinds }) + } else { + Err(diagnostics) + } + } } fn resolve_config_relative_path(path: &Path) -> PathBuf { @@ -819,6 +835,19 @@ pub fn config_path() -> PathBuf { config_dir().join("config.toml") } +pub fn load_live_keybinds() -> Result> { + let path = config_path(); + if !path.exists() { + return Config::default().live_keybinds(); + } + + 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::(&content) + .map_err(|err| vec![format!("config parse error: {err}; keeping current keybinds")])?; + config.live_keybinds() +} + fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String { let replacement = format!("{key} = {value}"); let mut lines: Vec = content.lines().map(|line| line.to_string()).collect(); @@ -1285,6 +1314,42 @@ rename_workspace = "g" assert_eq!(kb.rename_workspace_label, "shift+n"); } + #[test] + fn live_keybinds_reject_invalid_keybinding() { + let config: Config = toml::from_str( + r#" +[keys] +rename_workspace = "wat" +"#, + ) + .unwrap(); + + let diagnostics = config.live_keybinds().unwrap_err(); + assert!(diagnostics + .iter() + .any(|d| d.contains("keys.rename_workspace"))); + } + + #[test] + fn live_keybinds_ignore_non_key_diagnostics() { + let config: Config = toml::from_str( + r#" +[keys] +new_workspace = "g" + +[ui.sound] +done_path = "sounds/missing.mp3" +"#, + ) + .unwrap(); + + let live = config.live_keybinds().unwrap(); + assert_eq!( + live.keybinds.new_workspace, + (KeyCode::Char('g'), KeyModifiers::empty()) + ); + } + #[test] fn sound_table_config_parses() { let toml = r#" -- cgit v1.2.3