diff --git a/crates/rgitui_settings/src/lib.rs b/crates/rgitui_settings/src/lib.rs index 09f71fd..9c52dc0 100644 --- a/crates/rgitui_settings/src/lib.rs +++ b/crates/rgitui_settings/src/lib.rs @@ -10,6 +10,8 @@ use std::str::FromStr; use std::sync::{Mutex, OnceLock, RwLock}; use uuid::Uuid; +pub mod shortcuts; + /// Controls the compactness of the UI layout. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] @@ -250,6 +252,9 @@ pub struct AppSettings { pub commit_limit: usize, #[serde(default)] pub watch_all_worktrees: bool, + /// Keyboard shortcut overrides. + #[serde(default)] + pub shortcuts: crate::shortcuts::CustomShortcuts, /// Last known position/size of the standalone Settings window. Restored /// on next open if the saved origin still falls within a connected display. #[serde(default)] @@ -533,6 +538,7 @@ impl Default for AppSettings { last_update_check_at: None, commit_limit: default_commit_limit(), watch_all_worktrees: false, + shortcuts: crate::shortcuts::CustomShortcuts::default(), settings_window_bounds: None, } } diff --git a/crates/rgitui_settings/src/shortcuts.rs b/crates/rgitui_settings/src/shortcuts.rs new file mode 100644 index 0000000..a8d4f40 --- /dev/null +++ b/crates/rgitui_settings/src/shortcuts.rs @@ -0,0 +1,357 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// A command identifier as stored in shortcut override keys. +/// Matches the `CommandId::as_str()` variant names from command_palette.rs. +pub const COMMAND_IDS: &[&str] = &[ + "fetch", + "pull", + "push", + "push_all", + "pull_all", + "force_push", + "commit", + "stage_all", + "unstage_all", + "stash_save", + "stash_pop", + "stash_apply", + "stash_drop", + "create_branch", + "delete_branch", + "rename_branch", + "merge_branch", + "create_tag", + "create_worktree", + "create_pr", + "cherry_pick", + "revert_commit", + "interactive_rebase", + "discard_all", + "clean_untracked", + "reset_hard", + "abort_operation", + "continue_merge", + "toggle_diff_mode", + "search", + "ai_message", + "refresh", + "settings", + "open_repo", + "workspace_home", + "restore_last_workspace", + "shortcuts", + "switch_branch", + "blame", + "undo", + "file_history", + "reflog", + "submodules", + "bisect", + "bisect_start", + "bisect_good", + "bisect_bad", + "bisect_reset", + "bisect_skip", + "global_search", + "toggle_issues", + "toggle_pull_requests", + "toggle_branch_health", + "toggle_stashes", + "stash_branch", + "open_theme_editor", +]; + +/// Parsed keyboard shortcut — extracted modifier flags and key label. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedShortcut { + pub control: bool, + pub shift: bool, + pub alt: bool, + pub platform: bool, + pub key: String, +} + +impl std::fmt::Display for ParsedShortcut { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut parts = Vec::new(); + if self.control { + parts.push("Ctrl"); + } + if self.shift { + parts.push("Shift"); + } + if self.alt { + parts.push("Alt"); + } + if self.platform { + parts.push("Cmd"); + } + parts.push(&self.key); + write!(f, "{}", parts.join("+")) + } +} + +impl ParsedShortcut { + /// Parse a shortcut string like "Ctrl+Shift+R" or "Alt+5" into a `ParsedShortcut`. + /// Returns `None` for empty strings or strings with no valid key component. + pub fn parse(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + let tokens: Vec<&str> = s.split('+').map(|t| t.trim()).collect(); + if tokens.is_empty() { + return None; + } + let key = tokens.last()?.to_string(); + if key.is_empty() { + return None; + } + let mut shortcut = ParsedShortcut { + control: false, + shift: false, + alt: false, + platform: false, + key, + }; + for token in tokens.iter().take(tokens.len().saturating_sub(1)) { + match token.to_lowercase().as_str() { + "ctrl" | "control" => shortcut.control = true, + "shift" => shortcut.shift = true, + "alt" | "option" => shortcut.alt = true, + "cmd" | "command" | "super" | "meta" => shortcut.platform = true, + _ => {} + } + } + Some(shortcut) + } + + /// Returns true if this shortcut has no modifiers (key only, e.g. "j", "/", "?"). + pub fn is_key_only(&self) -> bool { + !self.control && !self.shift && !self.alt && !self.platform + } +} + +/// A keyboard shortcut override for a specific command. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct CustomShortcuts { + /// Command shortcut overrides. Keys are `CommandId::as_str()` names (e.g. + /// `"toggle_stashes"`), values are shortcut strings (e.g. `"Alt+8"`). + #[serde(default)] + pub overrides: HashMap, +} + +impl CustomShortcuts { + /// Returns the shortcut string for the given command, or `None` if no custom + /// override is configured. + pub fn shortcut_for(&self, command: &str) -> Option<&str> { + self.overrides.get(command).map(|s| s.as_str()) + } + + /// Returns the parsed custom shortcut for `command`, or `None` if no override + /// is set or the stored string is unparseable. + pub fn parsed_shortcut_for(&self, command: &str) -> Option { + self.shortcut_for(command).and_then(ParsedShortcut::parse) + } + + /// Returns `true` if any command has a custom shortcut override. + pub fn has_overrides(&self) -> bool { + !self.overrides.is_empty() + } + + /// Validates that all override keys are recognized `CommandId` names. + /// Returns a list of unknown command names found. + pub fn validate(&self) -> Vec<&str> { + self.overrides + .keys() + .filter(|k| !COMMAND_IDS.contains(&k.as_str())) + .map(|k| k.as_str()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn custom_shortcuts_default_is_empty() { + let shortcuts = CustomShortcuts::default(); + assert!(shortcuts.overrides.is_empty()); + assert!(!shortcuts.has_overrides()); + } + + #[test] + fn custom_shortcuts_serde_roundtrip() { + let mut shortcuts = CustomShortcuts::default(); + shortcuts + .overrides + .insert("toggle_issues".to_string(), "Alt+5".to_string()); + shortcuts + .overrides + .insert("toggle_stashes".to_string(), "Alt+8".to_string()); + + let json = serde_json::to_string(&shortcuts).unwrap(); + let parsed: CustomShortcuts = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.overrides.len(), 2); + assert_eq!( + parsed.overrides.get("toggle_issues"), + Some(&"Alt+5".to_string()) + ); + } + + #[test] + fn custom_shortcuts_empty_json() { + let json = "{}"; + let shortcuts: CustomShortcuts = serde_json::from_str(json).unwrap(); + assert!(shortcuts.overrides.is_empty()); + } + + #[test] + fn parsed_shortcut_ctrl_shift_r() { + let p = ParsedShortcut::parse("Ctrl+Shift+R").unwrap(); + assert!(p.control); + assert!(p.shift); + assert!(!p.alt); + assert!(!p.platform); + assert_eq!(p.key, "R"); + } + + #[test] + fn parsed_shortcut_alt_5() { + let p = ParsedShortcut::parse("Alt+5").unwrap(); + assert!(!p.control); + assert!(!p.shift); + assert!(p.alt); + assert!(!p.platform); + assert_eq!(p.key, "5"); + } + + #[test] + fn parsed_shortcut_case_insensitive() { + let p = ParsedShortcut::parse("CTRL+SHIFT+R").unwrap(); + assert!(p.control); + assert!(p.shift); + } + + #[test] + fn parsed_shortcut_platform_key() { + let p = ParsedShortcut::parse("Cmd+K").unwrap(); + assert!(!p.control); + assert!(!p.shift); + assert!(!p.alt); + assert!(p.platform); + assert_eq!(p.key, "K"); + } + + #[test] + fn parsed_shortcut_simple_key() { + let p = ParsedShortcut::parse("j").unwrap(); + assert!(!p.control); + assert!(!p.shift); + assert!(!p.alt); + assert!(!p.platform); + assert_eq!(p.key, "j"); + assert!(p.is_key_only()); + } + + #[test] + fn parsed_shortcut_question_mark() { + let p = ParsedShortcut::parse("?").unwrap(); + assert!(p.is_key_only()); + assert_eq!(p.key, "?"); + } + + #[test] + fn parsed_shortcut_empty_string() { + assert!(ParsedShortcut::parse("").is_none()); + assert!(ParsedShortcut::parse(" ").is_none()); + } + + #[test] + fn parsed_shortcut_display() { + let p = ParsedShortcut::parse("Ctrl+Shift+R").unwrap(); + assert_eq!(format!("{}", p), "Ctrl+Shift+R"); + } + + #[test] + fn shortcut_for_returns_override() { + let mut shortcuts = CustomShortcuts::default(); + shortcuts + .overrides + .insert("toggle_stashes".to_string(), "Alt+8".to_string()); + + assert_eq!(shortcuts.shortcut_for("toggle_stashes"), Some("Alt+8")); + assert_eq!(shortcuts.shortcut_for("nonexistent"), None); + } + + #[test] + fn parsed_shortcut_for_valid() { + let mut shortcuts = CustomShortcuts::default(); + shortcuts + .overrides + .insert("toggle_stashes".to_string(), "Alt+8".to_string()); + + let p = shortcuts.parsed_shortcut_for("toggle_stashes").unwrap(); + assert!(p.alt); + assert_eq!(p.key, "8"); + } + + #[test] + fn parsed_shortcut_for_invalid_string() { + let mut shortcuts = CustomShortcuts::default(); + shortcuts + .overrides + .insert("toggle_stashes".to_string(), "".to_string()); + + assert!(shortcuts.parsed_shortcut_for("toggle_stashes").is_none()); + } + + #[test] + fn validate_rejects_unknown_commands() { + let mut shortcuts = CustomShortcuts::default(); + shortcuts + .overrides + .insert("toggle_stashes".to_string(), "Alt+8".to_string()); + shortcuts + .overrides + .insert("fake_command".to_string(), "Ctrl+X".to_string()); + + let invalid = shortcuts.validate(); + assert_eq!(invalid, vec!["fake_command"]); + } + + #[test] + fn validate_accepts_known_commands() { + let mut shortcuts = CustomShortcuts::default(); + shortcuts + .overrides + .insert("toggle_stashes".to_string(), "Alt+8".to_string()); + shortcuts + .overrides + .insert("fetch".to_string(), "F".to_string()); + + assert!(shortcuts.validate().is_empty()); + } + + #[test] + fn parsed_shortcut_aliases() { + // control = ctrl + let a = ParsedShortcut::parse("ctrl+K").unwrap(); + let b = ParsedShortcut::parse("control+K").unwrap(); + assert_eq!(a, b); + + // alt = option + let a = ParsedShortcut::parse("Alt+K").unwrap(); + let b = ParsedShortcut::parse("Option+K").unwrap(); + assert_eq!(a, b); + + // platform = cmd = command = super = meta + for alias in ["Cmd", "Command", "Super", "Meta"] { + let p = ParsedShortcut::parse(&format!("{}+K", alias)).unwrap(); + assert!(p.platform, "alias {} should set platform=true", alias); + } + } +} diff --git a/crates/rgitui_workspace/src/workspace/key_handler.rs b/crates/rgitui_workspace/src/workspace/key_handler.rs index c2434f1..a925f2e 100644 --- a/crates/rgitui_workspace/src/workspace/key_handler.rs +++ b/crates/rgitui_workspace/src/workspace/key_handler.rs @@ -31,6 +31,14 @@ impl Workspace { let key = keystroke.key.as_str(); let modifiers = &keystroke.modifiers; + let mut custom_shortcuts: Option< + std::sync::Arc, + > = None; + if let Some(state) = cx.try_global::() { + let cloned = std::sync::Arc::new(state.settings().shortcuts.clone()); + custom_shortcuts = Some(cloned); + } + // Dismiss interactive rebase dialog on Escape if key == "escape" && self.overlays.interactive_rebase.read(cx).is_visible() { self.overlays.interactive_rebase.update(cx, |ir, cx| { @@ -165,6 +173,84 @@ impl Workspace { return; } + if !any_overlay_active { + if let Some(shortcuts) = custom_shortcuts { + for (command_id_str, cmd_id) in [ + ("fetch", CommandId::Fetch), + ("pull", CommandId::Pull), + ("push", CommandId::Push), + ("push_all", CommandId::PushAll), + ("pull_all", CommandId::PullAll), + ("force_push", CommandId::ForcePush), + ("commit", CommandId::Commit), + ("stage_all", CommandId::StageAll), + ("unstage_all", CommandId::UnstageAll), + ("stash_save", CommandId::StashSave), + ("stash_pop", CommandId::StashPop), + ("stash_apply", CommandId::StashApply), + ("stash_drop", CommandId::StashDrop), + ("create_branch", CommandId::CreateBranch), + ("delete_branch", CommandId::DeleteBranch), + ("rename_branch", CommandId::RenameBranch), + ("merge_branch", CommandId::MergeBranch), + ("create_tag", CommandId::CreateTag), + ("create_worktree", CommandId::CreateWorktree), + ("create_pr", CommandId::CreatePr), + ("cherry_pick", CommandId::CherryPick), + ("revert_commit", CommandId::RevertCommit), + ("interactive_rebase", CommandId::InteractiveRebase), + ("discard_all", CommandId::DiscardAll), + ("clean_untracked", CommandId::CleanUntracked), + ("reset_hard", CommandId::ResetHard), + ("abort_operation", CommandId::AbortOperation), + ("continue_merge", CommandId::ContinueMerge), + ("toggle_diff_mode", CommandId::ToggleDiffMode), + ("search", CommandId::Search), + ("ai_message", CommandId::AiMessage), + ("refresh", CommandId::Refresh), + ("settings", CommandId::Settings), + ("open_repo", CommandId::OpenRepo), + ("workspace_home", CommandId::WorkspaceHome), + ("restore_last_workspace", CommandId::RestoreLastWorkspace), + ("shortcuts", CommandId::Shortcuts), + ("switch_branch", CommandId::SwitchBranch), + ("blame", CommandId::Blame), + ("undo", CommandId::Undo), + ("file_history", CommandId::FileHistory), + ("reflog", CommandId::Reflog), + ("submodules", CommandId::Submodules), + ("bisect", CommandId::Bisect), + ("bisect_start", CommandId::BisectStart), + ("bisect_good", CommandId::BisectGood), + ("bisect_bad", CommandId::BisectBad), + ("bisect_reset", CommandId::BisectReset), + ("bisect_skip", CommandId::BisectSkip), + ("global_search", CommandId::GlobalSearch), + ("toggle_issues", CommandId::ToggleIssues), + ("toggle_pull_requests", CommandId::TogglePullRequests), + ("toggle_branch_health", CommandId::ToggleBranchHealth), + ("toggle_stashes", CommandId::ToggleStashes), + ("stash_branch", CommandId::StashBranch), + ("open_theme_editor", CommandId::OpenThemeEditor), + ] { + if let Some(parsed) = shortcuts.parsed_shortcut_for(command_id_str) { + let ctrl_match = parsed.control == modifiers.control; + let shift_match = parsed.shift == modifiers.shift; + let alt_match = parsed.alt == modifiers.alt; + let plat_match = parsed.platform == modifiers.platform; + let key_match = parsed.key.eq_ignore_ascii_case(key); + + if ctrl_match && shift_match && alt_match && plat_match && key_match { + // Custom overrides do not intercept specific modal view toggles yet, + // they just directly execute_command. + self.execute_command(cmd_id, cx); + return; + } + } + } + } + } + // Ctrl+Shift+R to fetch if !any_overlay_active && (modifiers.control || modifiers.platform)