From 21caa11dc6fd6fafe357f71efde604405cd724d1 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:12:31 +0100 Subject: [PATCH 01/16] feat(keymap): generate commands and gpui actions from one macro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CommandId` was a hand-written 56-variant enum with a parallel `as_str` match, a reverse parser, and default keystrokes scattered through `key_handler.rs` — four places to keep in sync, with no compiler help. Introduce `commands!`, a `macro_rules!` macro that takes one declaration per command and emits all of it: the `CommandId` variant, a gpui `Action` unit struct in the declared namespace, the canonical `namespace::Name` keymap name, the default keystroke(s), the key context, the palette availability predicate, palette visibility, and the doc comment reused as a description. `ALL_COMMANDS` exposes the whole set so binding, validation, docs and the shortcuts UI can be driven from it. `as_str()` is derived from the variant name by a const PascalCase to snake_case conversion, and a test pins all 56 historical strings so the values persisted in settings cannot drift. Adds `CommandPalette`, `NextTab`, `PrevTab` and `CloseTab`, which were inline `key_handler.rs` branches with no `CommandId`, so they can be rebound like everything else. Co-Authored-By: Claude Fable 5 --- .../rgitui_workspace/src/command_palette.rs | 1103 +++++++---------- crates/rgitui_workspace/src/keymap/macros.rs | 270 ++++ crates/rgitui_workspace/src/keymap/mod.rs | 12 + .../rgitui_workspace/src/keymap/registry.rs | 443 +++++++ crates/rgitui_workspace/src/lib.rs | 4 + .../src/workspace/commands.rs | 31 +- 6 files changed, 1227 insertions(+), 636 deletions(-) create mode 100644 crates/rgitui_workspace/src/keymap/macros.rs create mode 100644 crates/rgitui_workspace/src/keymap/mod.rs create mode 100644 crates/rgitui_workspace/src/keymap/registry.rs diff --git a/crates/rgitui_workspace/src/command_palette.rs b/crates/rgitui_workspace/src/command_palette.rs index 6815a58..69dc173 100644 --- a/crates/rgitui_workspace/src/command_palette.rs +++ b/crates/rgitui_workspace/src/command_palette.rs @@ -10,6 +10,8 @@ use gpui::{ use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInputEvent, Tooltip}; +use crate::CommandId; + /// Pre-computed git context used for context-sensitive command filtering. /// Computed from GitProject state and passed to predicates. #[derive(Debug, Clone, Copy, Default)] @@ -83,172 +85,53 @@ impl CommandContext { } /// A no-op predicate that always shows the command. -const fn always_show(_: CommandContext) -> bool { +pub(crate) const fn always_show(_: CommandContext) -> bool { true } /// Show only when the user has a GitHub token configured (for PR creation). -const fn has_github_token(ctx: CommandContext) -> bool { +pub(crate) const fn has_github_token(ctx: CommandContext) -> bool { ctx.has_github_token } /// Show only when the repository has at least one remote configured. -const fn has_remotes(ctx: CommandContext) -> bool { +pub(crate) const fn has_remotes(ctx: CommandContext) -> bool { ctx.has_remotes } /// Show only when there are unstaged and/or staged file changes. -const fn has_changes(ctx: CommandContext) -> bool { +pub(crate) const fn has_changes(ctx: CommandContext) -> bool { ctx.has_changes } /// Show only when the repository worktree is clean (no uncommitted changes). -const fn worktree_clean(ctx: CommandContext) -> bool { +pub(crate) const fn worktree_clean(ctx: CommandContext) -> bool { ctx.worktree_clean } /// Show only when the repository is currently bisecting. -const fn is_bisecting(ctx: CommandContext) -> bool { +pub(crate) const fn is_bisecting(ctx: CommandContext) -> bool { ctx.is_bisecting } /// Show only when there is at least one stash entry. -const fn has_stashes(ctx: CommandContext) -> bool { +pub(crate) const fn has_stashes(ctx: CommandContext) -> bool { ctx.has_stashes } /// Show only when there are staged files to commit. -const fn has_staged(ctx: CommandContext) -> bool { +pub(crate) const fn has_staged(ctx: CommandContext) -> bool { ctx.has_staged } /// Show only when in a merge, rebase, cherry-pick, or revert in-progress state. -const fn in_progress_operation(ctx: CommandContext) -> bool { +pub(crate) const fn in_progress_operation(ctx: CommandContext) -> bool { ctx.in_progress_operation } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum CommandId { - Fetch, - Pull, - Push, - PushAll, - PullAll, - ForcePush, - Commit, - StageAll, - UnstageAll, - StashSave, - StashPop, - StashApply, - StashDrop, - CreateBranch, - DeleteBranch, - RenameBranch, - MergeBranch, - CreateTag, - CreateWorktree, - CreatePr, - CherryPick, - RevertCommit, - InteractiveRebase, - DiscardAll, - CleanUntracked, - ResetHard, - AbortOperation, - ContinueMerge, - ToggleDiffMode, - Search, - AiMessage, - Refresh, - Settings, - OpenRepo, - WorkspaceHome, - RestoreLastWorkspace, - Shortcuts, - SwitchBranch, - Blame, - Undo, - FileHistory, - Reflog, - Submodules, - Bisect, - BisectStart, - BisectGood, - BisectBad, - BisectReset, - BisectSkip, - GlobalSearch, - ToggleIssues, - TogglePullRequests, - ToggleBranchHealth, - ToggleStashes, - StashBranch, - OpenThemeEditor, -} - +/// The command identifier, its gpui action and its default keybinding are +/// generated by the `commands!` macro in `keymap::registry`. impl CommandId { - pub fn as_str(self) -> &'static str { - match self { - Self::Fetch => "fetch", - Self::Pull => "pull", - Self::Push => "push", - Self::PushAll => "push_all", - Self::PullAll => "pull_all", - Self::ForcePush => "force_push", - Self::Commit => "commit", - Self::StageAll => "stage_all", - Self::UnstageAll => "unstage_all", - Self::StashSave => "stash_save", - Self::StashPop => "stash_pop", - Self::StashApply => "stash_apply", - Self::StashDrop => "stash_drop", - Self::CreateBranch => "create_branch", - Self::DeleteBranch => "delete_branch", - Self::RenameBranch => "rename_branch", - Self::MergeBranch => "merge_branch", - Self::CreateTag => "create_tag", - Self::CreateWorktree => "create_worktree", - Self::CreatePr => "create_pr", - Self::CherryPick => "cherry_pick", - Self::RevertCommit => "revert_commit", - Self::InteractiveRebase => "interactive_rebase", - Self::DiscardAll => "discard_all", - Self::CleanUntracked => "clean_untracked", - Self::ResetHard => "reset_hard", - Self::AbortOperation => "abort_operation", - Self::ContinueMerge => "continue_merge", - Self::ToggleDiffMode => "toggle_diff_mode", - Self::Search => "search", - Self::AiMessage => "ai_message", - Self::Refresh => "refresh", - Self::Settings => "settings", - Self::OpenRepo => "open_repo", - Self::WorkspaceHome => "workspace_home", - Self::RestoreLastWorkspace => "restore_last_workspace", - Self::Shortcuts => "shortcuts", - Self::SwitchBranch => "switch_branch", - Self::Blame => "blame", - Self::Undo => "undo", - Self::FileHistory => "file_history", - Self::Reflog => "reflog", - Self::Submodules => "submodules", - Self::Bisect => "bisect", - Self::BisectStart => "bisect_start", - Self::BisectGood => "bisect_good", - Self::BisectBad => "bisect_bad", - Self::BisectReset => "bisect_reset", - Self::BisectSkip => "bisect_skip", - Self::GlobalSearch => "global_search", - Self::ToggleIssues => "toggle_issues", - Self::TogglePullRequests => "toggle_pull_requests", - Self::ToggleBranchHealth => "toggle_branch_health", - Self::ToggleStashes => "toggle_stashes", - Self::StashBranch => "stash_branch", - Self::OpenThemeEditor => "open_theme_editor", - } - } - pub fn display_label(self) -> &'static str { match self { Self::Fetch => "fetch", @@ -307,78 +190,10 @@ impl CommandId { Self::ToggleStashes => "toggle stashes panel", Self::StashBranch => "create branch from stash", Self::OpenThemeEditor => "edit theme", - } - } -} - -impl std::fmt::Display for CommandId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -impl TryFrom<&str> for CommandId { - type Error = (); - - fn try_from(s: &str) -> Result { - match s { - "fetch" => Ok(Self::Fetch), - "pull" => Ok(Self::Pull), - "push" => Ok(Self::Push), - "push_all" => Ok(Self::PushAll), - "pull_all" => Ok(Self::PullAll), - "force_push" => Ok(Self::ForcePush), - "commit" => Ok(Self::Commit), - "stage_all" => Ok(Self::StageAll), - "unstage_all" => Ok(Self::UnstageAll), - "stash_save" => Ok(Self::StashSave), - "stash_pop" => Ok(Self::StashPop), - "stash_apply" => Ok(Self::StashApply), - "stash_drop" => Ok(Self::StashDrop), - "create_branch" => Ok(Self::CreateBranch), - "delete_branch" => Ok(Self::DeleteBranch), - "rename_branch" => Ok(Self::RenameBranch), - "merge_branch" => Ok(Self::MergeBranch), - "create_tag" => Ok(Self::CreateTag), - "create_worktree" => Ok(Self::CreateWorktree), - "create_pr" => Ok(Self::CreatePr), - "cherry_pick" => Ok(Self::CherryPick), - "revert_commit" => Ok(Self::RevertCommit), - "interactive_rebase" => Ok(Self::InteractiveRebase), - "discard_all" => Ok(Self::DiscardAll), - "clean_untracked" => Ok(Self::CleanUntracked), - "reset_hard" => Ok(Self::ResetHard), - "abort_operation" => Ok(Self::AbortOperation), - "continue_merge" => Ok(Self::ContinueMerge), - "toggle_diff_mode" => Ok(Self::ToggleDiffMode), - "search" => Ok(Self::Search), - "ai_message" => Ok(Self::AiMessage), - "refresh" => Ok(Self::Refresh), - "settings" => Ok(Self::Settings), - "open_repo" => Ok(Self::OpenRepo), - "workspace_home" => Ok(Self::WorkspaceHome), - "restore_last_workspace" => Ok(Self::RestoreLastWorkspace), - "shortcuts" => Ok(Self::Shortcuts), - "switch_branch" => Ok(Self::SwitchBranch), - "blame" => Ok(Self::Blame), - "undo" => Ok(Self::Undo), - "file_history" => Ok(Self::FileHistory), - "reflog" => Ok(Self::Reflog), - "submodules" => Ok(Self::Submodules), - "bisect" => Ok(Self::Bisect), - "bisect_start" => Ok(Self::BisectStart), - "bisect_good" => Ok(Self::BisectGood), - "bisect_bad" => Ok(Self::BisectBad), - "bisect_reset" => Ok(Self::BisectReset), - "bisect_skip" => Ok(Self::BisectSkip), - "global_search" => Ok(Self::GlobalSearch), - "toggle_issues" => Ok(Self::ToggleIssues), - "toggle_pull_requests" => Ok(Self::TogglePullRequests), - "toggle_branch_health" => Ok(Self::ToggleBranchHealth), - "toggle_stashes" => Ok(Self::ToggleStashes), - "stash_branch" => Ok(Self::StashBranch), - "open_theme_editor" => Ok(Self::OpenThemeEditor), - _ => Err(()), + Self::CommandPalette => "command palette", + Self::NextTab => "next tab", + Self::PrevTab => "previous tab", + Self::CloseTab => "close tab", } } } @@ -395,6 +210,13 @@ pub struct PaletteCommand { } impl PaletteCommand { + /// The availability predicate. Must agree with the `commands!` registry; + /// `palette_predicates_match_the_registry` enforces that. + #[cfg(test)] + pub(crate) fn predicate(&self) -> fn(CommandContext) -> bool { + self.predicate + } + fn new( id: CommandId, label: &'static str, @@ -439,441 +261,452 @@ pub struct CommandPalette { impl EventEmitter for CommandPalette {} +/// Every command offered in the command palette, with its label, category +/// and availability predicate. +/// +/// Pure so the registry tests can check it against the `commands!` table +/// without a window: `hidden_commands_are_the_ones_absent_from_the_palette` +/// pins membership and `palette_predicates_match_the_registry` pins the +/// predicates. +pub(crate) fn palette_commands() -> Vec { + // Context-sensitive predicates: + // has_remotes — only when remotes are configured + // has_staged — only when files are staged + // has_changes — only when worktree has unstaged/staged changes + // has_stashes — only when stash entries exist + // worktree_clean — only when no uncommitted changes + // is_bisecting — only when a bisect is in progress + // in_progress_operation — only during merge/rebase/cherry-pick + // always_show — no context restriction + vec![ + PaletteCommand::new( + CommandId::Fetch, + "Git: Fetch", + Some("Download objects and refs from another repository"), + Some("Ctrl+Shift+F"), + "Git", + ) + .with_predicate(has_remotes), + PaletteCommand::new( + CommandId::Pull, + "Git: Pull", + Some("Fetch from and integrate with another repository or a local branch"), + None, + "Git", + ) + .with_predicate(has_remotes), + PaletteCommand::new( + CommandId::Push, + "Git: Push", + Some("Update remote refs along with associated objects"), + None, + "Git", + ) + .with_predicate(has_remotes), + PaletteCommand::new( + CommandId::PushAll, + "Git: Push All", + Some("Push all branches to their configured remotes"), + None, + "Git", + ) + .with_predicate(has_remotes), + PaletteCommand::new( + CommandId::PullAll, + "Git: Pull All", + Some("Pull updates for all configured branches"), + None, + "Git", + ) + .with_predicate(has_remotes), + PaletteCommand::new( + CommandId::ForcePush, + "Git: Force Push", + Some("Force update remote refs (can overwrite history)"), + None, + "Git", + ) + .with_predicate(has_remotes), + PaletteCommand::new( + CommandId::Commit, + "Git: Commit", + Some("Record changes to the repository"), + Some("Ctrl+Enter"), + "Git", + ) + .with_predicate(has_staged), + PaletteCommand::new( + CommandId::StageAll, + "Git: Stage All", + Some("Add all changes to the staging area"), + Some("Ctrl+S"), + "Git", + ) + .with_predicate(has_changes), + PaletteCommand::new( + CommandId::UnstageAll, + "Git: Unstage All", + Some("Remove all changes from the staging area"), + Some("Ctrl+U"), + "Git", + ) + .with_predicate(has_changes), + PaletteCommand::new( + CommandId::StashSave, + "Git: Stash", + Some("Save your local modifications to a new stash"), + Some("Ctrl+Z"), + "Git", + ) + .with_predicate(has_changes), + PaletteCommand::new( + CommandId::StashPop, + "Git: Pop Stash", + Some("Apply the latest stash and remove it"), + Some("Ctrl+Shift+Z"), + "Git", + ) + .with_predicate(has_stashes), + PaletteCommand::new( + CommandId::StashApply, + "Git: Apply Stash (keep)", + Some("Apply the latest stash but keep it in the list"), + None, + "Git", + ) + .with_predicate(has_stashes), + PaletteCommand::new( + CommandId::StashDrop, + "Git: Drop Stash", + Some("Delete the latest stash entry"), + None, + "Git", + ) + .with_predicate(has_stashes), + PaletteCommand::new( + CommandId::CreateBranch, + "Git: Create Branch", + Some("Create a new branch"), + Some("Ctrl+B"), + "Git", + ), + PaletteCommand::new( + CommandId::SwitchBranch, + "Git: Switch Branch", + Some("Switch to an existing branch"), + Some("Ctrl+Shift+B"), + "Git", + ), + PaletteCommand::new( + CommandId::DeleteBranch, + "Git: Delete Branch", + Some("Delete a local branch"), + None, + "Git", + ), + PaletteCommand::new( + CommandId::RenameBranch, + "Git: Rename Branch", + Some("Rename the current or selected branch"), + None, + "Git", + ), + PaletteCommand::new( + CommandId::MergeBranch, + "Git: Merge Branch", + Some("Join two or more development histories together"), + None, + "Git", + ) + .with_predicate(worktree_clean), + PaletteCommand::new( + CommandId::CreateTag, + "Git: Create Tag", + Some("Create a tag at the selected commit"), + None, + "Git", + ), + PaletteCommand::new( + CommandId::CreateWorktree, + "Git: Create Worktree", + Some("Check out a branch into a separate working tree"), + None, + "Git", + ), + PaletteCommand::new( + CommandId::CreatePr, + "Git: Create Pull Request", + Some("Open a pull request for the current branch"), + None, + "Git", + ) + .with_predicate(has_github_token), + PaletteCommand::new( + CommandId::CherryPick, + "Git: Cherry-pick Commit", + Some("Apply the changes introduced by some existing commits"), + None, + "Git", + ) + .with_predicate(worktree_clean), + PaletteCommand::new( + CommandId::RevertCommit, + "Git: Revert Commit", + Some("Revert an existing commit"), + None, + "Git", + ) + .with_predicate(worktree_clean), + PaletteCommand::new( + CommandId::InteractiveRebase, + "Git: Interactive Rebase", + Some("Reapply commits on top of another base tip interactively"), + None, + "Git", + ) + .with_predicate(worktree_clean), + PaletteCommand::new( + CommandId::DiscardAll, + "Git: Discard All Changes", + Some("Discard every unstaged and staged working tree change"), + None, + "Git", + ) + .with_predicate(has_changes), + PaletteCommand::new( + CommandId::CleanUntracked, + "Git: Clean Untracked Files", + Some("Remove untracked files from the working tree"), + None, + "Git", + ), + PaletteCommand::new( + CommandId::ResetHard, + "Git: Reset Hard (to HEAD)", + Some("Discard all local changes, staged and unstaged"), + None, + "Git", + ) + .with_predicate(has_changes), + PaletteCommand::new( + CommandId::AbortOperation, + "Git: Abort Merge/Rebase", + Some("Abort the in-progress merge, rebase, cherry-pick, or revert"), + None, + "Git", + ) + .with_predicate(in_progress_operation), + PaletteCommand::new( + CommandId::ContinueMerge, + "Git: Continue Merge", + Some("Continue the in-progress merge, rebase, cherry-pick, or revert"), + None, + "Git", + ) + .with_predicate(in_progress_operation), + PaletteCommand::new( + CommandId::ToggleDiffMode, + "View: Toggle Diff Mode", + Some("Switch between inline and side-by-side diff"), + Some("d"), + "View", + ), + PaletteCommand::new( + CommandId::Search, + "View: Search Commits", + Some("Filter the commit graph by message, author, or hash"), + Some("Ctrl+F"), + "View", + ), + PaletteCommand::new( + CommandId::AiMessage, + "AI: Generate Commit Message", + Some("Use AI to generate a commit message based on staged changes"), + Some("Ctrl+G"), + "AI", + ) + .with_predicate(has_staged), + PaletteCommand::new( + CommandId::Refresh, + "Git: Refresh", + Some("Reload repository status, branches, and graph data"), + Some("F5"), + "Git", + ), + PaletteCommand::new( + CommandId::Settings, + "Preferences: Open Settings", + Some("Open the application settings window"), + Some("Ctrl+,"), + "Preferences", + ), + PaletteCommand::new( + CommandId::OpenRepo, + "File: Open Repository", + Some("Choose a repository to open"), + Some("Ctrl+O"), + "File", + ), + PaletteCommand::new( + CommandId::WorkspaceHome, + "Workspace: Home", + Some("Return to the workspace home screen"), + None, + "Workspace", + ), + PaletteCommand::new( + CommandId::RestoreLastWorkspace, + "Workspace: Restore Last", + Some("Reopen the last active workspace"), + None, + "Workspace", + ), + PaletteCommand::new( + CommandId::Shortcuts, + "Help: Keyboard Shortcuts", + Some("Show available keyboard shortcuts"), + Some("?"), + "Help", + ), + PaletteCommand::new( + CommandId::Blame, + "View: Blame File", + Some("Show what revision and author last modified each line"), + Some("b"), + "View", + ), + PaletteCommand::new( + CommandId::FileHistory, + "View: File History", + Some("Show commit history for the selected file"), + Some("h"), + "View", + ), + PaletteCommand::new( + CommandId::Undo, + "Edit: Undo Last Operation", + Some("Undo the most recent git operation when possible"), + None, + "Edit", + ), + PaletteCommand::new( + CommandId::BisectStart, + "Git: Bisect Start", + Some("Start a binary search for the commit that introduced a bug"), + None, + "Git", + ) + .with_predicate(worktree_clean), + PaletteCommand::new( + CommandId::BisectGood, + "Git: Bisect Good (mark current)", + Some("Mark the current revision as good"), + None, + "Git", + ) + .with_predicate(is_bisecting), + PaletteCommand::new( + CommandId::BisectBad, + "Git: Bisect Bad (mark current)", + Some("Mark the current revision as bad"), + None, + "Git", + ) + .with_predicate(is_bisecting), + PaletteCommand::new( + CommandId::BisectReset, + "Git: Bisect Reset", + Some("Stop bisecting and return to the original branch"), + None, + "Git", + ) + .with_predicate(is_bisecting), + PaletteCommand::new( + CommandId::BisectSkip, + "Git: Bisect Skip (skip this commit)", + Some("Skip the current revision during bisect"), + None, + "Git", + ) + .with_predicate(is_bisecting), + PaletteCommand::new( + CommandId::Reflog, + "View: Reflog", + Some("Show recent branch and HEAD movements"), + None, + "View", + ), + PaletteCommand::new( + CommandId::Submodules, + "View: Submodules", + Some("Inspect configured git submodules"), + None, + "View", + ), + PaletteCommand::new( + CommandId::Bisect, + "View: Bisect Log", + Some("Show current bisect progress and selected revisions"), + None, + "View", + ) + .with_predicate(is_bisecting), + PaletteCommand::new( + CommandId::GlobalSearch, + "Search: Global Search", + Some("Search for text across the entire repository"), + Some("Ctrl+Shift+F"), + "Search", + ), + PaletteCommand::new( + CommandId::ToggleIssues, + "View: Issues Panel", + Some("Open the GitHub issues panel"), + Some("Alt+5"), + "View", + ), + PaletteCommand::new( + CommandId::TogglePullRequests, + "View: Pull Requests Panel", + Some("Open the GitHub pull requests panel"), + Some("Alt+6"), + "View", + ), + PaletteCommand::new( + CommandId::ToggleBranchHealth, + "View: Branch Health Panel", + Some("Open branch recency and merge-status checks"), + Some("Alt+7"), + "View", + ), + PaletteCommand::new( + CommandId::ToggleStashes, + "View: Stashes Panel", + Some("Open the stash management panel"), + Some("Alt+8"), + "View", + ), + PaletteCommand::new( + CommandId::StashBranch, + "Git: Create Branch from Stash", + Some("Create a new branch starting from a stash entry"), + None, + "Git", + ) + .with_predicate(has_stashes), + PaletteCommand::new( + CommandId::OpenThemeEditor, + "View: Edit Theme", + Some("Customize and save the active theme"), + Some("Ctrl+Shift+T"), + "View", + ), + ] +} + impl CommandPalette { pub fn new(cx: &mut Context) -> Self { - // Context-sensitive predicates: - // has_remotes — only when remotes are configured - // has_staged — only when files are staged - // has_changes — only when worktree has unstaged/staged changes - // has_stashes — only when stash entries exist - // worktree_clean — only when no uncommitted changes - // is_bisecting — only when a bisect is in progress - // in_progress_operation — only during merge/rebase/cherry-pick - // always_show — no context restriction - let commands: Vec = vec![ - PaletteCommand::new( - CommandId::Fetch, - "Git: Fetch", - Some("Download objects and refs from another repository"), - Some("Ctrl+Shift+R"), - "Git", - ) - .with_predicate(has_remotes), - PaletteCommand::new( - CommandId::Pull, - "Git: Pull", - Some("Fetch from and integrate with another repository or a local branch"), - None, - "Git", - ) - .with_predicate(has_remotes), - PaletteCommand::new( - CommandId::Push, - "Git: Push", - Some("Update remote refs along with associated objects"), - None, - "Git", - ) - .with_predicate(has_remotes), - PaletteCommand::new( - CommandId::PushAll, - "Git: Push All", - Some("Push all branches to their configured remotes"), - None, - "Git", - ) - .with_predicate(has_remotes), - PaletteCommand::new( - CommandId::PullAll, - "Git: Pull All", - Some("Pull updates for all configured branches"), - None, - "Git", - ) - .with_predicate(has_remotes), - PaletteCommand::new( - CommandId::ForcePush, - "Git: Force Push", - Some("Force update remote refs (can overwrite history)"), - None, - "Git", - ) - .with_predicate(has_remotes), - PaletteCommand::new( - CommandId::Commit, - "Git: Commit", - Some("Record changes to the repository"), - Some("Ctrl+Enter"), - "Git", - ) - .with_predicate(has_staged), - PaletteCommand::new( - CommandId::StageAll, - "Git: Stage All", - Some("Add all changes to the staging area"), - Some("Ctrl+S"), - "Git", - ) - .with_predicate(has_changes), - PaletteCommand::new( - CommandId::UnstageAll, - "Git: Unstage All", - Some("Remove all changes from the staging area"), - Some("Ctrl+U"), - "Git", - ) - .with_predicate(has_changes), - PaletteCommand::new( - CommandId::StashSave, - "Git: Stash", - Some("Save your local modifications to a new stash"), - Some("Ctrl+Z"), - "Git", - ) - .with_predicate(has_changes), - PaletteCommand::new( - CommandId::StashPop, - "Git: Pop Stash", - Some("Apply the latest stash and remove it"), - Some("Ctrl+Shift+Z"), - "Git", - ) - .with_predicate(has_stashes), - PaletteCommand::new( - CommandId::StashApply, - "Git: Apply Stash (keep)", - Some("Apply the latest stash but keep it in the list"), - None, - "Git", - ) - .with_predicate(has_stashes), - PaletteCommand::new( - CommandId::StashDrop, - "Git: Drop Stash", - Some("Delete the latest stash entry"), - None, - "Git", - ) - .with_predicate(has_stashes), - PaletteCommand::new( - CommandId::CreateBranch, - "Git: Create Branch", - Some("Create a new branch"), - Some("Ctrl+B"), - "Git", - ), - PaletteCommand::new( - CommandId::SwitchBranch, - "Git: Switch Branch", - Some("Switch to an existing branch"), - Some("Ctrl+Shift+B"), - "Git", - ), - PaletteCommand::new( - CommandId::DeleteBranch, - "Git: Delete Branch", - Some("Delete a local branch"), - None, - "Git", - ), - PaletteCommand::new( - CommandId::RenameBranch, - "Git: Rename Branch", - Some("Rename the current or selected branch"), - None, - "Git", - ), - PaletteCommand::new( - CommandId::MergeBranch, - "Git: Merge Branch", - Some("Join two or more development histories together"), - None, - "Git", - ) - .with_predicate(worktree_clean), - PaletteCommand::new( - CommandId::CreateTag, - "Git: Create Tag", - Some("Create a tag at the selected commit"), - None, - "Git", - ), - PaletteCommand::new( - CommandId::CreateWorktree, - "Git: Create Worktree", - Some("Check out a branch into a separate working tree"), - None, - "Git", - ), - PaletteCommand::new( - CommandId::CreatePr, - "Git: Create Pull Request", - Some("Open a pull request for the current branch"), - None, - "Git", - ) - .with_predicate(has_github_token), - PaletteCommand::new( - CommandId::CherryPick, - "Git: Cherry-pick Commit", - Some("Apply the changes introduced by some existing commits"), - None, - "Git", - ) - .with_predicate(worktree_clean), - PaletteCommand::new( - CommandId::RevertCommit, - "Git: Revert Commit", - Some("Revert an existing commit"), - None, - "Git", - ) - .with_predicate(worktree_clean), - PaletteCommand::new( - CommandId::InteractiveRebase, - "Git: Interactive Rebase", - Some("Reapply commits on top of another base tip interactively"), - None, - "Git", - ) - .with_predicate(worktree_clean), - PaletteCommand::new( - CommandId::DiscardAll, - "Git: Discard All Changes", - Some("Discard every unstaged and staged working tree change"), - None, - "Git", - ) - .with_predicate(has_changes), - PaletteCommand::new( - CommandId::CleanUntracked, - "Git: Clean Untracked Files", - Some("Remove untracked files from the working tree"), - None, - "Git", - ), - PaletteCommand::new( - CommandId::ResetHard, - "Git: Reset Hard (to HEAD)", - Some("Discard all local changes, staged and unstaged"), - None, - "Git", - ) - .with_predicate(has_changes), - PaletteCommand::new( - CommandId::AbortOperation, - "Git: Abort Merge/Rebase", - Some("Abort the in-progress merge, rebase, cherry-pick, or revert"), - None, - "Git", - ) - .with_predicate(in_progress_operation), - PaletteCommand::new( - CommandId::ContinueMerge, - "Git: Continue Merge", - Some("Continue the in-progress merge, rebase, cherry-pick, or revert"), - None, - "Git", - ) - .with_predicate(in_progress_operation), - PaletteCommand::new( - CommandId::ToggleDiffMode, - "View: Toggle Diff Mode", - Some("Switch between inline and side-by-side diff"), - Some("d"), - "View", - ), - PaletteCommand::new( - CommandId::Search, - "View: Search Commits", - Some("Filter the commit graph by message, author, or hash"), - Some("Ctrl+F"), - "View", - ), - PaletteCommand::new( - CommandId::AiMessage, - "AI: Generate Commit Message", - Some("Use AI to generate a commit message based on staged changes"), - Some("Ctrl+G"), - "AI", - ) - .with_predicate(has_staged), - PaletteCommand::new( - CommandId::Refresh, - "Git: Refresh", - Some("Reload repository status, branches, and graph data"), - Some("F5"), - "Git", - ), - PaletteCommand::new( - CommandId::Settings, - "Preferences: Open Settings", - Some("Open the application settings window"), - Some("Ctrl+,"), - "Preferences", - ), - PaletteCommand::new( - CommandId::OpenRepo, - "File: Open Repository", - Some("Choose a repository to open"), - Some("Ctrl+O"), - "File", - ), - PaletteCommand::new( - CommandId::WorkspaceHome, - "Workspace: Home", - Some("Return to the workspace home screen"), - None, - "Workspace", - ), - PaletteCommand::new( - CommandId::RestoreLastWorkspace, - "Workspace: Restore Last", - Some("Reopen the last active workspace"), - None, - "Workspace", - ), - PaletteCommand::new( - CommandId::Shortcuts, - "Help: Keyboard Shortcuts", - Some("Show available keyboard shortcuts"), - Some("?"), - "Help", - ), - PaletteCommand::new( - CommandId::Blame, - "View: Blame File", - Some("Show what revision and author last modified each line"), - Some("b"), - "View", - ), - PaletteCommand::new( - CommandId::FileHistory, - "View: File History", - Some("Show commit history for the selected file"), - Some("h"), - "View", - ), - PaletteCommand::new( - CommandId::Undo, - "Edit: Undo Last Operation", - Some("Undo the most recent git operation when possible"), - None, - "Edit", - ), - PaletteCommand::new( - CommandId::BisectStart, - "Git: Bisect Start", - Some("Start a binary search for the commit that introduced a bug"), - None, - "Git", - ) - .with_predicate(worktree_clean), - PaletteCommand::new( - CommandId::BisectGood, - "Git: Bisect Good (mark current)", - Some("Mark the current revision as good"), - None, - "Git", - ) - .with_predicate(is_bisecting), - PaletteCommand::new( - CommandId::BisectBad, - "Git: Bisect Bad (mark current)", - Some("Mark the current revision as bad"), - None, - "Git", - ) - .with_predicate(is_bisecting), - PaletteCommand::new( - CommandId::BisectReset, - "Git: Bisect Reset", - Some("Stop bisecting and return to the original branch"), - None, - "Git", - ) - .with_predicate(is_bisecting), - PaletteCommand::new( - CommandId::BisectSkip, - "Git: Bisect Skip (skip this commit)", - Some("Skip the current revision during bisect"), - None, - "Git", - ) - .with_predicate(is_bisecting), - PaletteCommand::new( - CommandId::Reflog, - "View: Reflog", - Some("Show recent branch and HEAD movements"), - None, - "View", - ), - PaletteCommand::new( - CommandId::Submodules, - "View: Submodules", - Some("Inspect configured git submodules"), - None, - "View", - ), - PaletteCommand::new( - CommandId::Bisect, - "View: Bisect Log", - Some("Show current bisect progress and selected revisions"), - None, - "View", - ) - .with_predicate(is_bisecting), - PaletteCommand::new( - CommandId::GlobalSearch, - "Search: Global Search", - Some("Search for text across the entire repository"), - Some("Ctrl+Shift+F"), - "Search", - ), - PaletteCommand::new( - CommandId::ToggleIssues, - "View: Issues Panel", - Some("Open the GitHub issues panel"), - Some("Alt+5"), - "View", - ), - PaletteCommand::new( - CommandId::TogglePullRequests, - "View: Pull Requests Panel", - Some("Open the GitHub pull requests panel"), - Some("Alt+6"), - "View", - ), - PaletteCommand::new( - CommandId::ToggleBranchHealth, - "View: Branch Health Panel", - Some("Open branch recency and merge-status checks"), - Some("Alt+7"), - "View", - ), - PaletteCommand::new( - CommandId::ToggleStashes, - "View: Stashes Panel", - Some("Open the stash management panel"), - Some("Alt+8"), - "View", - ), - PaletteCommand::new( - CommandId::StashBranch, - "Git: Create Branch from Stash", - Some("Create a new branch starting from a stash entry"), - None, - "Git", - ) - .with_predicate(has_stashes), - PaletteCommand::new( - CommandId::OpenThemeEditor, - "View: Edit Theme", - Some("Customize and save the active theme"), - Some("Ctrl+Shift+T"), - "View", - ), - ]; + let commands = palette_commands(); let filtered_indices: Vec<(usize, usize)> = (0..commands.len()).map(|i| (i, 0)).collect(); diff --git a/crates/rgitui_workspace/src/keymap/macros.rs b/crates/rgitui_workspace/src/keymap/macros.rs new file mode 100644 index 0000000..2cee6dd --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/macros.rs @@ -0,0 +1,270 @@ +//! The `commands!` macro — the single source of truth for rgitui commands. +//! +//! One declaration per command produces, all at once: +//! +//! * a [`CommandId`](crate::CommandId) variant, +//! * a `gpui` [`Action`](gpui::Action) unit struct in the declared namespace, +//! * the canonical keymap name (`namespace::PascalCase` — gpui panics if an +//! action name contains `::` itself, so the namespace is the only separator), +//! * the default keystroke(s), +//! the key context the binding is scoped to, +//! * the command-palette availability predicate, +//! * whether the command is offered in the command palette at all, +//! * and the doc comment, reused as the description in `docs/KEYBINDINGS.md` +//! and in the `keymap.json` JSON schema. +//! +//! Plus, for the whole set, [`ALL_COMMANDS`](crate::keymap::ALL_COMMANDS) — +//! which drives binding, validation, doc generation and the shortcuts UI. +//! +//! # Syntax +//! +//! ```ignore +//! commands! { +//! view Workspace in rgitui context "Workspace && !modal" { +//! /// Toggle the command palette. +//! CommandPalette "secondary-shift-p" in "Workspace" [hidden]; +//! /// Stage every change in the working tree. +//! StageAll "secondary-s" if has_changes; +//! /// Unstage everything. +//! UnstageAll ["secondary-shift-s", "secondary-u"] if has_changes; +//! /// Pull from the tracked remote. +//! Pull unbound if has_remotes; +//! } +//! } +//! ``` +//! +//! * `view in context ""` opens a block. `` +//! groups the commands in the generated docs; `context` is the default key +//! context for every binding in the block. +//! * The keystroke slot takes one string, a bracketed list of strings (several +//! default keystrokes for one command), or the bare word `unbound` for a +//! command that is reachable only from the palette. +//! * `in ""` overrides the block's key context for one command. +//! * `if ` names a `fn(CommandContext) -> bool` in scope; +//! the default is `always_show`. +//! * `[hidden]` keeps the command out of the command palette. + +/// Expands the keystroke slot of a `commands!` entry into a `&'static [&'static str]`. +macro_rules! command_keystrokes { + (unbound) => { + &[] as &[&'static str] + }; + ([$($keystroke:literal),* $(,)?]) => { + &[$($keystroke),*] as &[&'static str] + }; + ($keystroke:literal) => { + &[$keystroke] as &[&'static str] + }; +} + +/// Resolves a command's key context: the per-command override if present, +/// otherwise the enclosing `view` block's default. +macro_rules! command_context { + ($default:literal) => { + $default + }; + ($default:literal, $override:literal) => { + $override + }; +} + +/// Resolves a command's palette availability predicate. +macro_rules! command_predicate { + () => { + $crate::command_palette::always_show + }; + ($predicate:ident) => { + $predicate + }; +} + +/// Resolves whether a command is offered in the command palette. +/// +/// Only the literal marker `[hidden]` is accepted, so a typo is a compile error +/// rather than a silently ignored flag. +macro_rules! command_in_palette { + () => { + true + }; + ([hidden]) => { + false + }; +} + +/// Number of bytes a PascalCase identifier occupies once converted to snake_case. +pub(crate) const fn snake_case_len(name: &str) -> usize { + let bytes = name.as_bytes(); + let mut len = 0usize; + let mut i = 0usize; + while i < bytes.len() { + if bytes[i].is_ascii_uppercase() && i > 0 { + len += 1; + } + len += 1; + i += 1; + } + len +} + +/// Converts a PascalCase identifier to snake_case at compile time. +/// +/// `N` must equal [`snake_case_len`] of `name`; [`command_id_str`] computes it. +pub(crate) const fn snake_case_bytes(name: &str) -> [u8; N] { + let bytes = name.as_bytes(); + let mut out = [0u8; N]; + let mut i = 0usize; + let mut out_i = 0usize; + while i < bytes.len() { + let byte = bytes[i]; + if byte.is_ascii_uppercase() { + if i > 0 { + out[out_i] = b'_'; + out_i += 1; + } + out[out_i] = byte.to_ascii_lowercase(); + } else { + out[out_i] = byte; + } + out_i += 1; + i += 1; + } + out +} + +/// Compile-time snake_case rendering of a command variant name. +/// +/// These strings are the stable on-disk identifiers used by the command palette +/// and by settings, so they must never drift from the historical hand-written +/// values — `command_id_strings_are_stable` in `registry.rs` pins all of them. +macro_rules! command_id_str { + ($name:ident) => {{ + const SOURCE: &str = stringify!($name); + const LEN: usize = $crate::keymap::macros::snake_case_len(SOURCE); + const BYTES: &[u8; LEN] = &$crate::keymap::macros::snake_case_bytes::(SOURCE); + match ::core::str::from_utf8(BYTES) { + Ok(id) => id, + // Unreachable: the input is an ASCII Rust identifier. + Err(_) => panic!("command id is not valid UTF-8"), + } + }}; +} + +/// Declares every rgitui command. See the [module docs](self) for the syntax. +macro_rules! commands { + ( + $( + view $view:ident in $namespace:ident context $view_context:literal { + $( + $(#[doc = $doc:literal])* + $name:ident $keystrokes:tt + $(in $context:literal)? + $(if $predicate:ident)? + $([$hidden:ident])? + ; + )* + } + )* + ) => { + /// Every user-invokable rgitui command. + /// + /// Generated by [`commands!`]. [`CommandId::as_str`] is the stable + /// identifier used on disk; the gpui action name is + /// [`CommandId::action_name`]. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum CommandId { + $($( + $(#[doc = $doc])* + $name, + )*)* + } + + /// The gpui [`Action`](gpui::Action) structs bound by the keymap. + /// + /// One unit struct per command, registered with gpui under + /// `namespace::Name` so it can be named from `keymap.json`. + pub mod actions { + $($( + $(#[doc = $doc])* + #[derive(Clone, PartialEq, Default, Debug, gpui::Action)] + #[action(namespace = $namespace)] + pub struct $name; + )*)* + } + + impl CommandId { + /// Every command, in declaration order. + pub const ALL: &'static [CommandId] = &[ + $($( CommandId::$name, )*)* + ]; + + /// The stable snake_case identifier for this command. + /// + /// Persisted in settings and matched by the command palette, so + /// these strings are frozen; see `command_id_strings_are_stable`. + pub const fn as_str(self) -> &'static str { + match self { + $($( CommandId::$name => command_id_str!($name), )*)* + } + } + + /// The gpui action name used to refer to this command from `keymap.json`. + pub const fn action_name(self) -> &'static str { + match self { + $($( + CommandId::$name => + concat!(stringify!($namespace), "::", stringify!($name)), + )*)* + } + } + } + + /// Static metadata for every command, in declaration order. + /// + /// Drives default key binding, keymap validation, `docs/KEYBINDINGS.md`, + /// the `keymap.json` schema and the shortcuts UI. + pub const ALL_COMMANDS: &[CommandMeta] = &[ + $($( + CommandMeta { + id: CommandId::$name, + view: stringify!($view), + namespace: stringify!($namespace), + action_name: concat!(stringify!($namespace), "::", stringify!($name)), + default_keystrokes: command_keystrokes!($keystrokes), + context: command_context!($view_context $(, $context)?), + description: concat!("" $(, $doc)*), + in_palette: command_in_palette!($([$hidden])?), + availability: command_predicate!($($predicate)?), + }, + )*)* + ]; + + /// Registers one `on_action` handler per command belonging to `view`. + /// + /// Every handler forwards its [`CommandId`] to `dispatch`, so the view + /// keeps a single entry point for keyboard-invoked commands. + pub fn attach_actions( + element: E, + view: &str, + cx: &mut gpui::Context, + dispatch: fn(&mut V, CommandId, &mut gpui::Window, &mut gpui::Context), + ) -> E + where + E: gpui::InteractiveElement, + V: 'static, + { + let mut element = element; + $( + if view == stringify!($view) { + $( + element = element.on_action(cx.listener( + move |view, _: &actions::$name, window, cx| { + dispatch(view, CommandId::$name, window, cx); + }, + )); + )* + } + )* + element + } + }; +} diff --git a/crates/rgitui_workspace/src/keymap/mod.rs b/crates/rgitui_workspace/src/keymap/mod.rs new file mode 100644 index 0000000..59cd7fd --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/mod.rs @@ -0,0 +1,12 @@ +//! User-definable keybindings. +//! +//! * [`macros`] defines `commands!`, the single source of truth. +//! * [`registry`] declares every command with it, producing [`CommandId`], the +//! gpui action structs and [`ALL_COMMANDS`]. + +#[macro_use] +pub(crate) mod macros; + +pub mod registry; + +pub use registry::{actions, attach_actions, CommandId, CommandMeta, ALL_COMMANDS}; diff --git a/crates/rgitui_workspace/src/keymap/registry.rs b/crates/rgitui_workspace/src/keymap/registry.rs new file mode 100644 index 0000000..a097f60 --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/registry.rs @@ -0,0 +1,443 @@ +//! The command registry: one `commands!` declaration that defines every +//! rgitui command, its gpui action and its default key binding. +//! +//! # Key contexts +//! +//! `Workspace` is set on the workspace root element. The root adds `modal` +//! whenever an overlay or dialog is open, so `Workspace && !modal` is the +//! scope for shortcuts that must not fire while a modal is up — it replaces +//! the hand-rolled `any_overlay_active` gate for the commands listed here. +//! `TextInput` is set by [`rgitui_ui::TextInput`], so `!TextInput` keeps +//! unmodified single-key shortcuts from stealing typed characters. + +// The `if ` clause of `commands!` resolves these by name at the +// invocation site; `always_show` is the default and is referenced through +// `$crate`, so it is not imported here. +use crate::command_palette::{ + has_changes, has_github_token, has_remotes, has_staged, has_stashes, in_progress_operation, + is_bisecting, worktree_clean, CommandContext, +}; + +/// Static description of a single command. +/// +/// Generated by `commands!`; see [`ALL_COMMANDS`]. +pub struct CommandMeta { + /// The command this metadata describes. + pub id: CommandId, + /// The view whose `commands!` block declared it. Groups the generated docs. + pub view: &'static str, + /// The gpui action namespace. + pub namespace: &'static str, + /// The full gpui action name, as written in `keymap.json`. + pub action_name: &'static str, + /// Default keystrokes. Empty when the command has no default binding. + pub default_keystrokes: &'static [&'static str], + /// Key context predicate the default bindings are scoped to. + pub context: &'static str, + /// Doc comment from the declaration. Leading whitespace is not trimmed. + pub description: &'static str, + /// Whether the command is offered in the command palette. + pub in_palette: bool, + /// Command-palette availability predicate. + pub availability: fn(CommandContext) -> bool, +} + +impl CommandMeta { + /// The description with doc-comment padding removed. + pub fn description(&self) -> &'static str { + self.description.trim() + } +} + +commands! { + view Workspace in rgitui context "Workspace && !modal" { + /// Download objects and refs from the tracked remote. + Fetch "secondary-shift-r" if has_remotes; + /// Fetch from and integrate with the tracked remote branch. + Pull unbound if has_remotes; + /// Update the remote ref along with associated objects. + Push unbound if has_remotes; + /// Push every open repository. + PushAll unbound if has_remotes; + /// Pull every open repository. + PullAll unbound if has_remotes; + /// Overwrite the remote branch with the local one. + ForcePush unbound if has_remotes; + /// Commit the staged changes using the message in the commit panel. + Commit "secondary-enter" if has_staged; + /// Stage every change in the working tree. + StageAll "secondary-s" if has_changes; + /// Unstage everything currently staged. + UnstageAll ["secondary-shift-s", "secondary-u"] if has_changes; + /// Stash the working tree and index. + StashSave "secondary-z" if has_changes; + /// Apply the latest stash entry and drop it. + StashPop "secondary-shift-z" if has_stashes; + /// Apply the latest stash entry and keep it. + StashApply unbound if has_stashes; + /// Delete a stash entry. + StashDrop unbound if has_stashes; + /// Create a new branch. + CreateBranch "secondary-b"; + /// Delete a branch. + DeleteBranch unbound; + /// Rename a branch. + RenameBranch unbound; + /// Merge another branch into the current one. + MergeBranch unbound if worktree_clean; + /// Create a tag. + CreateTag unbound; + /// Create a linked worktree. + CreateWorktree unbound; + /// Open a pull request for the current branch. + CreatePr unbound if has_github_token; + /// Cherry-pick a commit onto the current branch. + CherryPick unbound if worktree_clean; + /// Revert a commit. + RevertCommit unbound if worktree_clean; + /// Start an interactive rebase. + InteractiveRebase unbound if worktree_clean; + /// Discard every uncommitted change. + DiscardAll unbound if has_changes; + /// Delete untracked files. + CleanUntracked unbound; + /// Reset the working tree and index to HEAD. + ResetHard unbound if has_changes; + /// Abort the merge, rebase, cherry-pick or revert in progress. + AbortOperation unbound if in_progress_operation; + /// Continue the merge, rebase, cherry-pick or revert in progress. + ContinueMerge unbound if in_progress_operation; + /// Switch the diff viewer between unified and side-by-side. + ToggleDiffMode unbound; + /// Search the commit graph. + Search unbound; + /// Generate a commit message with the configured AI provider. + AiMessage "secondary-g" if has_staged; + /// Reload the repository state from disk. + Refresh "f5"; + /// Open the settings window. + Settings "secondary-," in "Workspace"; + /// Open the repository picker. + OpenRepo "secondary-o" in "Workspace"; + /// Close every tab and return to the workspace home screen. + WorkspaceHome "secondary-h"; + /// Reopen the most recently saved workspace. + RestoreLastWorkspace unbound; + /// Show the keyboard shortcut reference. + Shortcuts "?" in "Workspace && !TextInput"; + /// Focus the sidebar to switch branches. + SwitchBranch "secondary-shift-b"; + /// Blame the selected file. + Blame unbound; + /// Undo the last git operation. + Undo unbound; + /// Show the commit history of the selected file. + FileHistory unbound; + /// Show the reflog. + Reflog unbound; + /// Show the submodule list. + Submodules unbound; + /// Show the bisect log. + Bisect unbound if is_bisecting; + /// Start a bisect session. + BisectStart unbound if worktree_clean; + /// Mark the current bisect commit as good. + BisectGood unbound if is_bisecting; + /// Mark the current bisect commit as bad. + BisectBad unbound if is_bisecting; + /// End the bisect session and restore HEAD. + BisectReset unbound if is_bisecting; + /// Skip the current bisect commit. + BisectSkip unbound if is_bisecting; + /// Search the contents of the working tree. + GlobalSearch unbound; + /// Toggle the issues panel. + ToggleIssues "alt-5"; + /// Toggle the pull requests panel. + TogglePullRequests "alt-6"; + /// Toggle the branch health panel. + ToggleBranchHealth "alt-7"; + /// Toggle the stashes panel. + ToggleStashes "alt-8"; + /// Create a branch from a stash entry. + StashBranch unbound if has_stashes; + /// Open the theme editor. + OpenThemeEditor ["secondary-shift-t", "alt-9"] in "Workspace"; + /// Toggle the command palette. + CommandPalette "secondary-shift-p" in "Workspace" [hidden]; + /// Activate the next repository tab. + NextTab "secondary-tab" [hidden]; + /// Activate the previous repository tab. + PrevTab "secondary-shift-tab" [hidden]; + /// Close the active repository tab. + CloseTab "secondary-w" [hidden]; + } +} + +impl CommandId { + /// Static metadata for this command. + pub fn meta(self) -> &'static CommandMeta { + ALL_COMMANDS + .iter() + .find(|meta| meta.id == self) + // Unreachable: `ALL_COMMANDS` and `CommandId` are generated together. + .expect("every CommandId has metadata") + } + + /// The key context the default bindings for this command are scoped to. + pub fn context(self) -> &'static str { + self.meta().context + } + + /// This command's default keystrokes, or an empty slice when it has none. + pub fn default_keystrokes(self) -> &'static [&'static str] { + self.meta().default_keystrokes + } + + /// A one-line description of what this command does. + pub fn description(self) -> &'static str { + self.meta().description() + } + + /// Whether this command is offered in the command palette. + pub fn in_palette(self) -> bool { + self.meta().in_palette + } + + /// The command-palette availability predicate for this command. + pub fn availability(self) -> fn(CommandContext) -> bool { + self.meta().availability + } +} + +impl std::fmt::Display for CommandId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl TryFrom<&str> for CommandId { + type Error = (); + + fn try_from(value: &str) -> Result { + CommandId::ALL + .iter() + .copied() + .find(|id| id.as_str() == value) + .ok_or(()) + } +} + +/// Looks up a command by its gpui action name (`namespace::Name`). +pub fn command_for_action(action_name: &str) -> Option { + ALL_COMMANDS + .iter() + .find(|meta| meta.action_name == action_name) + .map(|meta| meta.id) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + /// The historical `CommandId::as_str()` values. These are persisted and + /// matched by the command palette, so the generated snake_case conversion + /// must reproduce them byte for byte. + const LEGACY_IDS: &[(CommandId, &str)] = &[ + (CommandId::Fetch, "fetch"), + (CommandId::Pull, "pull"), + (CommandId::Push, "push"), + (CommandId::PushAll, "push_all"), + (CommandId::PullAll, "pull_all"), + (CommandId::ForcePush, "force_push"), + (CommandId::Commit, "commit"), + (CommandId::StageAll, "stage_all"), + (CommandId::UnstageAll, "unstage_all"), + (CommandId::StashSave, "stash_save"), + (CommandId::StashPop, "stash_pop"), + (CommandId::StashApply, "stash_apply"), + (CommandId::StashDrop, "stash_drop"), + (CommandId::CreateBranch, "create_branch"), + (CommandId::DeleteBranch, "delete_branch"), + (CommandId::RenameBranch, "rename_branch"), + (CommandId::MergeBranch, "merge_branch"), + (CommandId::CreateTag, "create_tag"), + (CommandId::CreateWorktree, "create_worktree"), + (CommandId::CreatePr, "create_pr"), + (CommandId::CherryPick, "cherry_pick"), + (CommandId::RevertCommit, "revert_commit"), + (CommandId::InteractiveRebase, "interactive_rebase"), + (CommandId::DiscardAll, "discard_all"), + (CommandId::CleanUntracked, "clean_untracked"), + (CommandId::ResetHard, "reset_hard"), + (CommandId::AbortOperation, "abort_operation"), + (CommandId::ContinueMerge, "continue_merge"), + (CommandId::ToggleDiffMode, "toggle_diff_mode"), + (CommandId::Search, "search"), + (CommandId::AiMessage, "ai_message"), + (CommandId::Refresh, "refresh"), + (CommandId::Settings, "settings"), + (CommandId::OpenRepo, "open_repo"), + (CommandId::WorkspaceHome, "workspace_home"), + (CommandId::RestoreLastWorkspace, "restore_last_workspace"), + (CommandId::Shortcuts, "shortcuts"), + (CommandId::SwitchBranch, "switch_branch"), + (CommandId::Blame, "blame"), + (CommandId::Undo, "undo"), + (CommandId::FileHistory, "file_history"), + (CommandId::Reflog, "reflog"), + (CommandId::Submodules, "submodules"), + (CommandId::Bisect, "bisect"), + (CommandId::BisectStart, "bisect_start"), + (CommandId::BisectGood, "bisect_good"), + (CommandId::BisectBad, "bisect_bad"), + (CommandId::BisectReset, "bisect_reset"), + (CommandId::BisectSkip, "bisect_skip"), + (CommandId::GlobalSearch, "global_search"), + (CommandId::ToggleIssues, "toggle_issues"), + (CommandId::TogglePullRequests, "toggle_pull_requests"), + (CommandId::ToggleBranchHealth, "toggle_branch_health"), + (CommandId::ToggleStashes, "toggle_stashes"), + (CommandId::StashBranch, "stash_branch"), + (CommandId::OpenThemeEditor, "open_theme_editor"), + ]; + + #[test] + fn command_id_strings_are_stable() { + for (id, expected) in LEGACY_IDS { + assert_eq!(id.as_str(), *expected, "as_str drifted for {id:?}"); + } + } + + #[test] + fn command_ids_round_trip_through_strings() { + for id in CommandId::ALL { + assert_eq!( + CommandId::try_from(id.as_str()), + Ok(*id), + "{id:?} did not round-trip" + ); + } + assert_eq!(CommandId::try_from("not_a_command"), Err(())); + } + + #[test] + fn command_ids_are_unique() { + let ids: HashSet<&str> = CommandId::ALL.iter().map(|id| id.as_str()).collect(); + assert_eq!(ids.len(), CommandId::ALL.len()); + } + + #[test] + fn registry_covers_every_command() { + assert_eq!(ALL_COMMANDS.len(), CommandId::ALL.len()); + for (meta, id) in ALL_COMMANDS.iter().zip(CommandId::ALL) { + assert_eq!(meta.id, *id); + } + } + + #[test] + fn action_names_are_namespaced_and_unique() { + let mut names = HashSet::new(); + for meta in ALL_COMMANDS { + assert!( + meta.action_name + .starts_with(&format!("{}::", meta.namespace)), + "{} is not namespaced", + meta.action_name + ); + let bare = meta + .action_name + .trim_start_matches(&format!("{}::", meta.namespace)); + assert!( + !bare.contains("::"), + "gpui panics on action names containing `::`: {}", + meta.action_name + ); + assert!( + names.insert(meta.action_name), + "duplicate {}", + meta.action_name + ); + assert_eq!(command_for_action(meta.action_name), Some(meta.id)); + } + } + + /// Guards the runtime path: `keymap.json` resolves actions by name through + /// `App::build_action`, which only knows the actions gpui collected from the + /// `inventory` registry at link time. If the generated structs were ever + /// dropped by the linker every binding would silently fail to load. + #[test] + fn every_action_is_registered_with_gpui() { + let registered: HashSet<&str> = gpui::generate_list_of_all_registered_actions() + .map(|action| action.name) + .collect(); + for meta in ALL_COMMANDS { + assert!( + registered.contains(meta.action_name), + "{} is not registered with gpui, so keymap.json could not name it", + meta.action_name + ); + } + } + + /// The doc comment on each command becomes the action's `documentation()`, + /// which is what a future keybinding editor would show. + #[test] + fn action_documentation_comes_from_the_doc_comment() { + let documented: Vec<(&str, Option<&str>)> = gpui::generate_list_of_all_registered_actions() + .map(|action| (action.name, action.documentation)) + .collect(); + for meta in ALL_COMMANDS { + let (_, documentation) = documented + .iter() + .find(|(name, _)| *name == meta.action_name) + .expect("every action is registered"); + assert_eq!( + *documentation, + Some(meta.description()), + "{} documentation drifted from its doc comment", + meta.action_name + ); + } + } + + #[test] + fn every_command_is_documented() { + for meta in ALL_COMMANDS { + assert!( + !meta.description().is_empty(), + "{} has no doc comment", + meta.action_name + ); + } + } + + #[test] + fn hidden_commands_are_the_ones_absent_from_the_palette() { + let palette: HashSet = crate::command_palette::palette_commands() + .iter() + .map(|command| command.id) + .collect(); + for meta in ALL_COMMANDS { + assert_eq!( + palette.contains(&meta.id), + meta.in_palette, + "{} palette membership disagrees with the `[hidden]` marker", + meta.action_name + ); + } + } + + #[test] + fn palette_predicates_match_the_registry() { + for command in crate::command_palette::palette_commands() { + let expected = command.id.availability(); + assert!( + std::ptr::fn_addr_eq(command.predicate(), expected), + "{} availability predicate disagrees with the registry", + command.id + ); + } + } +} diff --git a/crates/rgitui_workspace/src/lib.rs b/crates/rgitui_workspace/src/lib.rs index f657450..a754cec 100644 --- a/crates/rgitui_workspace/src/lib.rs +++ b/crates/rgitui_workspace/src/lib.rs @@ -17,6 +17,7 @@ mod github_data_service; mod github_device_flow; mod interactive_rebase; mod issues_panel; +pub mod keymap; mod markdown_view; mod prs_panel; mod reflog_view; @@ -55,6 +56,9 @@ pub use detail_panel::*; pub use file_history_view::*; pub use interactive_rebase::*; pub use issues_panel::*; +// `CommandId` and the command metadata are generated by `keymap`'s `commands!` +// macro; re-exported at the crate root where they used to live. +pub use keymap::{CommandId, CommandMeta, ALL_COMMANDS}; pub use markdown_view::*; pub use prs_panel::*; pub use reflog_view::*; diff --git a/crates/rgitui_workspace/src/workspace/commands.rs b/crates/rgitui_workspace/src/workspace/commands.rs index ab2fb76..2d8d15a 100644 --- a/crates/rgitui_workspace/src/workspace/commands.rs +++ b/crates/rgitui_workspace/src/workspace/commands.rs @@ -74,6 +74,31 @@ impl Workspace { te.show_for_active_theme(cx); }); } + CommandId::NextTab => { + if !self.tabs.is_empty() { + self.active_tab = (self.active_tab + 1) % self.tabs.len(); + cx.notify(); + } + } + CommandId::PrevTab => { + if !self.tabs.is_empty() { + self.active_tab = if self.active_tab == 0 { + self.tabs.len() - 1 + } else { + self.active_tab - 1 + }; + cx.notify(); + } + } + CommandId::CloseTab => { + if !self.tabs.is_empty() { + self.close_tab(self.active_tab, cx); + } + } + // Toggling the palette needs a `Window`, so it is handled in + // `dispatch_command`. It is `[hidden]`, so the palette never + // dispatches it to itself. + CommandId::CommandPalette => {} cmd => { let Some(tab) = self.tabs.get(self.active_tab).cloned() else { return; @@ -497,7 +522,11 @@ impl Workspace { | CommandId::WorkspaceHome | CommandId::RestoreLastWorkspace | CommandId::Undo - | CommandId::OpenThemeEditor => {} + | CommandId::OpenThemeEditor + | CommandId::CommandPalette + | CommandId::NextTab + | CommandId::PrevTab + | CommandId::CloseTab => {} } } From 8e91acfcd0c3c80e16e139db51eb40dd629772a1 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:16:23 +0100 Subject: [PATCH 02/16] feat(keymap): load keymap.json and report binding conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read a Zed-shaped `keymap.json` from rgitui's config directory: an array of `{ context, bindings }` sections, JSONC so it can carry comments and trailing commas (via `serde_json_lenient`, the crate Zed uses for the same job). `null` unbinds through gpui's `NoAction`, and an action may carry JSON input as `[name, input]`. Defaults are applied first and the user's file last, so gpui's later-wins ordering makes user bindings take precedence. Saving the file reloads the whole keymap: a `notify` watcher on the config directory debounces for 200ms, then clears and rebuilds every binding. Errors accumulate rather than aborting: an unparseable context predicate skips one section, an unknown action name or malformed keystroke skips one binding, and everything else still loads. Problems land in the `KeymapState` global, which the workspace observes so they surface as toasts — for reloads as well as startup. Conflict detection does not rely on gpui silently dropping the loser. It reports two classes: the same keystroke in overlapping contexts (equal predicates, an absent predicate, or one a superset of the other per `KeyBindingContextPredicate::is_superset`), and a binding shadowing the prefix of a longer chord. Overlaps resolve later-wins; a chord prefix always loses so the chord stays reachable. A user binding replacing a default is the feature, so it is exempt, as are explicit unbinds. The detection is a pure function over `(keystrokes, context, action)` triples, unit-tested without a display. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 2 + Cargo.toml | 3 + crates/rgitui/src/main.rs | 7 + crates/rgitui_settings/src/lib.rs | 18 +- crates/rgitui_workspace/Cargo.toml | 2 + .../rgitui_workspace/src/keymap/conflict.rs | 488 +++++++++++++ crates/rgitui_workspace/src/keymap/loader.rs | 640 ++++++++++++++++++ crates/rgitui_workspace/src/keymap/mod.rs | 143 ++++ .../src/settings_window/view.rs | 2 +- crates/rgitui_workspace/src/workspace/mod.rs | 22 + 10 files changed, 1325 insertions(+), 2 deletions(-) create mode 100644 crates/rgitui_workspace/src/keymap/conflict.rs create mode 100644 crates/rgitui_workspace/src/keymap/loader.rs diff --git a/Cargo.lock b/Cargo.lock index 39757cb..e9a1d9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5233,6 +5233,7 @@ dependencies = [ "http_client", "log", "md5", + "notify", "pulldown-cmark", "rfd", "rgitui_ai", @@ -5246,6 +5247,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_json_lenient", "smallvec", "smol", "urlencoding", diff --git a/Cargo.toml b/Cargo.toml index 66d0919..17b1829 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,9 @@ log = "0.4" env_logger = "0.11" serde = { version = "1", features = ["derive"] } serde_json = "1" +# Comment- and trailing-comma-tolerant JSON for hand-edited config (keymap.json). +# Same crate Zed uses for its keymap and settings files. +serde_json_lenient = "0.2" chrono = { version = "0.4", features = ["serde"] } parking_lot = "0.12" smallvec = { version = "1", features = ["union"] } diff --git a/crates/rgitui/src/main.rs b/crates/rgitui/src/main.rs index ed64ad4..8544f67 100644 --- a/crates/rgitui/src/main.rs +++ b/crates/rgitui/src/main.rs @@ -75,6 +75,9 @@ impl AppRoot { if let Some(workspace) = &self.workspace { workspace.update(cx, |ws, cx| { ws.show_crash_recovery_toast(cx); + // Startup keymap problems are reported here rather than during + // `keymap::init`, which runs before any window exists. + ws.show_keymap_problems(cx); }); } @@ -220,6 +223,10 @@ fn main() { // Initialize subsystems rgitui_theme::init(cx); rgitui_settings::init(cx); + // Applies the default keybindings plus the user's keymap.json, and + // watches that file so saving it reloads the keymap. Must come after + // settings init, which creates the config directory. + rgitui_workspace::keymap::init(cx); // Initialize empty avatar cache immediately, load disk data in background cx.set_global(rgitui_ui::AvatarCache::new()); diff --git a/crates/rgitui_settings/src/lib.rs b/crates/rgitui_settings/src/lib.rs index 601b75c..497a499 100644 --- a/crates/rgitui_settings/src/lib.rs +++ b/crates/rgitui_settings/src/lib.rs @@ -947,6 +947,22 @@ pub fn config_dir() -> PathBuf { .join("rgitui") } +/// File name of the settings file inside [`config_dir`]. +const SETTINGS_FILE_NAME: &str = "settings.json"; + +/// File name of the user keymap inside [`config_dir`]. +const KEYMAP_FILE_NAME: &str = "keymap.json"; + +/// Path of the settings file. +pub fn settings_path() -> PathBuf { + config_dir().join(SETTINGS_FILE_NAME) +} + +/// Path of the user keymap, alongside the settings file. +pub fn keymap_path() -> PathBuf { + config_dir().join(KEYMAP_FILE_NAME) +} + /// A single queued settings write. `json` is pre-serialized on the caller's /// thread so the snapshot reflects state at call time; `ack`, when present, /// is fired after the write attempt completes so a blocking caller can wait. @@ -1017,7 +1033,7 @@ fn write_settings_file(config_path: &Path, json: &str) { /// Initialize settings. Must be called during app init. pub fn init(cx: &mut App) { - let config_path = config_dir().join("settings.json"); + let config_path = settings_path(); let mut load_warnings = Vec::new(); let settings = if config_path.exists() { match std::fs::read_to_string(&config_path) { diff --git a/crates/rgitui_workspace/Cargo.toml b/crates/rgitui_workspace/Cargo.toml index 21a09a8..8d667ec 100644 --- a/crates/rgitui_workspace/Cargo.toml +++ b/crates/rgitui_workspace/Cargo.toml @@ -23,7 +23,9 @@ anyhow.workspace = true log.workspace = true serde.workspace = true serde_json.workspace = true +serde_json_lenient.workspace = true smallvec.workspace = true +notify.workspace = true dirs.workspace = true md5.workspace = true http_client.workspace = true diff --git a/crates/rgitui_workspace/src/keymap/conflict.rs b/crates/rgitui_workspace/src/keymap/conflict.rs new file mode 100644 index 0000000..d358615 --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/conflict.rs @@ -0,0 +1,488 @@ +//! Keybinding conflict detection. +//! +//! gpui resolves ambiguity silently: when two bindings match the same keystroke +//! in overlapping contexts the later one wins and the earlier one simply never +//! fires. That is fine for the *intended* case — a user binding replacing a +//! default — but it hides genuine mistakes, so rgitui detects and reports them +//! before handing anything to gpui. +//! +//! Everything here is pure: [`detect_conflicts`] takes a list of +//! `(keystrokes, context, action)` triples and returns a report, so it is +//! unit-testable without a window or an `App`. +//! +//! # Rules +//! +//! A pair of bindings conflicts when +//! +//! 1. **Overlap** — they resolve to the same keystroke sequence and their +//! contexts overlap. Contexts overlap when they are equal, when either is +//! absent (an absent context matches everywhere), or when one predicate is a +//! superset of the other per [`KeyBindingContextPredicate::is_superset`]. +//! 2. **Chord prefix** — one binding's keystroke sequence is a strict prefix of +//! the other's and their contexts overlap, e.g. `ctrl-k` alongside +//! `ctrl-k ctrl-o`. Typing the prefix would resolve immediately and the +//! chord could never be reached. +//! +//! It is *not* a conflict when +//! +//! * a [`BindingSource::User`] binding overlaps a [`BindingSource::Default`] +//! one — that is the whole point of a user keymap; or +//! * either binding is an unbind (`null` in `keymap.json`, gpui's `NoAction`), +//! which is a deliberate instruction to remove a binding. +//! +//! # Resolution +//! +//! For an overlap the later entry wins and the earlier one is dropped, matching +//! gpui's own precedence. For a chord prefix the *prefix* is dropped whichever +//! order it appears in, so the longer chord stays reachable. Either way the +//! losing entry is reported rather than silently applied. + +use std::fmt; + +use gpui::{KeyBindingContextPredicate, Keystroke}; + +/// Where a binding came from. Defaults are always ordered before user bindings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BindingSource { + /// Declared by `commands!` in the registry. + Default, + /// Read from the user's `keymap.json`. + User, +} + +impl fmt::Display for BindingSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BindingSource::Default => f.write_str("default"), + BindingSource::User => f.write_str("keymap.json"), + } + } +} + +/// The gpui action name that removes a binding. Bound to `null` in `keymap.json`. +pub const NO_ACTION: &str = "zed::NoAction"; + +/// One candidate key binding, before it is handed to gpui. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BindingSpec { + /// Whitespace-separated keystrokes, exactly as written. + pub keystrokes: String, + /// Context predicate source, or `None` for "matches everywhere". + pub context: Option, + /// Full gpui action name, or [`NO_ACTION`] for an unbind. + pub action: String, + /// Where this binding came from. + pub source: BindingSource, +} + +impl BindingSpec { + /// A binding declared by the registry. + pub fn default_binding(keystrokes: &str, context: &str, action: &str) -> Self { + Self { + keystrokes: keystrokes.to_owned(), + context: (!context.is_empty()).then(|| context.to_owned()), + action: action.to_owned(), + source: BindingSource::Default, + } + } + + /// A binding read from the user's `keymap.json`. + pub fn user_binding(keystrokes: &str, context: Option<&str>, action: &str) -> Self { + Self { + keystrokes: keystrokes.to_owned(), + context: context.filter(|c| !c.is_empty()).map(str::to_owned), + action: action.to_owned(), + source: BindingSource::User, + } + } + + fn is_unbind(&self) -> bool { + self.action == NO_ACTION + } + + fn context_label(&self) -> &str { + self.context.as_deref().unwrap_or("") + } +} + +/// Why two bindings conflict. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConflictKind { + /// Same keystroke sequence, overlapping contexts. + Overlap, + /// One keystroke sequence is a strict prefix of the other's chord. + ChordPrefix, +} + +/// A detected conflict between two candidate bindings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Conflict { + /// Why the two bindings conflict. + pub kind: ConflictKind, + /// Index of the binding that is kept. + pub winner: usize, + /// Index of the binding that is dropped. + pub ignored: usize, + /// Human-readable explanation, shown to the user. + pub message: String, +} + +/// The outcome of [`detect_conflicts`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ConflictReport { + /// Every conflict found, in the order the losing binding appears. + pub conflicts: Vec, + /// Indices of bindings that must not be applied. Sorted and deduplicated. + pub dropped: Vec, +} + +impl ConflictReport { + /// Whether the binding at `index` survived conflict resolution. + pub fn is_kept(&self, index: usize) -> bool { + !self.dropped.contains(&index) + } + + /// One message per conflict, ready to surface as a toast. + pub fn messages(&self) -> Vec { + self.conflicts + .iter() + .map(|conflict| conflict.message.clone()) + .collect() + } +} + +/// A keystroke reduced to the parts gpui matches on, so that different spellings +/// of the same keystroke (`ctrl-s` and `secondary-s` off macOS) compare equal. +#[derive(Debug, Clone, PartialEq, Eq)] +struct NormalizedKeystroke { + control: bool, + alt: bool, + shift: bool, + platform: bool, + function: bool, + key: String, +} + +impl From for NormalizedKeystroke { + fn from(keystroke: Keystroke) -> Self { + Self { + control: keystroke.modifiers.control, + alt: keystroke.modifiers.alt, + shift: keystroke.modifiers.shift, + platform: keystroke.modifiers.platform, + function: keystroke.modifiers.function, + key: keystroke.key, + } + } +} + +/// Parses a whitespace-separated keystroke sequence. +/// +/// Returns `None` when any keystroke is unparseable — such entries are reported +/// by the loader and excluded from conflict detection. +fn normalize_sequence(keystrokes: &str) -> Option> { + let sequence: Vec = keystrokes + .split_whitespace() + .map(|source| Keystroke::parse(source).ok().map(NormalizedKeystroke::from)) + .collect::>()?; + (!sequence.is_empty()).then_some(sequence) +} + +/// Whether two context predicates can both be satisfied by the same element. +/// +/// An absent predicate matches everywhere, so it overlaps everything. Two +/// present predicates overlap when either is a superset of the other. A +/// predicate that fails to parse is reported by the loader; here it is treated +/// as overlapping nothing so a single bad predicate cannot suppress unrelated +/// bindings. +fn contexts_overlap(left: Option<&str>, right: Option<&str>) -> bool { + match (left, right) { + (None, _) | (_, None) => true, + (Some(left), Some(right)) => { + if left == right { + return true; + } + let Ok(left) = KeyBindingContextPredicate::parse(left) else { + return false; + }; + let Ok(right) = KeyBindingContextPredicate::parse(right) else { + return false; + }; + left.is_superset(&right) || right.is_superset(&left) + } + } +} + +/// Whether `prefix` is a strict prefix of `sequence`. +fn is_strict_prefix(prefix: &[NormalizedKeystroke], sequence: &[NormalizedKeystroke]) -> bool { + prefix.len() < sequence.len() && sequence.starts_with(prefix) +} + +/// Finds every conflict in `bindings`, which must be ordered by precedence: +/// earlier entries lose to later ones. See the [module docs](self) for the rules. +pub fn detect_conflicts(bindings: &[BindingSpec]) -> ConflictReport { + let sequences: Vec>> = bindings + .iter() + .map(|binding| normalize_sequence(&binding.keystrokes)) + .collect(); + + let mut conflicts = Vec::new(); + let mut dropped = Vec::new(); + + for later in 0..bindings.len() { + for earlier in 0..later { + let (Some(earlier_keys), Some(later_keys)) = (&sequences[earlier], &sequences[later]) + else { + continue; + }; + let (earlier_binding, later_binding) = (&bindings[earlier], &bindings[later]); + + // Unbinds are deliberate removals, not mistakes. + if earlier_binding.is_unbind() || later_binding.is_unbind() { + continue; + } + if !contexts_overlap( + earlier_binding.context.as_deref(), + later_binding.context.as_deref(), + ) { + continue; + } + + if earlier_keys == later_keys { + // A user binding replacing a default is the feature, not a bug. + if earlier_binding.source == BindingSource::Default + && later_binding.source == BindingSource::User + { + continue; + } + conflicts.push(Conflict { + kind: ConflictKind::Overlap, + winner: later, + ignored: earlier, + message: format!( + "`{}` is bound twice in overlapping contexts: `{}` ({}, context `{}`) \ + is ignored in favour of `{}` ({}, context `{}`).", + later_binding.keystrokes, + earlier_binding.action, + earlier_binding.source, + earlier_binding.context_label(), + later_binding.action, + later_binding.source, + later_binding.context_label(), + ), + }); + dropped.push(earlier); + } else if is_strict_prefix(earlier_keys, later_keys) { + conflicts.push(prefix_conflict(earlier, later, bindings)); + dropped.push(earlier); + } else if is_strict_prefix(later_keys, earlier_keys) { + conflicts.push(prefix_conflict(later, earlier, bindings)); + dropped.push(later); + } + } + } + + dropped.sort_unstable(); + dropped.dedup(); + ConflictReport { conflicts, dropped } +} + +/// Builds the report entry for a chord-prefix conflict. The prefix loses so the +/// longer chord stays reachable. +fn prefix_conflict(prefix: usize, chord: usize, bindings: &[BindingSpec]) -> Conflict { + let (prefix_binding, chord_binding) = (&bindings[prefix], &bindings[chord]); + Conflict { + kind: ConflictKind::ChordPrefix, + winner: chord, + ignored: prefix, + message: format!( + "`{}` ({}, context `{}`) shadows the chord `{}` ({}, context `{}`); \ + the shorter binding is ignored so the chord stays reachable.", + prefix_binding.keystrokes, + prefix_binding.source, + prefix_binding.context_label(), + chord_binding.keystrokes, + chord_binding.source, + chord_binding.context_label(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn default_binding(keystrokes: &str, context: &str, action: &str) -> BindingSpec { + BindingSpec::default_binding(keystrokes, context, action) + } + + fn user_binding(keystrokes: &str, context: Option<&str>, action: &str) -> BindingSpec { + BindingSpec::user_binding(keystrokes, context, action) + } + + #[test] + fn distinct_keystrokes_do_not_conflict() { + let report = detect_conflicts(&[ + default_binding("ctrl-s", "Workspace", "rgitui::StageAll"), + default_binding("ctrl-u", "Workspace", "rgitui::UnstageAll"), + ]); + assert_eq!(report, ConflictReport::default()); + } + + #[test] + fn disjoint_contexts_do_not_conflict() { + let report = detect_conflicts(&[ + default_binding("y", "GraphView", "graph::CopySha"), + default_binding("y", "DiffViewer", "diff::CopyLine"), + ]); + assert!(report.conflicts.is_empty(), "{:?}", report.conflicts); + } + + #[test] + fn same_context_duplicate_is_detected_and_the_later_wins() { + let bindings = [ + default_binding("ctrl-s", "Workspace", "rgitui::StageAll"), + default_binding("ctrl-s", "Workspace", "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report.conflicts.len(), 1); + assert_eq!(report.conflicts[0].kind, ConflictKind::Overlap); + assert_eq!(report.conflicts[0].winner, 1); + assert_eq!(report.conflicts[0].ignored, 0); + assert_eq!(report.dropped, vec![0]); + assert!(!report.is_kept(0)); + assert!(report.is_kept(1)); + } + + #[test] + fn superset_context_overlap_is_detected() { + // `Workspace` matches everything `Workspace && !modal` matches. + let bindings = [ + default_binding("ctrl-s", "Workspace && !modal", "rgitui::StageAll"), + default_binding("ctrl-s", "Workspace", "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report.conflicts.len(), 1); + assert_eq!(report.conflicts[0].kind, ConflictKind::Overlap); + assert_eq!(report.dropped, vec![0]); + } + + #[test] + fn a_missing_context_overlaps_every_context() { + let bindings = [ + user_binding("ctrl-s", None, "rgitui::StageAll"), + user_binding("ctrl-s", Some("Workspace"), "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report.conflicts.len(), 1); + assert_eq!(report.dropped, vec![0]); + } + + #[test] + fn chord_prefix_shadowing_drops_the_prefix() { + let bindings = [ + user_binding("ctrl-k ctrl-o", Some("Workspace"), "rgitui::OpenRepo"), + user_binding("ctrl-k", Some("Workspace"), "rgitui::StageAll"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report.conflicts.len(), 1); + assert_eq!(report.conflicts[0].kind, ConflictKind::ChordPrefix); + // The prefix loses even though it comes later, so the chord stays reachable. + assert_eq!(report.conflicts[0].ignored, 1); + assert_eq!(report.conflicts[0].winner, 0); + assert_eq!(report.dropped, vec![1]); + } + + #[test] + fn chord_prefix_shadowing_is_order_independent() { + let bindings = [ + user_binding("ctrl-k", Some("Workspace"), "rgitui::StageAll"), + user_binding("ctrl-k ctrl-o", Some("Workspace"), "rgitui::OpenRepo"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report.conflicts.len(), 1); + assert_eq!(report.conflicts[0].kind, ConflictKind::ChordPrefix); + assert_eq!(report.dropped, vec![0]); + } + + #[test] + fn chord_prefix_in_a_disjoint_context_is_fine() { + let bindings = [ + user_binding("ctrl-k ctrl-o", Some("GraphView"), "graph::CopySha"), + user_binding("ctrl-k", Some("DiffViewer"), "diff::CopyLine"), + ]; + let report = detect_conflicts(&bindings); + assert!(report.conflicts.is_empty(), "{:?}", report.conflicts); + } + + #[test] + fn a_user_binding_may_replace_a_default() { + let bindings = [ + default_binding("ctrl-s", "Workspace", "rgitui::StageAll"), + user_binding("ctrl-s", Some("Workspace"), "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report, ConflictReport::default()); + } + + #[test] + fn two_user_bindings_on_one_keystroke_do_conflict() { + let bindings = [ + user_binding("ctrl-s", Some("Workspace"), "rgitui::StageAll"), + user_binding("ctrl-s", Some("Workspace"), "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report.conflicts.len(), 1); + assert_eq!(report.dropped, vec![0]); + } + + #[test] + fn unbinds_are_never_conflicts() { + let bindings = [ + default_binding("ctrl-s", "Workspace", "rgitui::StageAll"), + user_binding("ctrl-s", Some("Workspace"), NO_ACTION), + user_binding("ctrl-k", Some("Workspace"), NO_ACTION), + user_binding("ctrl-k ctrl-o", Some("Workspace"), "rgitui::OpenRepo"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report, ConflictReport::default()); + } + + #[test] + fn equivalent_spellings_of_one_keystroke_are_compared_equal() { + let native = if cfg!(target_os = "macos") { + "cmd-s" + } else { + "ctrl-s" + }; + let bindings = [ + user_binding("secondary-s", Some("Workspace"), "rgitui::StageAll"), + user_binding(native, Some("Workspace"), "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report.conflicts.len(), 1, "{:?}", report.conflicts); + } + + #[test] + fn unparseable_keystrokes_are_skipped() { + let bindings = [ + user_binding("ctrl-a-b", Some("Workspace"), "rgitui::StageAll"), + user_binding("ctrl-a-b", Some("Workspace"), "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + assert!(report.conflicts.is_empty()); + } + + #[test] + fn messages_name_both_bindings() { + let bindings = [ + user_binding("ctrl-s", Some("Workspace"), "rgitui::StageAll"), + user_binding("ctrl-s", Some("Workspace"), "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + let message = &report.messages()[0]; + assert!(message.contains("rgitui::StageAll"), "{message}"); + assert!(message.contains("rgitui::Commit"), "{message}"); + assert!(message.contains("ctrl-s"), "{message}"); + } +} diff --git a/crates/rgitui_workspace/src/keymap/loader.rs b/crates/rgitui_workspace/src/keymap/loader.rs new file mode 100644 index 0000000..432d666 --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/loader.rs @@ -0,0 +1,640 @@ +//! Reads `keymap.json` and turns it, together with the registry defaults, into +//! gpui key bindings. +//! +//! # File format +//! +//! Zed-shaped: an array of sections, each with a context predicate and a map of +//! keystrokes to action names. Comments and trailing commas are allowed +//! (parsed with `serde_json_lenient`, the same crate Zed uses). +//! +//! ```jsonc +//! [ +//! { +//! "context": "Workspace && !modal", +//! "bindings": { +//! // A plain action name. +//! "ctrl-alt-s": "rgitui::StageAll", +//! // An action name plus JSON input. +//! "ctrl-alt-r": ["rgitui::Refresh", {}], +//! // `null` removes the binding entirely. +//! "ctrl-s": null +//! } +//! } +//! ] +//! ``` +//! +//! # Precedence +//! +//! Defaults are collected first, then the user's file, and gpui prefers the +//! binding added last — so the user always wins. Anything ambiguous *within* +//! one source is reported by [`conflict::detect_conflicts`] and dropped rather +//! than silently shadowed. +//! +//! # Error handling +//! +//! Every problem is accumulated: one unparseable context predicate, unknown +//! action name or malformed keystroke does not discard the rest of the file. +//! The collected messages are returned so the workspace can toast them. + +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use gpui::{App, KeyBinding, KeyBindingContextPredicate, SharedString}; +use serde::de::{MapAccess, Visitor}; +use serde::{Deserialize, Deserializer}; + +use super::conflict::{self, BindingSource, BindingSpec, ConflictReport, NO_ACTION}; +use super::registry::ALL_COMMANDS; + +/// A keystroke-to-action map that keeps the order the entries were written in. +/// +/// Order is load-bearing: within one section the last binding on a keystroke +/// wins, so the entries must reach gpui in file order. +#[derive(Debug, Default)] +struct OrderedBindings(Vec<(String, serde_json::Value)>); + +impl<'de> Deserialize<'de> for OrderedBindings { + fn deserialize>(deserializer: D) -> Result { + struct OrderedBindingsVisitor; + + impl<'de> Visitor<'de> for OrderedBindingsVisitor { + type Value = OrderedBindings; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a map of keystrokes to action names") + } + + fn visit_map>(self, mut map: M) -> Result { + let mut entries = Vec::with_capacity(map.size_hint().unwrap_or(0)); + while let Some((keystrokes, action)) = + map.next_entry::()? + { + entries.push((keystrokes, action)); + } + Ok(OrderedBindings(entries)) + } + } + + deserializer.deserialize_map(OrderedBindingsVisitor) + } +} + +/// One section of `keymap.json`. +#[derive(Debug, Default, Deserialize)] +struct KeymapSection { + /// Context predicate. Empty means the bindings apply everywhere. + #[serde(default)] + context: String, + /// Position-equivalent keystrokes for non-QWERTY layouts (macOS only). + #[serde(default)] + use_key_equivalents: bool, + /// Keystroke to action mapping, in file order. + #[serde(default)] + bindings: Option, +} + +/// A candidate binding plus the action input needed to build it. +#[derive(Debug, Clone)] +struct PendingBinding { + spec: BindingSpec, + input: Option, + use_key_equivalents: bool, +} + +/// The result of assembling the default and user keymaps. +#[derive(Debug, Default)] +pub struct LoadedKeymap { + /// Bindings to hand to [`gpui::App::bind_keys`], in precedence order. + pub bindings: Vec, + /// Problems the user should fix: parse errors, unknown actions, conflicts. + pub problems: Vec, +} + +/// Path of the user keymap, alongside `settings.json` in rgitui's config dir. +pub fn keymap_path() -> PathBuf { + rgitui_settings::keymap_path() +} + +/// The default bindings declared by `commands!`, in registry order. +/// +/// Pure, so the default set can be validated without an `App`. +pub fn default_specs() -> Vec { + ALL_COMMANDS + .iter() + .flat_map(|meta| { + meta.default_keystrokes.iter().map(|keystrokes| { + BindingSpec::default_binding(keystrokes, meta.context, meta.action_name) + }) + }) + .collect() +} + +/// Parses `keymap.json` content into candidate bindings, accumulating errors. +/// +/// A section whose `context` does not parse is skipped, but the rest of the file +/// still loads. +fn parse_user_specs(content: &str) -> (Vec, Vec) { + let mut problems = Vec::new(); + + if content.trim().is_empty() { + return (Vec::new(), problems); + } + + let sections: Vec = match serde_json_lenient::from_str(content) { + Ok(sections) => sections, + Err(error) => { + problems.push(format!( + "keymap.json is not valid JSON, so no custom keybindings were loaded: {error}. \ + Fix the syntax error and save the file to reload." + )); + return (Vec::new(), problems); + } + }; + + let mut pending = Vec::new(); + for section in §ions { + let context = (!section.context.trim().is_empty()).then(|| section.context.clone()); + + if let Some(context) = context.as_deref() { + if let Err(error) = KeyBindingContextPredicate::parse(context) { + problems.push(format!( + "keymap.json: the context `{context}` could not be parsed, so that section \ + was skipped: {error}." + )); + continue; + } + } + + for (keystrokes, action) in section.bindings.iter().flat_map(|bindings| &bindings.0) { + match parse_action(action) { + Ok((name, input)) => pending.push(PendingBinding { + spec: BindingSpec { + keystrokes: keystrokes.clone(), + context: context.clone(), + action: name, + source: BindingSource::User, + }, + input, + use_key_equivalents: section.use_key_equivalents, + }), + Err(error) => problems.push(format!( + "keymap.json: the binding for `{keystrokes}` was skipped: {error}" + )), + } + } + } + + (pending, problems) +} + +/// Splits a `keymap.json` action value into an action name and optional input. +/// +/// `null` becomes gpui's `NoAction`, which removes the binding. +fn parse_action(action: &serde_json::Value) -> Result<(String, Option), String> { + match action { + serde_json::Value::Null => Ok((NO_ACTION.to_owned(), None)), + serde_json::Value::String(name) => Ok((name.clone(), None)), + serde_json::Value::Array(items) => { + let [serde_json::Value::String(name), input] = items.as_slice() else { + return Err( + "expected a two-element array of `[\"namespace::Action\", input]`.".to_owned(), + ); + }; + Ok((name.clone(), Some(input.clone()))) + } + other => Err(format!( + "expected an action name, a two-element `[name, input]` array, or `null`, \ + but found `{other}`." + )), + } +} + +/// Checks whether an action name (with its optional JSON input) can be built. +/// +/// Injected so the validation pass stays pure and testable without an `App`. +type ActionResolver<'a> = &'a dyn Fn(&str, Option<&serde_json::Value>) -> Result<(), String>; + +/// Which candidate bindings survive validation, and what went wrong with the rest. +#[derive(Debug, Default, PartialEq, Eq)] +struct BindingPlan { + /// Indices into the candidate list, in precedence order. + keep: Vec, + /// One message per rejected binding. + problems: Vec, +} + +/// Validates every candidate binding that survived conflict resolution. +/// +/// Pure apart from `resolve_action`. Each rejection is recorded and the +/// remaining candidates are still planned, so one bad entry never aborts the +/// load. +fn plan_bindings( + pending: &[PendingBinding], + report: &ConflictReport, + resolve_action: ActionResolver<'_>, +) -> BindingPlan { + let mut plan = BindingPlan::default(); + + for (index, entry) in pending.iter().enumerate() { + if !report.is_kept(index) { + continue; + } + + if let Err(error) = resolve_action(&entry.spec.action, entry.input.as_ref()) { + plan.problems.push(format!( + "{}: the binding for `{}` was skipped: {error} \ + See docs/KEYBINDINGS.md for the list of action names.", + entry.spec.source, entry.spec.keystrokes + )); + continue; + } + + if let Some(context) = entry.spec.context.as_deref() { + if let Err(error) = KeyBindingContextPredicate::parse(context) { + plan.problems.push(format!( + "{}: the context `{context}` could not be parsed, so the binding for `{}` \ + was skipped: {error}.", + entry.spec.source, entry.spec.keystrokes + )); + continue; + } + } + + if let Err(error) = parse_keystrokes(&entry.spec.keystrokes) { + plan.problems.push(format!( + "{}: the binding for `{}` was skipped: {error}", + entry.spec.source, entry.spec.keystrokes + )); + continue; + } + + plan.keep.push(index); + } + + plan +} + +/// Validates a whitespace-separated keystroke sequence. +fn parse_keystrokes(keystrokes: &str) -> Result<(), String> { + if keystrokes.split_whitespace().next().is_none() { + return Err("the keystroke is empty.".to_owned()); + } + for keystroke in keystrokes.split_whitespace() { + gpui::Keystroke::parse(keystroke).map_err(|error| error.to_string())?; + } + Ok(()) +} + +/// Assembles the default bindings and the given user keymap content. +/// +/// Exposed separately from [`load`] so tests can drive it with inline content. +pub fn load_from_content(content: &str, cx: &App) -> LoadedKeymap { + let (user_pending, mut problems) = parse_user_specs(content); + + let mut pending: Vec = default_specs() + .into_iter() + .map(|spec| PendingBinding { + spec, + input: None, + use_key_equivalents: false, + }) + .collect(); + pending.extend(user_pending); + + let specs: Vec = pending.iter().map(|entry| entry.spec.clone()).collect(); + let report = conflict::detect_conflicts(&specs); + problems.extend(report.messages()); + + let plan = plan_bindings(&pending, &report, &|name, input| { + cx.build_action(name, input.cloned()) + .map(|_| ()) + .map_err(|error| format!("{error}.")) + }); + problems.extend(plan.problems); + + let bindings = build_bindings(&pending, &plan.keep, &mut problems, cx); + LoadedKeymap { bindings, problems } +} + +/// Constructs the gpui bindings for the planned candidates. +fn build_bindings( + pending: &[PendingBinding], + keep: &[usize], + problems: &mut Vec, + cx: &App, +) -> Vec { + let mut bindings = Vec::with_capacity(keep.len()); + + for entry in keep.iter().map(|&index| &pending[index]) { + // `plan_bindings` already proved the name, context and keystrokes are good. + let Ok(action) = cx.build_action(&entry.spec.action, entry.input.clone()) else { + continue; + }; + let context = match entry.spec.context.as_deref() { + Some(context) => KeyBindingContextPredicate::parse(context).ok().map(Rc::new), + None => None, + }; + let input = entry + .input + .as_ref() + .map(|input| SharedString::from(input.to_string())); + + match KeyBinding::load( + &entry.spec.keystrokes, + action, + context, + entry.use_key_equivalents, + input, + cx.keyboard_mapper().as_ref(), + ) { + Ok(binding) => bindings.push(binding), + Err(error) => problems.push(format!( + "{}: the binding for `{}` was skipped: {error}", + entry.spec.source, entry.spec.keystrokes + )), + } + } + + bindings +} + +/// Reads the user keymap from disk, returning empty content when absent. +fn read_user_keymap(path: &Path) -> (String, Vec) { + match std::fs::read_to_string(path) { + Ok(content) => (content, Vec::new()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (String::new(), Vec::new()), + Err(error) => ( + String::new(), + vec![format!( + "Could not read {}: {error}. Custom keybindings were not applied.", + path.display() + )], + ), + } +} + +/// Loads the defaults plus the user's `keymap.json` from disk. +pub fn load(cx: &App) -> LoadedKeymap { + let (content, mut problems) = read_user_keymap(&keymap_path()); + let mut loaded = load_from_content(&content, cx); + problems.extend(loaded.problems); + loaded.problems = problems; + loaded +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_default_keystroke_parses() { + for spec in default_specs() { + for keystroke in spec.keystrokes.split_whitespace() { + assert!( + gpui::Keystroke::parse(keystroke).is_ok(), + "default keystroke `{keystroke}` for {} does not parse", + spec.action + ); + } + } + } + + #[test] + fn every_default_context_parses() { + for spec in default_specs() { + let context = spec.context.as_deref().expect("defaults declare a context"); + assert!( + KeyBindingContextPredicate::parse(context).is_ok(), + "default context `{context}` for {} does not parse", + spec.action + ); + } + } + + #[test] + fn the_default_keymap_has_no_conflicts() { + let report = conflict::detect_conflicts(&default_specs()); + assert!( + report.conflicts.is_empty(), + "default keymap has conflicts:\n{}", + report.messages().join("\n") + ); + } + + #[test] + fn parses_jsonc_with_comments_and_trailing_commas() { + let content = r#" + [ + // A section. + { + "context": "Workspace", + "bindings": { + "ctrl-alt-s": "rgitui::StageAll", /* inline */ + }, + }, + ] + "#; + let (pending, problems) = parse_user_specs(content); + assert!(problems.is_empty(), "{problems:?}"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].spec.action, "rgitui::StageAll"); + assert_eq!(pending[0].spec.context.as_deref(), Some("Workspace")); + } + + #[test] + fn bindings_keep_their_file_order() { + let content = r#"[{ "bindings": { + "ctrl-1": "rgitui::Fetch", + "ctrl-2": "rgitui::Pull", + "ctrl-3": "rgitui::Push" + }}]"#; + let (pending, _) = parse_user_specs(content); + let keystrokes: Vec<&str> = pending + .iter() + .map(|entry| entry.spec.keystrokes.as_str()) + .collect(); + assert_eq!(keystrokes, ["ctrl-1", "ctrl-2", "ctrl-3"]); + } + + #[test] + fn null_becomes_an_unbind() { + let content = r#"[{ "context": "Workspace", "bindings": { "ctrl-s": null } }]"#; + let (pending, problems) = parse_user_specs(content); + assert!(problems.is_empty(), "{problems:?}"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].spec.action, NO_ACTION); + } + + #[test] + fn an_action_may_carry_json_input() { + let content = r#"[{ "bindings": { "ctrl-s": ["rgitui::StageAll", { "all": true }] } }]"#; + let (pending, problems) = parse_user_specs(content); + assert!(problems.is_empty(), "{problems:?}"); + assert_eq!(pending[0].spec.action, "rgitui::StageAll"); + assert!(pending[0].input.is_some()); + } + + #[test] + fn a_user_binding_overrides_a_default_and_is_ordered_last() { + let content = r#"[{ + "context": "Workspace && !modal", + "bindings": { "secondary-s": "rgitui::Commit" } + }]"#; + let (user, problems) = parse_user_specs(content); + assert!(problems.is_empty(), "{problems:?}"); + + let mut specs = default_specs(); + let default_index = specs + .iter() + .position(|spec| spec.action == "rgitui::StageAll") + .expect("StageAll is bound by default"); + specs.extend(user.iter().map(|entry| entry.spec.clone())); + let user_index = specs.len() - 1; + + assert!( + default_index < user_index, + "the user binding must come last" + ); + let report = conflict::detect_conflicts(&specs); + assert!( + report.conflicts.is_empty(), + "overriding a default must not be reported: {:?}", + report.messages() + ); + assert!(report.is_kept(default_index)); + assert!(report.is_kept(user_index)); + } + + #[test] + fn a_bad_section_context_does_not_discard_the_rest_of_the_file() { + let content = r#"[ + { "context": "Workspace &&", "bindings": { "ctrl-1": "rgitui::Fetch" } }, + { "context": "Workspace", "bindings": { "ctrl-2": "rgitui::Pull" } } + ]"#; + let (pending, problems) = parse_user_specs(content); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains("could not be parsed"), "{problems:?}"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].spec.action, "rgitui::Pull"); + } + + #[test] + fn a_malformed_action_value_does_not_discard_the_rest_of_the_section() { + let content = r#"[{ "bindings": { + "ctrl-1": 42, + "ctrl-2": "rgitui::Pull" + }}]"#; + let (pending, problems) = parse_user_specs(content); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains("ctrl-1"), "{problems:?}"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].spec.action, "rgitui::Pull"); + } + + #[test] + fn a_syntax_error_is_reported_once() { + let (pending, problems) = parse_user_specs("[{ \"context\": }]"); + assert!(pending.is_empty()); + assert_eq!(problems.len(), 1); + assert!(problems[0].contains("not valid JSON"), "{problems:?}"); + } + + #[test] + fn an_empty_file_loads_the_defaults_only() { + let (pending, problems) = parse_user_specs(" \n "); + assert!(pending.is_empty()); + assert!(problems.is_empty()); + } + + /// Stands in for `App::build_action`: every registry action name resolves, + /// plus `zed::NoAction`. + fn resolve_known_actions(name: &str, _input: Option<&serde_json::Value>) -> Result<(), String> { + let known = name == NO_ACTION || ALL_COMMANDS.iter().any(|meta| meta.action_name == name); + if known { + Ok(()) + } else { + Err(format!("didn't find an action named `{name}`.")) + } + } + + fn plan(content: &str) -> BindingPlan { + let (user, _) = parse_user_specs(content); + let mut pending: Vec = default_specs() + .into_iter() + .map(|spec| PendingBinding { + spec, + input: None, + use_key_equivalents: false, + }) + .collect(); + pending.extend(user); + let specs: Vec = pending.iter().map(|entry| entry.spec.clone()).collect(); + let report = conflict::detect_conflicts(&specs); + plan_bindings(&pending, &report, &resolve_known_actions) + } + + #[test] + fn every_default_binding_survives_planning() { + let plan = plan(""); + assert!(plan.problems.is_empty(), "{:?}", plan.problems); + assert_eq!(plan.keep.len(), default_specs().len()); + } + + #[test] + fn an_unknown_action_name_is_reported_and_the_load_continues() { + let plan = plan( + r#"[{ "context": "Workspace", "bindings": { + "ctrl-alt-1": "rgitui::NoSuchCommand", + "ctrl-alt-2": "rgitui::Pull" + }}]"#, + ); + assert_eq!(plan.problems.len(), 1, "{:?}", plan.problems); + assert!( + plan.problems[0].contains("rgitui::NoSuchCommand"), + "{:?}", + plan.problems + ); + // The defaults plus the one good user binding. + assert_eq!(plan.keep.len(), default_specs().len() + 1); + } + + #[test] + fn an_unparseable_keystroke_is_reported_and_the_load_continues() { + let plan = plan( + r#"[{ "context": "Workspace", "bindings": { + "ctrl-a-b": "rgitui::Fetch", + "ctrl-alt-2": "rgitui::Pull" + }}]"#, + ); + assert_eq!(plan.problems.len(), 1, "{:?}", plan.problems); + assert!(plan.problems[0].contains("ctrl-a-b"), "{:?}", plan.problems); + assert_eq!(plan.keep.len(), default_specs().len() + 1); + } + + #[test] + fn an_unbind_is_planned_so_gpui_can_apply_it() { + let plan = + plan(r#"[{ "context": "Workspace && !modal", "bindings": { "secondary-s": null } }]"#); + assert!(plan.problems.is_empty(), "{:?}", plan.problems); + assert_eq!(plan.keep.len(), default_specs().len() + 1); + } + + #[test] + fn a_conflicting_pair_of_user_bindings_drops_the_earlier_one() { + let plan = plan( + r#"[{ "context": "Workspace", "bindings": { + "ctrl-alt-9": "rgitui::Fetch" + }}, { "context": "Workspace", "bindings": { + "ctrl-alt-9": "rgitui::Pull" + }}]"#, + ); + // One of the two user bindings was dropped, so only one was added. + assert_eq!(plan.keep.len(), default_specs().len() + 1); + let last = *plan.keep.last().expect("at least one binding"); + assert_eq!( + last, + default_specs().len() + 1, + "the later binding must win" + ); + } +} diff --git a/crates/rgitui_workspace/src/keymap/mod.rs b/crates/rgitui_workspace/src/keymap/mod.rs index 59cd7fd..4242a5f 100644 --- a/crates/rgitui_workspace/src/keymap/mod.rs +++ b/crates/rgitui_workspace/src/keymap/mod.rs @@ -3,10 +3,153 @@ //! * [`macros`] defines `commands!`, the single source of truth. //! * [`registry`] declares every command with it, producing [`CommandId`], the //! gpui action structs and [`ALL_COMMANDS`]. +//! * [`conflict`] detects ambiguous bindings before gpui silently resolves them. +//! * [`loader`] reads `keymap.json` and assembles the final binding list. +//! +//! [`init`] wires it up: it applies the bindings and watches `keymap.json` so +//! saving the file reloads it. Reload outcomes land in [`KeymapState`], which the +//! workspace observes in order to toast problems. #[macro_use] pub(crate) mod macros; +pub mod conflict; +pub mod loader; pub mod registry; +use std::path::PathBuf; +use std::time::Duration; + +use gpui::{App, Global}; + +pub use loader::keymap_path; pub use registry::{actions, attach_actions, CommandId, CommandMeta, ALL_COMMANDS}; + +/// Debounce applied after a `keymap.json` change before reloading, so an editor +/// writing the file in several steps triggers one reload. +const RELOAD_DEBOUNCE: Duration = Duration::from_millis(200); + +/// The outcome of the most recent keymap load. +/// +/// A gpui [`Global`], so the workspace can `observe_global` it and surface +/// problems as toasts — including for reloads that happen long after startup. +pub struct KeymapState { + /// Problems from the most recent load, ready to show to the user. + pub problems: Vec, + /// Number of bindings currently applied. + pub binding_count: usize, + /// Incremented on every load, so observers can tell reloads apart. + pub generation: usize, +} + +impl Global for KeymapState {} + +impl KeymapState { + /// Takes the pending problems, leaving the state empty. + pub fn take_problems(&mut self) -> Vec { + std::mem::take(&mut self.problems) + } +} + +/// Keeps the `keymap.json` watcher alive for the lifetime of the app. +struct KeymapWatcher { + #[allow(dead_code, reason = "dropping the watcher stops the notifications")] + watcher: Box, +} + +impl Global for KeymapWatcher {} + +/// Applies the default bindings followed by the user's `keymap.json`. +/// +/// Every binding is cleared first, so this doubles as the reload path. Defaults +/// go in before user bindings because gpui prefers the binding added last. +pub fn reload(cx: &mut App) { + let loaded = loader::load(cx); + let binding_count = loaded.bindings.len(); + + cx.clear_key_bindings(); + cx.bind_keys(loaded.bindings); + + for problem in &loaded.problems { + log::warn!("keymap: {problem}"); + } + log::info!("keymap: applied {binding_count} key bindings"); + + let generation = cx + .try_global::() + .map_or(0, |state| state.generation + 1); + cx.set_global(KeymapState { + problems: loaded.problems, + binding_count, + generation, + }); +} + +/// Loads the keymap and starts watching `keymap.json` for changes. +/// +/// Call once during startup, after settings are initialised (the config +/// directory must exist for the watcher to attach). +pub fn init(cx: &mut App) { + reload(cx); + watch_keymap_file(cx); +} + +/// Watches the config directory and reloads when `keymap.json` changes. +/// +/// The directory rather than the file is watched, so a keymap created after +/// startup — or replaced by an editor's atomic save — is still noticed. +fn watch_keymap_file(cx: &mut App) { + use notify::{RecursiveMode, Watcher as _}; + + let path = keymap_path(); + let Some(directory) = path.parent().map(PathBuf::from) else { + return; + }; + if let Err(error) = std::fs::create_dir_all(&directory) { + log::warn!( + "keymap: could not create {}, so keymap.json will not be watched: {error}", + directory.display() + ); + return; + } + + let (tx, rx) = async_channel::unbounded::<()>(); + let watched = path.clone(); + let watcher = notify::recommended_watcher(move |event: notify::Result| { + let Ok(event) = event else { + return; + }; + if event.paths.iter().any(|changed| changed == &watched) { + let _ = tx.try_send(()); + } + }); + + let mut watcher = match watcher { + Ok(watcher) => watcher, + Err(error) => { + log::warn!("keymap: could not start the keymap.json watcher: {error}"); + return; + } + }; + if let Err(error) = watcher.watch(&directory, RecursiveMode::NonRecursive) { + log::warn!( + "keymap: could not watch {}: {error}. Restart rgitui to pick up keymap.json changes.", + directory.display() + ); + return; + } + + cx.set_global(KeymapWatcher { + watcher: Box::new(watcher), + }); + + cx.spawn(async move |cx: &mut gpui::AsyncApp| { + while rx.recv().await.is_ok() { + // Coalesce the burst of events an editor emits for one save. + cx.background_executor().timer(RELOAD_DEBOUNCE).await; + while rx.try_recv().is_ok() {} + cx.update(reload); + } + }) + .detach(); +} diff --git a/crates/rgitui_workspace/src/settings_window/view.rs b/crates/rgitui_workspace/src/settings_window/view.rs index 1dad27a..b7ec1a9 100644 --- a/crates/rgitui_workspace/src/settings_window/view.rs +++ b/crates/rgitui_workspace/src/settings_window/view.rs @@ -3868,7 +3868,7 @@ impl SettingsView { section = section.child(shortcuts_card); section = section.child(Self::section_divider(cx)); - let config_path = config_dir().join("settings.json"); + let config_path = rgitui_settings::settings_path(); let config_path_display = config_path.display().to_string(); let config_dir_path = config_dir(); let mut config_card = Self::setting_card(cx); diff --git a/crates/rgitui_workspace/src/workspace/mod.rs b/crates/rgitui_workspace/src/workspace/mod.rs index a8474e1..a1fc7ce 100644 --- a/crates/rgitui_workspace/src/workspace/mod.rs +++ b/crates/rgitui_workspace/src/workspace/mod.rs @@ -278,6 +278,9 @@ pub struct Workspace { pub(super) update_notification: Option, pub(super) settings_window: Option>, pub(super) _settings_window_closed_subscription: Option, + /// Keeps the [`crate::keymap::KeymapState`] observer alive so `keymap.json` + /// reload problems are toasted as they happen. + pub(super) _keymap_subscription: gpui::Subscription, } /// Smallest width the left sidebar may take, whether reached by dragging the @@ -422,6 +425,10 @@ impl Workspace { update_notification: None, settings_window: None, _settings_window_closed_subscription: None, + // Fires on every keymap.json reload, including ones long after startup. + _keymap_subscription: cx.observe_global::( + |workspace, cx| workspace.show_keymap_problems(cx), + ), } } @@ -638,6 +645,21 @@ impl Workspace { } } + /// Surface anything wrong with the user's `keymap.json` as a toast. + /// + /// Drains the problems so each is shown once, whether it came from the + /// startup load or from a later reload triggered by saving the file. + pub fn show_keymap_problems(&mut self, cx: &mut Context) { + if cx.try_global::().is_none() { + return; + } + let problems = + cx.update_global::(|state, _| state.take_problems()); + for problem in problems { + self.show_toast(problem, ToastKind::Error, cx); + } + } + /// Mark a clean exit when the user explicitly closes or goes home. pub fn mark_clean_exit(&self, cx: &mut Context) { cx.update_global::(|settings, _| { From 7d34a85ba3567d0c7d11ef72fb04f0ad7850ad1c Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:18:16 +0100 Subject: [PATCH 03/16] docs(keymap): generate the keybinding reference and keymap schema `docs/KEYBINDINGS.md` gives users a table per view of keystroke, action name and description, plus how to write `keymap.json`. `docs/keymap.schema.json` enumerates every action name with its description, so an editor pointed at it completes and validates the file. Both are rendered from `ALL_COMMANDS`, so neither can drift from the `commands!` declaration, and both are golden-tested: the tests regenerate the content and fail with a line diff when the committed file is stale. `RGITUI_BLESS=1 cargo test -p rgitui_workspace keymap::generate` rewrites them. The keymap's root is a JSON array, so there is nowhere to put a `$schema` key; the schema has to be associated by file name in the editor. Co-Authored-By: Claude Fable 5 --- .../rgitui_workspace/src/keymap/generate.rs | 328 ++++++++++++++++++ crates/rgitui_workspace/src/keymap/mod.rs | 3 + docs/KEYBINDINGS.md | 102 ++++++ docs/keymap.schema.json | 306 ++++++++++++++++ 4 files changed, 739 insertions(+) create mode 100644 crates/rgitui_workspace/src/keymap/generate.rs create mode 100644 docs/KEYBINDINGS.md create mode 100644 docs/keymap.schema.json diff --git a/crates/rgitui_workspace/src/keymap/generate.rs b/crates/rgitui_workspace/src/keymap/generate.rs new file mode 100644 index 0000000..be40e5d --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/generate.rs @@ -0,0 +1,328 @@ +//! Generates the committed keybinding artifacts from the registry. +//! +//! Two files are derived from `commands!` and checked in: +//! +//! * `docs/KEYBINDINGS.md` — the user-facing reference, +//! * `docs/keymap.schema.json` — a JSON Schema enumerating every action name so +//! editors can complete `keymap.json`. +//! +//! Both are golden-tested: the tests below regenerate the content and compare it +//! with the committed file, failing with a line diff when it is stale. Run +//! `cargo test -p rgitui_workspace keymap::generate` after changing `commands!`, +//! then copy the regenerated content in (the failure message tells you which +//! file drifted). + +use std::fmt::Write as _; + +use super::registry::{CommandMeta, ALL_COMMANDS}; + +/// Header written into both generated files so nobody hand-edits them. +const GENERATED_NOTICE: &str = "Generated from the `commands!` declaration in \ + `crates/rgitui_workspace/src/keymap/registry.rs`. Do not edit by hand."; + +/// The distinct view names in the registry, in declaration order. +fn views() -> Vec<&'static str> { + let mut views: Vec<&'static str> = Vec::new(); + for meta in ALL_COMMANDS { + if !views.contains(&meta.view) { + views.push(meta.view); + } + } + views +} + +/// Renders a command's default keystrokes for a Markdown table cell. +fn keystroke_cell(meta: &CommandMeta) -> String { + if meta.default_keystrokes.is_empty() { + return "_unbound_".to_owned(); + } + meta.default_keystrokes + .iter() + .map(|keystroke| format!("`{keystroke}`")) + .collect::>() + .join(" or ") +} + +/// Renders `docs/KEYBINDINGS.md`. +pub fn keybindings_markdown() -> String { + let mut out = String::new(); + + out.push_str("# Keyboard shortcuts\n\n"); + out.push_str("\n\n"); + out.push_str( + "Every shortcut below is rebindable. Create `keymap.json` next to `settings.json` in \ + rgitui's config directory and bind the action names from the tables below.\n\n", + ); + out.push_str( + "`secondary` is the platform's primary modifier: `cmd` on macOS, `ctrl` everywhere \ + else. Commands marked _unbound_ have no default keystroke and are reached from the \ + command palette (`secondary-shift-p`).\n\n", + ); + out.push_str("## Customising\n\n"); + out.push_str( + "```jsonc\n\ + [\n \ + {\n \ + \"context\": \"Workspace && !modal\",\n \ + \"bindings\": {\n \ + // Rebind staging.\n \ + \"ctrl-alt-s\": \"rgitui::StageAll\",\n \ + // Remove a default binding.\n \ + \"secondary-s\": null\n \ + }\n \ + }\n\ + ]\n\ + ```\n\n", + ); + out.push_str( + "The file is reloaded when you save it. Bindings you add win over the defaults. \ + Two bindings on the same keystroke in overlapping contexts, or a binding that \ + shadows the prefix of a chord, are reported as a toast and the losing binding is \ + dropped rather than silently ignored.\n\n", + ); + out.push_str( + "`docs/keymap.schema.json` lists every action name; point your editor at it for \ + completion.\n", + ); + + for view in views() { + let _ = write!(out, "\n## {view}\n\n"); + out.push_str("| Keystroke | Action | Description |\n"); + out.push_str("| --- | --- | --- |\n"); + for meta in ALL_COMMANDS.iter().filter(|meta| meta.view == view) { + let _ = writeln!( + out, + "| {} | `{}` | {} |", + keystroke_cell(meta), + meta.action_name, + meta.description() + ); + } + } + + let _ = write!( + out, + "\n## Key contexts\n\n\ + | Context | Set on |\n\ + | --- | --- |\n\ + | `Workspace` | the workspace root, so it is always in scope |\n\ + | `modal` | added to the workspace root while any overlay or dialog is open |\n\ + | `TextInput` | any focused text field, so single-key shortcuts do not steal typing |\n\ + \n\ + Contexts combine with `&&`, `||` and `!`, and `>` matches a descendant.\n" + ); + + out +} + +/// Renders `docs/keymap.schema.json`. +pub fn keymap_json_schema() -> String { + let action_names: Vec = std::iter::once(json_action_name( + super::conflict::NO_ACTION, + "Remove the binding this keystroke would otherwise have.", + )) + .chain(ALL_COMMANDS.iter().map(|meta| { + json_action_name( + meta.action_name, + &format!("{} Command id: `{}`.", meta.description(), meta.id.as_str()), + ) + })) + .collect(); + + let schema = serde_json::json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/noahbclarkson/rgitui/main/docs/keymap.schema.json", + "title": "rgitui keymap", + "description": GENERATED_NOTICE, + "type": "array", + "items": { "$ref": "#/$defs/section" }, + "$defs": { + "section": { + "type": "object", + "additionalProperties": false, + "properties": { + "context": { + "type": "string", + "description": + "When these bindings are active, e.g. `Workspace && !modal`. \ + Combine identifiers with `&&`, `||` and `!`; `>` matches a \ + descendant. Omit to bind everywhere.", + }, + "use_key_equivalents": { + "type": "boolean", + "description": + "Interpret keystrokes by their position on a QWERTY keyboard. \ + macOS only.", + "default": false, + }, + "bindings": { + "type": "object", + "description": + "Keystrokes to actions. A keystroke is modifiers then a key joined \ + by `-` (`secondary-shift-p`); separate the keystrokes of a chord \ + with spaces (`ctrl-k ctrl-o`). Later entries win.", + "additionalProperties": { "$ref": "#/$defs/action" }, + }, + }, + }, + "action": { + "description": + "An action name, a two-element `[name, input]` array, or `null` to unbind.", + "oneOf": [ + { "$ref": "#/$defs/actionName" }, + { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [{ "$ref": "#/$defs/actionName" }, true], + }, + { "type": "null", "description": "Remove this binding." }, + ], + }, + "actionName": { + "type": "string", + "anyOf": action_names, + }, + }, + }); + + let mut out = serde_json::to_string_pretty(&schema).expect("the schema is serializable"); + out.push('\n'); + out +} + +/// One `anyOf` branch pinning a single action name, carrying its description so +/// editors show it during completion. +fn json_action_name(name: &str, description: &str) -> serde_json::Value { + serde_json::json!({ "const": name, "description": description.trim() }) +} + +/// Repository-relative path of the generated keybinding reference. +#[cfg(test)] +const MARKDOWN_PATH: &str = "docs/KEYBINDINGS.md"; +/// Repository-relative path of the generated keymap schema. +#[cfg(test)] +const SCHEMA_PATH: &str = "docs/keymap.schema.json"; + +/// Setting this environment variable makes the golden tests rewrite the +/// committed artifacts instead of failing: +/// +/// ```text +/// RGITUI_BLESS=1 cargo test -p rgitui_workspace keymap::generate +/// ``` +/// +/// Re-run the tests afterwards to confirm the files now match. +#[cfg(test)] +const BLESS_ENV: &str = "RGITUI_BLESS"; + +/// Renders a minimal line diff so a stale golden file is easy to fix. +#[cfg(test)] +fn line_diff(expected: &str, actual: &str) -> String { + let expected: Vec<&str> = expected.lines().collect(); + let actual: Vec<&str> = actual.lines().collect(); + let mut out = String::new(); + for index in 0..expected.len().max(actual.len()) { + match (expected.get(index), actual.get(index)) { + (Some(left), Some(right)) if left == right => {} + (left, right) => { + let _ = writeln!(out, "line {}:", index + 1); + let _ = writeln!(out, " committed: {}", left.unwrap_or(&"")); + let _ = writeln!(out, " generated: {}", right.unwrap_or(&"")); + } + } + } + out +} + +/// Fails with a diff when a committed artifact no longer matches the registry. +/// +/// With [`BLESS_ENV`] set, rewrites the file instead so the artifacts can be +/// regenerated without a separate binary. +#[cfg(test)] +fn assert_golden(path: &str, committed: &str, generated: &str) { + if std::env::var_os(BLESS_ENV).is_some() { + let absolute = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path); + std::fs::write(&absolute, generated) + .unwrap_or_else(|error| panic!("could not write {}: {error}", absolute.display())); + return; + } + assert!( + committed == generated, + "{path} is stale — regenerate it with `{BLESS_ENV}=1 cargo test -p rgitui_workspace \ + keymap::generate`.\n\n{}", + line_diff(committed, generated) + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keybindings_doc_is_up_to_date() { + assert_golden( + MARKDOWN_PATH, + include_str!("../../../../docs/KEYBINDINGS.md"), + &keybindings_markdown(), + ); + } + + #[test] + fn keymap_schema_is_up_to_date() { + assert_golden( + SCHEMA_PATH, + include_str!("../../../../docs/keymap.schema.json"), + &keymap_json_schema(), + ); + } + + #[test] + fn the_doc_lists_every_command_exactly_once() { + let markdown = keybindings_markdown(); + for meta in ALL_COMMANDS { + let row = format!("| `{}` |", meta.action_name); + assert_eq!( + markdown.matches(&row).count(), + 1, + "{} appears {} times in the generated doc", + meta.action_name, + markdown.matches(&row).count() + ); + } + } + + #[test] + fn the_schema_enumerates_every_action_name() { + let schema: serde_json::Value = + serde_json::from_str(&keymap_json_schema()).expect("generated schema is valid JSON"); + let names: Vec<&str> = schema["$defs"]["actionName"]["anyOf"] + .as_array() + .expect("anyOf is an array") + .iter() + .map(|branch| branch["const"].as_str().expect("const is a string")) + .collect(); + + assert!(names.contains(&super::super::conflict::NO_ACTION)); + for meta in ALL_COMMANDS { + assert!( + names.contains(&meta.action_name), + "{} is missing from the schema", + meta.action_name + ); + } + assert_eq!(names.len(), ALL_COMMANDS.len() + 1); + } + + #[test] + fn command_ids_are_discoverable_from_the_schema_descriptions() { + let schema = keymap_json_schema(); + for id in super::super::registry::CommandId::ALL { + let needle = format!("Command id: `{}`.", id.as_str()); + assert!(schema.contains(&needle), "{needle} is missing"); + } + } +} diff --git a/crates/rgitui_workspace/src/keymap/mod.rs b/crates/rgitui_workspace/src/keymap/mod.rs index 4242a5f..3d703a9 100644 --- a/crates/rgitui_workspace/src/keymap/mod.rs +++ b/crates/rgitui_workspace/src/keymap/mod.rs @@ -5,6 +5,8 @@ //! gpui action structs and [`ALL_COMMANDS`]. //! * [`conflict`] detects ambiguous bindings before gpui silently resolves them. //! * [`loader`] reads `keymap.json` and assembles the final binding list. +//! * [`generate`] renders the committed `docs/KEYBINDINGS.md` and +//! `docs/keymap.schema.json`. //! //! [`init`] wires it up: it applies the bindings and watches `keymap.json` so //! saving the file reloads it. Reload outcomes land in [`KeymapState`], which the @@ -14,6 +16,7 @@ pub(crate) mod macros; pub mod conflict; +pub mod generate; pub mod loader; pub mod registry; diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md new file mode 100644 index 0000000..76ef129 --- /dev/null +++ b/docs/KEYBINDINGS.md @@ -0,0 +1,102 @@ +# Keyboard shortcuts + + + +Every shortcut below is rebindable. Create `keymap.json` next to `settings.json` in rgitui's config directory and bind the action names from the tables below. + +`secondary` is the platform's primary modifier: `cmd` on macOS, `ctrl` everywhere else. Commands marked _unbound_ have no default keystroke and are reached from the command palette (`secondary-shift-p`). + +## Customising + +```jsonc +[ + { + "context": "Workspace && !modal", + "bindings": { + // Rebind staging. + "ctrl-alt-s": "rgitui::StageAll", + // Remove a default binding. + "secondary-s": null + } + } +] +``` + +The file is reloaded when you save it. Bindings you add win over the defaults. Two bindings on the same keystroke in overlapping contexts, or a binding that shadows the prefix of a chord, are reported as a toast and the losing binding is dropped rather than silently ignored. + +`docs/keymap.schema.json` lists every action name; point your editor at it for completion. + +## Workspace + +| Keystroke | Action | Description | +| --- | --- | --- | +| `secondary-shift-r` | `rgitui::Fetch` | Download objects and refs from the tracked remote. | +| _unbound_ | `rgitui::Pull` | Fetch from and integrate with the tracked remote branch. | +| _unbound_ | `rgitui::Push` | Update the remote ref along with associated objects. | +| _unbound_ | `rgitui::PushAll` | Push every open repository. | +| _unbound_ | `rgitui::PullAll` | Pull every open repository. | +| _unbound_ | `rgitui::ForcePush` | Overwrite the remote branch with the local one. | +| `secondary-enter` | `rgitui::Commit` | Commit the staged changes using the message in the commit panel. | +| `secondary-s` | `rgitui::StageAll` | Stage every change in the working tree. | +| `secondary-shift-s` or `secondary-u` | `rgitui::UnstageAll` | Unstage everything currently staged. | +| `secondary-z` | `rgitui::StashSave` | Stash the working tree and index. | +| `secondary-shift-z` | `rgitui::StashPop` | Apply the latest stash entry and drop it. | +| _unbound_ | `rgitui::StashApply` | Apply the latest stash entry and keep it. | +| _unbound_ | `rgitui::StashDrop` | Delete a stash entry. | +| `secondary-b` | `rgitui::CreateBranch` | Create a new branch. | +| _unbound_ | `rgitui::DeleteBranch` | Delete a branch. | +| _unbound_ | `rgitui::RenameBranch` | Rename a branch. | +| _unbound_ | `rgitui::MergeBranch` | Merge another branch into the current one. | +| _unbound_ | `rgitui::CreateTag` | Create a tag. | +| _unbound_ | `rgitui::CreateWorktree` | Create a linked worktree. | +| _unbound_ | `rgitui::CreatePr` | Open a pull request for the current branch. | +| _unbound_ | `rgitui::CherryPick` | Cherry-pick a commit onto the current branch. | +| _unbound_ | `rgitui::RevertCommit` | Revert a commit. | +| _unbound_ | `rgitui::InteractiveRebase` | Start an interactive rebase. | +| _unbound_ | `rgitui::DiscardAll` | Discard every uncommitted change. | +| _unbound_ | `rgitui::CleanUntracked` | Delete untracked files. | +| _unbound_ | `rgitui::ResetHard` | Reset the working tree and index to HEAD. | +| _unbound_ | `rgitui::AbortOperation` | Abort the merge, rebase, cherry-pick or revert in progress. | +| _unbound_ | `rgitui::ContinueMerge` | Continue the merge, rebase, cherry-pick or revert in progress. | +| _unbound_ | `rgitui::ToggleDiffMode` | Switch the diff viewer between unified and side-by-side. | +| _unbound_ | `rgitui::Search` | Search the commit graph. | +| `secondary-g` | `rgitui::AiMessage` | Generate a commit message with the configured AI provider. | +| `f5` | `rgitui::Refresh` | Reload the repository state from disk. | +| `secondary-,` | `rgitui::Settings` | Open the settings window. | +| `secondary-o` | `rgitui::OpenRepo` | Open the repository picker. | +| `secondary-h` | `rgitui::WorkspaceHome` | Close every tab and return to the workspace home screen. | +| _unbound_ | `rgitui::RestoreLastWorkspace` | Reopen the most recently saved workspace. | +| `?` | `rgitui::Shortcuts` | Show the keyboard shortcut reference. | +| `secondary-shift-b` | `rgitui::SwitchBranch` | Focus the sidebar to switch branches. | +| _unbound_ | `rgitui::Blame` | Blame the selected file. | +| _unbound_ | `rgitui::Undo` | Undo the last git operation. | +| _unbound_ | `rgitui::FileHistory` | Show the commit history of the selected file. | +| _unbound_ | `rgitui::Reflog` | Show the reflog. | +| _unbound_ | `rgitui::Submodules` | Show the submodule list. | +| _unbound_ | `rgitui::Bisect` | Show the bisect log. | +| _unbound_ | `rgitui::BisectStart` | Start a bisect session. | +| _unbound_ | `rgitui::BisectGood` | Mark the current bisect commit as good. | +| _unbound_ | `rgitui::BisectBad` | Mark the current bisect commit as bad. | +| _unbound_ | `rgitui::BisectReset` | End the bisect session and restore HEAD. | +| _unbound_ | `rgitui::BisectSkip` | Skip the current bisect commit. | +| _unbound_ | `rgitui::GlobalSearch` | Search the contents of the working tree. | +| `alt-5` | `rgitui::ToggleIssues` | Toggle the issues panel. | +| `alt-6` | `rgitui::TogglePullRequests` | Toggle the pull requests panel. | +| `alt-7` | `rgitui::ToggleBranchHealth` | Toggle the branch health panel. | +| `alt-8` | `rgitui::ToggleStashes` | Toggle the stashes panel. | +| _unbound_ | `rgitui::StashBranch` | Create a branch from a stash entry. | +| `secondary-shift-t` or `alt-9` | `rgitui::OpenThemeEditor` | Open the theme editor. | +| `secondary-shift-p` | `rgitui::CommandPalette` | Toggle the command palette. | +| `secondary-tab` | `rgitui::NextTab` | Activate the next repository tab. | +| `secondary-shift-tab` | `rgitui::PrevTab` | Activate the previous repository tab. | +| `secondary-w` | `rgitui::CloseTab` | Close the active repository tab. | + +## Key contexts + +| Context | Set on | +| --- | --- | +| `Workspace` | the workspace root, so it is always in scope | +| `modal` | added to the workspace root while any overlay or dialog is open | +| `TextInput` | any focused text field, so single-key shortcuts do not steal typing | + +Contexts combine with `&&`, `||` and `!`, and `>` matches a descendant. diff --git a/docs/keymap.schema.json b/docs/keymap.schema.json new file mode 100644 index 0000000..3d77d2b --- /dev/null +++ b/docs/keymap.schema.json @@ -0,0 +1,306 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/noahbclarkson/rgitui/main/docs/keymap.schema.json", + "title": "rgitui keymap", + "description": "Generated from the `commands!` declaration in `crates/rgitui_workspace/src/keymap/registry.rs`. Do not edit by hand.", + "type": "array", + "items": { + "$ref": "#/$defs/section" + }, + "$defs": { + "section": { + "type": "object", + "additionalProperties": false, + "properties": { + "context": { + "type": "string", + "description": "When these bindings are active, e.g. `Workspace && !modal`. Combine identifiers with `&&`, `||` and `!`; `>` matches a descendant. Omit to bind everywhere." + }, + "use_key_equivalents": { + "type": "boolean", + "description": "Interpret keystrokes by their position on a QWERTY keyboard. macOS only.", + "default": false + }, + "bindings": { + "type": "object", + "description": "Keystrokes to actions. A keystroke is modifiers then a key joined by `-` (`secondary-shift-p`); separate the keystrokes of a chord with spaces (`ctrl-k ctrl-o`). Later entries win.", + "additionalProperties": { + "$ref": "#/$defs/action" + } + } + } + }, + "action": { + "description": "An action name, a two-element `[name, input]` array, or `null` to unbind.", + "oneOf": [ + { + "$ref": "#/$defs/actionName" + }, + { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [ + { + "$ref": "#/$defs/actionName" + }, + true + ] + }, + { + "type": "null", + "description": "Remove this binding." + } + ] + }, + "actionName": { + "type": "string", + "anyOf": [ + { + "const": "zed::NoAction", + "description": "Remove the binding this keystroke would otherwise have." + }, + { + "const": "rgitui::Fetch", + "description": "Download objects and refs from the tracked remote. Command id: `fetch`." + }, + { + "const": "rgitui::Pull", + "description": "Fetch from and integrate with the tracked remote branch. Command id: `pull`." + }, + { + "const": "rgitui::Push", + "description": "Update the remote ref along with associated objects. Command id: `push`." + }, + { + "const": "rgitui::PushAll", + "description": "Push every open repository. Command id: `push_all`." + }, + { + "const": "rgitui::PullAll", + "description": "Pull every open repository. Command id: `pull_all`." + }, + { + "const": "rgitui::ForcePush", + "description": "Overwrite the remote branch with the local one. Command id: `force_push`." + }, + { + "const": "rgitui::Commit", + "description": "Commit the staged changes using the message in the commit panel. Command id: `commit`." + }, + { + "const": "rgitui::StageAll", + "description": "Stage every change in the working tree. Command id: `stage_all`." + }, + { + "const": "rgitui::UnstageAll", + "description": "Unstage everything currently staged. Command id: `unstage_all`." + }, + { + "const": "rgitui::StashSave", + "description": "Stash the working tree and index. Command id: `stash_save`." + }, + { + "const": "rgitui::StashPop", + "description": "Apply the latest stash entry and drop it. Command id: `stash_pop`." + }, + { + "const": "rgitui::StashApply", + "description": "Apply the latest stash entry and keep it. Command id: `stash_apply`." + }, + { + "const": "rgitui::StashDrop", + "description": "Delete a stash entry. Command id: `stash_drop`." + }, + { + "const": "rgitui::CreateBranch", + "description": "Create a new branch. Command id: `create_branch`." + }, + { + "const": "rgitui::DeleteBranch", + "description": "Delete a branch. Command id: `delete_branch`." + }, + { + "const": "rgitui::RenameBranch", + "description": "Rename a branch. Command id: `rename_branch`." + }, + { + "const": "rgitui::MergeBranch", + "description": "Merge another branch into the current one. Command id: `merge_branch`." + }, + { + "const": "rgitui::CreateTag", + "description": "Create a tag. Command id: `create_tag`." + }, + { + "const": "rgitui::CreateWorktree", + "description": "Create a linked worktree. Command id: `create_worktree`." + }, + { + "const": "rgitui::CreatePr", + "description": "Open a pull request for the current branch. Command id: `create_pr`." + }, + { + "const": "rgitui::CherryPick", + "description": "Cherry-pick a commit onto the current branch. Command id: `cherry_pick`." + }, + { + "const": "rgitui::RevertCommit", + "description": "Revert a commit. Command id: `revert_commit`." + }, + { + "const": "rgitui::InteractiveRebase", + "description": "Start an interactive rebase. Command id: `interactive_rebase`." + }, + { + "const": "rgitui::DiscardAll", + "description": "Discard every uncommitted change. Command id: `discard_all`." + }, + { + "const": "rgitui::CleanUntracked", + "description": "Delete untracked files. Command id: `clean_untracked`." + }, + { + "const": "rgitui::ResetHard", + "description": "Reset the working tree and index to HEAD. Command id: `reset_hard`." + }, + { + "const": "rgitui::AbortOperation", + "description": "Abort the merge, rebase, cherry-pick or revert in progress. Command id: `abort_operation`." + }, + { + "const": "rgitui::ContinueMerge", + "description": "Continue the merge, rebase, cherry-pick or revert in progress. Command id: `continue_merge`." + }, + { + "const": "rgitui::ToggleDiffMode", + "description": "Switch the diff viewer between unified and side-by-side. Command id: `toggle_diff_mode`." + }, + { + "const": "rgitui::Search", + "description": "Search the commit graph. Command id: `search`." + }, + { + "const": "rgitui::AiMessage", + "description": "Generate a commit message with the configured AI provider. Command id: `ai_message`." + }, + { + "const": "rgitui::Refresh", + "description": "Reload the repository state from disk. Command id: `refresh`." + }, + { + "const": "rgitui::Settings", + "description": "Open the settings window. Command id: `settings`." + }, + { + "const": "rgitui::OpenRepo", + "description": "Open the repository picker. Command id: `open_repo`." + }, + { + "const": "rgitui::WorkspaceHome", + "description": "Close every tab and return to the workspace home screen. Command id: `workspace_home`." + }, + { + "const": "rgitui::RestoreLastWorkspace", + "description": "Reopen the most recently saved workspace. Command id: `restore_last_workspace`." + }, + { + "const": "rgitui::Shortcuts", + "description": "Show the keyboard shortcut reference. Command id: `shortcuts`." + }, + { + "const": "rgitui::SwitchBranch", + "description": "Focus the sidebar to switch branches. Command id: `switch_branch`." + }, + { + "const": "rgitui::Blame", + "description": "Blame the selected file. Command id: `blame`." + }, + { + "const": "rgitui::Undo", + "description": "Undo the last git operation. Command id: `undo`." + }, + { + "const": "rgitui::FileHistory", + "description": "Show the commit history of the selected file. Command id: `file_history`." + }, + { + "const": "rgitui::Reflog", + "description": "Show the reflog. Command id: `reflog`." + }, + { + "const": "rgitui::Submodules", + "description": "Show the submodule list. Command id: `submodules`." + }, + { + "const": "rgitui::Bisect", + "description": "Show the bisect log. Command id: `bisect`." + }, + { + "const": "rgitui::BisectStart", + "description": "Start a bisect session. Command id: `bisect_start`." + }, + { + "const": "rgitui::BisectGood", + "description": "Mark the current bisect commit as good. Command id: `bisect_good`." + }, + { + "const": "rgitui::BisectBad", + "description": "Mark the current bisect commit as bad. Command id: `bisect_bad`." + }, + { + "const": "rgitui::BisectReset", + "description": "End the bisect session and restore HEAD. Command id: `bisect_reset`." + }, + { + "const": "rgitui::BisectSkip", + "description": "Skip the current bisect commit. Command id: `bisect_skip`." + }, + { + "const": "rgitui::GlobalSearch", + "description": "Search the contents of the working tree. Command id: `global_search`." + }, + { + "const": "rgitui::ToggleIssues", + "description": "Toggle the issues panel. Command id: `toggle_issues`." + }, + { + "const": "rgitui::TogglePullRequests", + "description": "Toggle the pull requests panel. Command id: `toggle_pull_requests`." + }, + { + "const": "rgitui::ToggleBranchHealth", + "description": "Toggle the branch health panel. Command id: `toggle_branch_health`." + }, + { + "const": "rgitui::ToggleStashes", + "description": "Toggle the stashes panel. Command id: `toggle_stashes`." + }, + { + "const": "rgitui::StashBranch", + "description": "Create a branch from a stash entry. Command id: `stash_branch`." + }, + { + "const": "rgitui::OpenThemeEditor", + "description": "Open the theme editor. Command id: `open_theme_editor`." + }, + { + "const": "rgitui::CommandPalette", + "description": "Toggle the command palette. Command id: `command_palette`." + }, + { + "const": "rgitui::NextTab", + "description": "Activate the next repository tab. Command id: `next_tab`." + }, + { + "const": "rgitui::PrevTab", + "description": "Activate the previous repository tab. Command id: `prev_tab`." + }, + { + "const": "rgitui::CloseTab", + "description": "Close the active repository tab. Command id: `close_tab`." + } + ] + } + } +} From 11bc62f950a4655126572dcc649174792f7c2083 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:25:48 +0100 Subject: [PATCH 04/16] refactor(keymap): dispatch global shortcuts as rebindable actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind the workspace's global shortcuts through gpui actions instead of the `if key == ... && modifiers...` chain in `key_handler.rs`, so `keymap.json` can rebind every one of them. The workspace root gains a `Workspace` key context and one macro-generated `on_action` handler per command, all funnelling into `dispatch_command`. Handlers land on the root, which is in the dispatch path of whatever child holds focus, so the shortcuts keep working from any panel. `!modal` replaces the `any_overlay_active` gate for these bindings. The root adds `modal` to its own key context while an overlay is open, rather than relying on the overlays' contexts — a dialog that takes no focus would otherwise not appear in the focused element's context chain, and the gate has to hold in that case too. `TextInput` now sets a key context so `!TextInput` can exclude text fields. The `?` shortcut uses it, which also fixes typing `?` into a text field both inserting the character and opening the shortcuts help. `dispatch_command` handles the five commands that need a `Window` — toggling the palette, settings, repo opener and shortcuts help all save focus first, and switching branches focuses the sidebar — preserving what `key_handler.rs` did rather than what `execute_command` does for the same `CommandId` from the palette. Removed from `key_handler.rs`: theme editor, fetch, AI message, palette, settings, refresh, repo opener, shortcuts help, stage/unstage all, create and switch branch, commit, stash save/pop, tab next/prev/close, workspace home, and the Alt+5-8 panel toggles. Its Esc cascade, `any_overlay_active` gate, graph and global search toggles, j/k/d/b/y/C/h bare keys, panel resize and Alt+1-4 panel focus stay for phase B. Co-Authored-By: Claude Fable 5 --- crates/rgitui_ui/src/text_input.rs | 3 + .../src/workspace/commands.rs | 49 +++- .../src/workspace/key_handler.rs | 219 +++--------------- .../rgitui_workspace/src/workspace/layout.rs | 69 ++++-- 4 files changed, 139 insertions(+), 201 deletions(-) diff --git a/crates/rgitui_ui/src/text_input.rs b/crates/rgitui_ui/src/text_input.rs index adfdcf8..b2e1ea9 100644 --- a/crates/rgitui_ui/src/text_input.rs +++ b/crates/rgitui_ui/src/text_input.rs @@ -602,6 +602,9 @@ impl Render for TextInput { } else { "native-text-input" }) + // Lets keybindings exclude text fields with `!TextInput`, so an + // unmodified single-key shortcut cannot steal a typed character. + .key_context("TextInput") .track_focus(&self.focus_handle) .on_key_down(cx.listener(Self::handle_key_down)) .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) diff --git a/crates/rgitui_workspace/src/workspace/commands.rs b/crates/rgitui_workspace/src/workspace/commands.rs index 2d8d15a..e4500d4 100644 --- a/crates/rgitui_workspace/src/workspace/commands.rs +++ b/crates/rgitui_workspace/src/workspace/commands.rs @@ -1,13 +1,56 @@ -use gpui::Context; +use gpui::{Context, Window}; use crate::{CommandId, CommitPanelEvent, ConfirmAction, ToastKind}; use super::{ - BottomPanelMode, ProjectTab, RightPanelMode, ViewCacheEntry, ViewCacheKey, ViewCaches, - Workspace, + BottomPanelMode, FocusedPanel, ProjectTab, RightPanelMode, ViewCacheEntry, ViewCacheKey, + ViewCaches, Workspace, }; impl Workspace { + /// Entry point for keyboard-invoked commands. + /// + /// The generated `on_action` handlers (see [`crate::keymap::attach_actions`]) + /// all land here. Commands that need a [`Window`] — to move focus or to + /// focus an overlay's input — are handled directly; everything else goes to + /// [`Self::execute_command`], which is also what the command palette calls. + pub(super) fn dispatch_command( + &mut self, + cmd: CommandId, + window: &mut Window, + cx: &mut Context, + ) { + match cmd { + CommandId::CommandPalette => { + self.save_focus(window, cx); + self.overlays.command_palette.update(cx, |palette, cx| { + palette.toggle(window, cx); + }); + } + CommandId::Settings => { + self.save_focus(window, cx); + self.open_or_focus_settings(cx); + } + CommandId::OpenRepo => { + self.save_focus(window, cx); + self.overlays.repo_opener.update(cx, |opener, cx| { + opener.toggle(window, cx); + }); + } + CommandId::Shortcuts => { + self.save_focus(window, cx); + self.overlays.shortcuts_help.update(cx, |help, cx| { + help.toggle(window, cx); + }); + } + // Switching branches means focusing the sidebar's branch list. + CommandId::SwitchBranch => { + self.focus_panel(FocusedPanel::Sidebar, window, cx); + } + cmd => self.execute_command(cmd, cx), + } + } + pub(super) fn execute_command(&mut self, cmd: CommandId, cx: &mut Context) { match cmd { CommandId::Settings => { diff --git a/crates/rgitui_workspace/src/workspace/key_handler.rs b/crates/rgitui_workspace/src/workspace/key_handler.rs index 3ecc969..78c5461 100644 --- a/crates/rgitui_workspace/src/workspace/key_handler.rs +++ b/crates/rgitui_workspace/src/workspace/key_handler.rs @@ -13,6 +13,11 @@ //! //! When introducing a new dialog or overlay, decide which window it lives //! in and add Esc dismissal to that window's handler. +//! +//! The workspace's *global* shortcuts no longer live here: they are declared by +//! `commands!` in [`crate::keymap::registry`] and dispatched as gpui actions, so +//! users can rebind them from `keymap.json`. What is left is the Esc cascade and +//! the view-local and panel-focus shortcuts, which Phase B will migrate too. use gpui::{ClipboardItem, Context, KeyDownEvent, Window}; @@ -21,6 +26,26 @@ use crate::{CommandId, ToastKind}; use super::{BottomPanelMode, FocusedPanel, Workspace}; impl Workspace { + /// Whether any overlay or dialog that suppresses panel shortcuts is open. + /// + /// Also drives the `modal` key context on the workspace root, which is how + /// `Workspace && !modal` keeps rebindable global shortcuts from firing while + /// a modal is up. + pub(super) fn any_overlay_active(&self, cx: &Context) -> bool { + self.overlays.command_palette.read(cx).is_visible() + || self.overlays.interactive_rebase.read(cx).is_visible() + || self.overlays.theme_editor.read(cx).is_visible() + || self.dialogs.branch_dialog.read(cx).is_visible() + || self.dialogs.tag_dialog.read(cx).is_visible() + || self.dialogs.worktree_dialog.read(cx).is_visible() + || self.dialogs.rename_dialog.read(cx).is_visible() + || self.overlays.repo_opener.read(cx).is_visible() + || self.dialogs.confirm_dialog.read(cx).is_visible() + || self.dialogs.stash_branch_dialog.read(cx).is_visible() + || self.overlays.global_search.read(cx).is_visible() + || self.overlays.shortcuts_help.read(cx).is_visible() + } + pub(super) fn handle_key_down( &mut self, event: &KeyDownEvent, @@ -121,34 +146,15 @@ impl Workspace { return; } - // Ctrl+Shift+T to open theme editor (Ctrl+9 as alternative) - if modifiers.secondary() && modifiers.shift && key == "t" { - self.execute_command(CommandId::OpenThemeEditor, cx); - return; - } - if modifiers.alt && !modifiers.secondary() && key == "9" { - self.execute_command(CommandId::OpenThemeEditor, cx); - return; - } - - // When an overlay is active, only allow modal toggle shortcuts (below) and Escape (above). - // Block all panel-specific shortcuts (j/k, Alt+1/2/3/4, Tab, resize, etc.) - // TODO(audit): QUAL-10 — this hand-rolled dispatcher (plus the ~30 per-view - // on_key_down string-matchers) should migrate to GPUI actions!/KeyBinding with a - // data-driven, user-rebindable keymap, letting the focus/key_context tree resolve - // overlay precedence instead of this manual `any_overlay_active` gate. - let any_overlay_active = self.overlays.command_palette.read(cx).is_visible() - || self.overlays.interactive_rebase.read(cx).is_visible() - || self.overlays.theme_editor.read(cx).is_visible() - || self.dialogs.branch_dialog.read(cx).is_visible() - || self.dialogs.tag_dialog.read(cx).is_visible() - || self.dialogs.worktree_dialog.read(cx).is_visible() - || self.dialogs.rename_dialog.read(cx).is_visible() - || self.overlays.repo_opener.read(cx).is_visible() - || self.dialogs.confirm_dialog.read(cx).is_visible() - || self.dialogs.stash_branch_dialog.read(cx).is_visible() - || self.overlays.global_search.read(cx).is_visible() - || self.overlays.shortcuts_help.read(cx).is_visible(); + // When an overlay is active, only allow Escape (above) plus the + // rebindable global actions, whose `!modal` context handles this for + // them. Block all panel-specific shortcuts (j/k, Alt+1/2/3/4, Tab, + // resize, etc.) + // TODO(audit): QUAL-10 (phase B) — the remaining per-view + // on_key_down string-matchers should also migrate to `commands!` + + // KeyBinding, letting the focus/key_context tree resolve overlay + // precedence instead of this manual `any_overlay_active` gate. + let any_overlay_active = self.any_overlay_active(cx); // Ctrl+Shift+F to toggle global search if !any_overlay_active && modifiers.secondary() && modifiers.shift && key == "f" { @@ -168,12 +174,6 @@ impl Workspace { return; } - // Ctrl+Shift+R to fetch - if !any_overlay_active && modifiers.secondary() && modifiers.shift && key == "r" { - self.execute_command(CommandId::Fetch, cx); - return; - } - // Ctrl+F to toggle graph search if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "f" { if let Some(tab) = self.tabs.get(self.active_tab) { @@ -196,52 +196,6 @@ impl Workspace { return; } - // Ctrl+G to generate AI commit message - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "g" { - self.execute_command(CommandId::AiMessage, cx); - return; - } - - // Ctrl+Shift+P or Cmd+Shift+P to open command palette - if modifiers.secondary() && modifiers.shift && key == "p" { - self.save_focus(window, cx); - self.overlays.command_palette.update(cx, |cp, cx| { - cp.toggle(window, cx); - }); - return; - } - - // Ctrl+, to open settings - if modifiers.secondary() && key == "," { - self.save_focus(window, cx); - self.open_or_focus_settings(cx); - return; - } - - // F5 to refresh - if !any_overlay_active && key == "f5" { - self.execute_command(CommandId::Refresh, cx); - } - - // Ctrl+O to open repo opener - if modifiers.secondary() && key == "o" { - self.save_focus(window, cx); - self.overlays.repo_opener.update(cx, |ro, cx| { - ro.toggle(window, cx); - }); - return; - } - - // ? to toggle shortcuts help (without modifiers) — works even when - // command palette is open, since the palette shows '?' as a hint. - if key == "?" && !modifiers.control && !modifiers.platform && !modifiers.alt { - self.save_focus(window, cx); - self.overlays.shortcuts_help.update(cx, |sh, cx| { - sh.toggle(window, cx); - }); - return; - } - // j/k vim-style navigation in the commit graph (skip when graph or detail panel // is focused, since they handle their own j/k to avoid double-movement) if !any_overlay_active @@ -435,95 +389,6 @@ impl Workspace { } } - // Ctrl+S to stage all - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "s" { - self.execute_command(CommandId::StageAll, cx); - return; - } - - // Ctrl+Shift+S to unstage all - if !any_overlay_active && modifiers.secondary() && modifiers.shift && key == "s" { - self.execute_command(CommandId::UnstageAll, cx); - return; - } - - // Ctrl+U to unstage all (alternative) - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "u" { - self.execute_command(CommandId::UnstageAll, cx); - return; - } - - // Ctrl+B to create branch - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "b" { - self.execute_command(CommandId::CreateBranch, cx); - return; - } - - // Ctrl+Shift+B to switch branch (focus sidebar for branch navigation) - if !any_overlay_active && modifiers.secondary() && modifiers.shift && key == "b" { - self.focus_panel(FocusedPanel::Sidebar, window, cx); - return; - } - - // Ctrl+Enter to commit - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "enter" { - self.execute_command(CommandId::Commit, cx); - return; - } - - // Ctrl+Z to stash save - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "z" { - self.execute_command(CommandId::StashSave, cx); - return; - } - - // Ctrl+Shift+Z to stash pop - if !any_overlay_active && modifiers.secondary() && modifiers.shift && key == "z" { - self.execute_command(CommandId::StashPop, cx); - return; - } - - // Ctrl+Tab to switch to next tab. Deliberately not the primary - // modifier: macOS reserves Cmd+Tab for the application switcher, so the - // WindowServer swallows it before the app sees it. Ctrl+Tab is also the - // native tab-cycling chord there. - if !any_overlay_active && modifiers.control && !modifiers.shift && key == "tab" { - if !self.tabs.is_empty() { - self.active_tab = (self.active_tab + 1) % self.tabs.len(); - cx.notify(); - } - return; - } - - // Ctrl+Shift+Tab to switch to previous tab. Ctrl for the same reason as - // Ctrl+Tab above. - if !any_overlay_active && modifiers.control && modifiers.shift && key == "tab" { - if !self.tabs.is_empty() { - if self.active_tab == 0 { - self.active_tab = self.tabs.len() - 1; - } else { - self.active_tab -= 1; - } - cx.notify(); - } - return; - } - - // Ctrl+W to close current tab - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "w" { - if !self.tabs.is_empty() { - self.close_tab(self.active_tab, cx); - } - return; - } - - // Ctrl+H to return to workspace home. Deliberately not the primary - // modifier: macOS reserves Cmd+H for Hide Application. - if !any_overlay_active && modifiers.control && !modifiers.shift && key == "h" { - self.go_home(cx); - return; - } - // Alt+1/2/3/4 to focus sidebar/graph/detail/diff panel if !any_overlay_active && modifiers.alt && !modifiers.secondary() { match key { @@ -543,22 +408,6 @@ impl Workspace { self.focus_panel(FocusedPanel::DiffViewer, window, cx); return; } - "5" => { - self.execute_command(CommandId::ToggleIssues, cx); - return; - } - "6" => { - self.execute_command(CommandId::TogglePullRequests, cx); - return; - } - "7" => { - self.execute_command(CommandId::ToggleBranchHealth, cx); - return; - } - "8" => { - self.execute_command(CommandId::ToggleStashes, cx); - return; - } _ => {} } } diff --git a/crates/rgitui_workspace/src/workspace/layout.rs b/crates/rgitui_workspace/src/workspace/layout.rs index 80a3b2e..21cf183 100644 --- a/crates/rgitui_workspace/src/workspace/layout.rs +++ b/crates/rgitui_workspace/src/workspace/layout.rs @@ -10,6 +10,7 @@ use rgitui_ui::{ Spinner, SpinnerSize, Tab, TabBar, Tooltip, }; +use crate::keymap; use crate::{CommandId, StatusBar, TitleBar, ToastKind}; use super::{ @@ -17,6 +18,18 @@ use super::{ SidebarResize, ViewAvailability, Workspace, }; +/// Key context identifier for the workspace root. +/// +/// Global bindings are scoped to it (see `commands!` in +/// [`crate::keymap::registry`]); `WORKSPACE_MODAL_KEY_CONTEXT` adds `modal` so +/// `Workspace && !modal` bindings stand down while an overlay is up. +const WORKSPACE_KEY_CONTEXT: &str = "Workspace"; +/// Key context for the workspace root while an overlay or dialog is open. +const WORKSPACE_MODAL_KEY_CONTEXT: &str = "Workspace modal"; + +/// The `view` name whose `commands!` block the workspace root binds. +const WORKSPACE_VIEW: &str = "Workspace"; + /// Resize bounds for the right detail panel, shared by the drag handle and the /// Ctrl+[ / Ctrl+] keyboard shortcuts so both input paths clamp identically. pub(super) const MIN_DETAIL_PANEL_WIDTH: f32 = 180.0; @@ -57,6 +70,44 @@ impl Workspace { let clamped = font_size.clamp(MIN_UI_FONT_SIZE, MAX_UI_FONT_SIZE); px(clamped as f32 * BASELINE_REM_SIZE / DEFAULT_UI_FONT_SIZE as f32) } + + /// Key context for the workspace root element. + /// + /// `modal` is added while any overlay or dialog is open. Deriving it here + /// rather than from the overlays' own contexts means `!modal` holds even for + /// dialogs that do not take focus, matching the old `any_overlay_active` + /// gate exactly. + fn workspace_key_context(&self, cx: &Context) -> &'static str { + if self.any_overlay_active(cx) { + WORKSPACE_MODAL_KEY_CONTEXT + } else { + WORKSPACE_KEY_CONTEXT + } + } + + /// The workspace root element, carrying the `Workspace` key context and one + /// `on_action` handler per `Workspace` command declared by `commands!`. + /// + /// Being on the root means the handlers are in the dispatch path of whatever + /// child holds focus, so a global shortcut works from any panel. + fn workspace_root( + &self, + cx: &mut Context, + ui_font: gpui::Font, + background: gpui::Hsla, + ) -> gpui::Stateful { + let root = div() + .id("workspace-root") + .key_context(self.workspace_key_context(cx)) + .size_full() + .font(ui_font) + .bg(background) + .on_key_down(cx.listener(Self::handle_key_down)); + + keymap::attach_actions(root, WORKSPACE_VIEW, cx, |workspace, cmd, window, cx| { + workspace.dispatch_command(cmd, window, cx); + }) + } } impl Render for Workspace { @@ -113,12 +164,8 @@ impl Render for Workspace { // If no tabs, show welcome screen if self.tabs.is_empty() { - return div() - .id("workspace-root") - .size_full() - .font(ui_font.clone()) - .bg(colors.background) - .on_key_down(cx.listener(Self::handle_key_down)) + let root = self.workspace_root(cx, ui_font.clone(), colors.background); + return root .child(self.render_welcome_interactive(cx)) .child(self.toast_layer.clone()) .child(self.overlays.command_palette.clone()) @@ -406,13 +453,9 @@ impl Render for Workspace { let operation_output_bar = self.render_operation_output_bar(cx); let update_banner = self.render_update_banner(cx); - div() - .id("workspace-root") - .v_flex() - .size_full() - .font(ui_font) - .bg(colors.background) - .on_key_down(cx.listener(Self::handle_key_down)) + let root = self.workspace_root(cx, ui_font, colors.background).v_flex(); + + root // Title bar .child({ let sidebar = active_tab.sidebar.clone(); From 214a98c16509ec05b0c3a6348cc3feaaa74c3256 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:36:15 +0100 Subject: [PATCH 05/16] refactor(keymap): dispatch panel shortcuts as rebindable actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A moved the 25 global shortcuts off `Workspace::handle_key_down`. This does the same for the list and panel views, and gives `commands!` the one thing the view blocks needed to express themselves. The macro now lets a single command bind keystrokes in *different* key contexts (`SelectNext ["down", "j" in "List && !TextInput"]`). That is not a convenience: gpui evaluates `!TextInput` false whenever a text field is anywhere on the focus path, so a vim-style letter must carry it while its arrow-key twin must not — otherwise `down` would stop moving the command palette's selection the moment the query field took focus. `CommandMeta` therefore carries `default_bindings: &[(keystrokes, context)]` in place of the old keystroke list plus single context, and `docs/KEYBINDINGS.md` grows a Context column. Six shared `menu` commands — Cancel, Confirm and the four selection moves — are bound once against a `List` key context that every view owning a row selection now sets alongside its own name. gpui dispatches an action outwards from the focused element and the first listener consumes it, so the panel that owns the selection is the one that moves; a view that does not want a command calls `cx.propagate()` and it carries on outwards, ending at the focused text field. That is what lets Enter mean "activate this row" in the sidebar and "submit" in a picker with one binding, and it removes the `is_searching` / `panel_has_focus` guards the old handlers needed. `GraphView` and `DiffViewer` live in crates that cannot depend on `rgitui_workspace` and so cannot name the generated actions. Rather than invert the dependency, their commands are declared here in the `graph` and `diff` namespaces and handled on the workspace root, which is an ancestor of both on every dispatch path; the views themselves only declare their key context, and the bindings still fire only while those panels hold focus. They keep their own navigation commands rather than joining the `List` group, because a shared `menu` command has to be handled by the element that owns the selection and neither of them can handle anything. Behaviour changes a user will notice: * The duplicate global bindings for `d`, `b`, `h` and `y` are gone, along with the `!panel_has_focus` gate on `j`/`k`. Those keys now act only on the panel that has focus — `y`/`shift-c` in the graph, `d` in the diff viewer or to leave blame, `h`/`b` to cross between blame and file history. `rgitui::Blame` and `rgitui::FileHistory` are reachable from the command palette instead of from a bare letter anywhere in the window. * Space now activates the selected sidebar row. The old handler matched `" "`, but gpui names that key `space`, so it never fired. * `secondary-f`, `/`, `secondary-shift-f`, `alt-1`–`alt-4`, `tab`, `secondary-[`/`]` and `secondary-up`/`down` are declared commands and so are rebindable from `keymap.json` like everything else. * `CommandId::display_label` is derived from the command id rather than a hand-written table, so a new command cannot be added without one. Co-Authored-By: Claude Fable 5 --- crates/rgitui_diff/src/lib.rs | 440 +++++++++--------- crates/rgitui_graph/src/lib.rs | 189 ++++---- crates/rgitui_workspace/src/bisect_view.rs | 108 ++--- crates/rgitui_workspace/src/blame_view.rs | 120 ++--- .../rgitui_workspace/src/command_palette.rs | 152 ++---- crates/rgitui_workspace/src/commit_panel.rs | 1 - .../rgitui_workspace/src/file_history_view.rs | 120 ++--- crates/rgitui_workspace/src/issues_panel.rs | 84 ++-- .../rgitui_workspace/src/keymap/generate.rs | 76 ++- crates/rgitui_workspace/src/keymap/loader.rs | 4 +- crates/rgitui_workspace/src/keymap/macros.rs | 72 ++- crates/rgitui_workspace/src/keymap/mod.rs | 30 ++ .../rgitui_workspace/src/keymap/registry.rs | 364 ++++++++++++++- crates/rgitui_workspace/src/prs_panel.rs | 71 ++- crates/rgitui_workspace/src/reflog_view.rs | 112 ++--- crates/rgitui_workspace/src/search_panel.rs | 124 +++-- crates/rgitui_workspace/src/sidebar.rs | 227 ++++----- crates/rgitui_workspace/src/submodule_view.rs | 94 ++-- .../src/workspace/commands.rs | 209 ++++++++- .../src/workspace/key_handler.rs | 425 ++--------------- .../rgitui_workspace/src/workspace/layout.rs | 26 +- docs/KEYBINDINGS.md | 241 +++++++--- docs/keymap.schema.json | 232 +++++++++ 23 files changed, 1979 insertions(+), 1542 deletions(-) diff --git a/crates/rgitui_diff/src/lib.rs b/crates/rgitui_diff/src/lib.rs index c1bcaf9..3b34925 100644 --- a/crates/rgitui_diff/src/lib.rs +++ b/crates/rgitui_diff/src/lib.rs @@ -9,9 +9,9 @@ use similar::{capture_diff_slices, Algorithm}; use gpui::prelude::*; use gpui::{ div, list, px, uniform_list, AnyElement, App, ClickEvent, ClipboardItem, Context, CursorStyle, - ElementId, EventEmitter, FocusHandle, FontStyle, FontWeight, HighlightStyle, KeyDownEvent, - ListAlignment, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Render, ScrollStrategy, SharedString, StyledText, + ElementId, EventEmitter, FocusHandle, FontStyle, FontWeight, HighlightStyle, ListAlignment, + ListHorizontalSizingBehavior, ListSizingBehavior, ListState, MouseButton, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, Render, ScrollStrategy, SharedString, StyledText, UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::{DiffLine, FileDiff, ThreeWayFileDiff}; @@ -1124,238 +1124,224 @@ impl DiffViewer { } } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - let primary = event.keystroke.modifiers.secondary(); + /// Moves the diff cursor down one row, scrolling it into view. + /// + /// The workspace drives this and the methods below from the `diff::*` + /// actions: this crate sits below `rgitui_workspace` in the dependency graph + /// and so cannot name them. + pub fn select_next_row(&mut self, cx: &mut Context) { let row_count = self.row_count(); - if row_count == 0 { return; } + let next = match self.highlighted_row { + Some(i) if i + 1 < row_count => i + 1, + None => 0, + Some(i) => i, + }; + self.highlight_row(next, cx); + } - match key { - "j" | "down" if !primary => { - let next = match self.highlighted_row { - Some(i) if i + 1 < row_count => i + 1, - None => 0, - Some(i) => i, - }; - self.highlighted_row = Some(next); - self.scroll_row_into_view(next, cx); - cx.notify(); - } - "k" | "up" if !primary => { - let next = match self.highlighted_row { - Some(i) if i > 0 => i - 1, - None if row_count > 0 => 0, - Some(i) => i, - None => 0, - }; - self.highlighted_row = Some(next); - self.scroll_row_into_view(next, cx); - cx.notify(); - } - "g" if !primary && !event.keystroke.modifiers.shift => { - self.highlighted_row = Some(0); - self.scroll_row_into_view(0, cx); - cx.notify(); - } - "g" if event.keystroke.modifiers.shift => { - let last = row_count.saturating_sub(1); - self.highlighted_row = Some(last); - self.scroll_row_into_view(last, cx); - cx.notify(); - } - "]" if !primary => { - // Jump to next hunk header after current position - let start = self.highlighted_row.map(|r| r + 1).unwrap_or(0); - let next = match self.display_mode { - DiffDisplayMode::Unified => (start..row_count) - .find(|&i| matches!(self.display_rows[i], DisplayRow::HunkHeader { .. })), - DiffDisplayMode::SideBySide => (start..row_count) - .find(|&i| matches!(self.sbs_rows[i], SideBySideRow::HunkHeader { .. })), - DiffDisplayMode::ThreeWay => (start..row_count).find(|&i| { - matches!(self.three_way_rows[i], ThreeWayRow::HunkHeader { .. }) - }), - } - // Wrap around - .or_else(|| match self.display_mode { - DiffDisplayMode::Unified => (0..start) - .find(|&i| matches!(self.display_rows[i], DisplayRow::HunkHeader { .. })), - DiffDisplayMode::SideBySide => (0..start) - .find(|&i| matches!(self.sbs_rows[i], SideBySideRow::HunkHeader { .. })), - DiffDisplayMode::ThreeWay => (0..start).find(|&i| { - matches!(self.three_way_rows[i], ThreeWayRow::HunkHeader { .. }) - }), - }); - if let Some(pos) = next { - self.highlighted_row = Some(pos); - self.scroll_row_into_view(pos, cx); - cx.notify(); - } - cx.stop_propagation(); - } - "[" if !primary => { - // Jump to previous hunk header before current position - let end = self.highlighted_row.unwrap_or(row_count); - let prev = match self.display_mode { - DiffDisplayMode::Unified => (0..end) - .rev() - .find(|&i| matches!(self.display_rows[i], DisplayRow::HunkHeader { .. })), - DiffDisplayMode::SideBySide => (0..end) - .rev() - .find(|&i| matches!(self.sbs_rows[i], SideBySideRow::HunkHeader { .. })), - DiffDisplayMode::ThreeWay => (0..end).rev().find(|&i| { - matches!(self.three_way_rows[i], ThreeWayRow::HunkHeader { .. }) - }), - } - // Wrap around - .or_else(|| match self.display_mode { - DiffDisplayMode::Unified => (end..row_count) - .rev() - .find(|&i| matches!(self.display_rows[i], DisplayRow::HunkHeader { .. })), - DiffDisplayMode::SideBySide => (end..row_count) - .rev() - .find(|&i| matches!(self.sbs_rows[i], SideBySideRow::HunkHeader { .. })), - DiffDisplayMode::ThreeWay => (end..row_count).rev().find(|&i| { - matches!(self.three_way_rows[i], ThreeWayRow::HunkHeader { .. }) - }), - }); - if let Some(pos) = prev { - self.highlighted_row = Some(pos); - self.scroll_row_into_view(pos, cx); - cx.notify(); - } - cx.stop_propagation(); - } - "d" if !primary => { - self.toggle_display_mode(cx); - } - "c" if primary => { - self.copy_selected_lines(cx); - } - "a" if primary && row_count > 0 => { - self.selection_anchor = Some(0); - self.selected_lines = Some(0..row_count); - cx.notify(); - } - "p" if !primary - && !event.keystroke.modifiers.alt - && !event.keystroke.modifiers.shift => - { - // p: toggle partial line-selection mode. Meaningless for - // committed or stashed content, which cannot be staged at all. - if self.source.is_historical() { - cx.stop_propagation(); - return; - } - self.partial_mode = !self.partial_mode; - if !self.partial_mode { - self.selected_lines = None; - self.selection_anchor = None; - } - cx.notify(); - cx.stop_propagation(); + /// Moves the diff cursor up one row, scrolling it into view. + pub fn select_prev_row(&mut self, cx: &mut Context) { + if self.row_count() == 0 { + return; + } + let next = match self.highlighted_row { + Some(i) if i > 0 => i - 1, + Some(i) => i, + None => 0, + }; + self.highlight_row(next, cx); + } + + /// Moves the diff cursor to the first row. + pub fn select_first_row(&mut self, cx: &mut Context) { + if self.row_count() > 0 { + self.highlight_row(0, cx); + } + } + + /// Moves the diff cursor to the last row. + pub fn select_last_row(&mut self, cx: &mut Context) { + let row_count = self.row_count(); + if row_count > 0 { + self.highlight_row(row_count - 1, cx); + } + } + + fn highlight_row(&mut self, row: usize, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_row_into_view(row, cx); + cx.notify(); + } + + /// Whether the row at `index` in the current display mode is a hunk header. + fn is_hunk_header(&self, index: usize) -> bool { + match self.display_mode { + DiffDisplayMode::Unified => { + matches!(self.display_rows[index], DisplayRow::HunkHeader { .. }) } - "s" | "S" if !event.keystroke.modifiers.alt && !primary => { - // s: stage hunks under selection (or cursor hunk if no selection). - // Only the working tree can be staged; committed and stashed - // content is historical and has nothing to stage. - if self.source.staging_action() == Some(StagingAction::Stage) { - if self.partial_mode { - // Line-level staging: emit the selected change lines (additions - // and deletions). Each pair carries its old/new line number; the - // git layer matches additions on the new side and deletions on the - // old side, so deletions must flow through unfiltered. - let line_pairs = if let Some(sel) = &self.selected_lines { - self.lines_under_selection(sel.clone()) - .into_iter() - .filter(Self::is_change_line) - .collect() - } else { - self.current_hunk_changes() - }; - if !line_pairs.is_empty() { - cx.emit(DiffViewerEvent::LineStageRequested(line_pairs)); - } - // TODO(audit): BUG-15 surface a "no stageable lines" toast when a - // partial selection yields nothing — needs a new DiffViewerEvent - // variant + a handler arm in rgitui_workspace events.rs (the toast - // system lives there), which can't be added from this crate alone. - } else { - let hunks = if let Some(sel) = &self.selected_lines { - self.hunks_under_selection(sel.clone()) - } else { - self.current_hunk_index() - .map(|i| vec![i]) - .unwrap_or_default() - }; - for idx in hunks { - cx.emit(DiffViewerEvent::HunkStageRequested(idx)); - } - } - } - cx.stop_propagation(); + DiffDisplayMode::SideBySide => { + matches!(self.sbs_rows[index], SideBySideRow::HunkHeader { .. }) } - "u" | "U" if !event.keystroke.modifiers.alt && !primary => { - // u: unstage hunks under selection (or cursor hunk if no selection). - // Only the index can be unstaged. - if self.source.staging_action() == Some(StagingAction::Unstage) { - if self.partial_mode { - // Line-level unstaging: emit the selected change lines (additions - // and deletions). Deletions must flow through so the git layer can - // match them on the old side; filtering to additions would make - // unstaging a pure deletion a silent no-op. - let line_pairs = if let Some(sel) = &self.selected_lines { - self.lines_under_selection(sel.clone()) - .into_iter() - .filter(Self::is_change_line) - .collect() - } else { - self.current_hunk_changes() - }; - if !line_pairs.is_empty() { - cx.emit(DiffViewerEvent::LineUnstageRequested(line_pairs)); - } - } else { - let hunks = if let Some(sel) = &self.selected_lines { - self.hunks_under_selection(sel.clone()) - } else { - self.current_hunk_index() - .map(|i| vec![i]) - .unwrap_or_default() - }; - for idx in hunks { - cx.emit(DiffViewerEvent::HunkUnstageRequested(idx)); - } - } - } - cx.stop_propagation(); + DiffDisplayMode::ThreeWay => { + matches!(self.three_way_rows[index], ThreeWayRow::HunkHeader { .. }) } - "s" | "S" if event.keystroke.modifiers.alt && !primary => { - // Alt+S: stage the current hunk - if self.source.staging_action() == Some(StagingAction::Stage) { - if let Some(idx) = self.current_hunk_index() { - cx.emit(DiffViewerEvent::HunkStageRequested(idx)); - } - } - cx.stop_propagation(); + } + } + + /// Moves the cursor to the next hunk header, wrapping at the end. + pub fn select_next_hunk(&mut self, cx: &mut Context) { + let row_count = self.row_count(); + if row_count == 0 { + return; + } + let start = self.highlighted_row.map(|r| r + 1).unwrap_or(0); + let next = (start..row_count) + .find(|&i| self.is_hunk_header(i)) + .or_else(|| (0..start).find(|&i| self.is_hunk_header(i))); + if let Some(pos) = next { + self.highlight_row(pos, cx); + } + } + + /// Moves the cursor to the previous hunk header, wrapping at the start. + pub fn select_prev_hunk(&mut self, cx: &mut Context) { + let row_count = self.row_count(); + if row_count == 0 { + return; + } + let end = self.highlighted_row.unwrap_or(row_count); + let prev = (0..end) + .rev() + .find(|&i| self.is_hunk_header(i)) + .or_else(|| (end..row_count).rev().find(|&i| self.is_hunk_header(i))); + if let Some(pos) = prev { + self.highlight_row(pos, cx); + } + } + + /// Toggles line-level selection, clearing any selection when leaving it. + pub fn toggle_partial_mode(&mut self, cx: &mut Context) { + // Meaningless for committed or stashed content, which cannot be staged + // at all. + if self.source.is_historical() { + return; + } + self.partial_mode = !self.partial_mode; + if !self.partial_mode { + self.selected_lines = None; + self.selection_anchor = None; + } + cx.notify(); + } + + /// Selects every row in the diff. + pub fn select_all_lines(&mut self, cx: &mut Context) { + let row_count = self.row_count(); + if row_count == 0 { + return; + } + self.selection_anchor = Some(0); + self.selected_lines = Some(0..row_count); + cx.notify(); + } + + /// Copies the selected diff lines to the clipboard. + pub fn copy_selection(&self, cx: &mut Context) { + self.copy_selected_lines(cx); + } + + /// Requests staging of the hunks — or, in partial mode, the individual + /// lines — under the current selection, falling back to the cursor's hunk. + pub fn stage_selection(&mut self, cx: &mut Context) { + // Only the working tree can be staged; committed and stashed content is + // historical and has nothing to stage. + if self.source.staging_action() != Some(StagingAction::Stage) { + return; + } + if self.partial_mode { + // Line-level staging: emit the selected change lines (additions and + // deletions). Each pair carries its old/new line number; the git + // layer matches additions on the new side and deletions on the old + // side, so deletions must flow through unfiltered. + let line_pairs = self.change_lines_under_selection(); + if !line_pairs.is_empty() { + cx.emit(DiffViewerEvent::LineStageRequested(line_pairs)); } - "u" | "U" if event.keystroke.modifiers.alt && !primary => { - // Alt+U: unstage the current hunk - if self.source.staging_action() == Some(StagingAction::Unstage) { - if let Some(idx) = self.current_hunk_index() { - cx.emit(DiffViewerEvent::HunkUnstageRequested(idx)); - } - } - cx.stop_propagation(); + // TODO(audit): BUG-15 surface a "no stageable lines" toast when a + // partial selection yields nothing — needs a new DiffViewerEvent + // variant + a handler arm in rgitui_workspace events.rs (the toast + // system lives there), which can't be added from this crate alone. + return; + } + for idx in self.hunks_to_act_on() { + cx.emit(DiffViewerEvent::HunkStageRequested(idx)); + } + } + + /// Requests unstaging of the hunks — or lines — under the current selection. + pub fn unstage_selection(&mut self, cx: &mut Context) { + // Only the index can be unstaged. + if self.source.staging_action() != Some(StagingAction::Unstage) { + return; + } + if self.partial_mode { + // Deletions must flow through so the git layer can match them on the + // old side; filtering to additions would make unstaging a pure + // deletion a silent no-op. + let line_pairs = self.change_lines_under_selection(); + if !line_pairs.is_empty() { + cx.emit(DiffViewerEvent::LineUnstageRequested(line_pairs)); } - _ => {} + return; + } + for idx in self.hunks_to_act_on() { + cx.emit(DiffViewerEvent::HunkUnstageRequested(idx)); + } + } + + /// Requests staging of just the hunk under the cursor. + pub fn stage_current_hunk(&mut self, cx: &mut Context) { + if self.source.staging_action() != Some(StagingAction::Stage) { + return; + } + if let Some(idx) = self.current_hunk_index() { + cx.emit(DiffViewerEvent::HunkStageRequested(idx)); + } + } + + /// Requests unstaging of just the hunk under the cursor. + pub fn unstage_current_hunk(&mut self, cx: &mut Context) { + if self.source.staging_action() != Some(StagingAction::Unstage) { + return; + } + if let Some(idx) = self.current_hunk_index() { + cx.emit(DiffViewerEvent::HunkUnstageRequested(idx)); + } + } + + /// The change lines under the selection, or the cursor hunk's if there is none. + fn change_lines_under_selection(&self) -> Vec<(Option, Option)> { + match &self.selected_lines { + Some(selection) => self + .lines_under_selection(selection.clone()) + .into_iter() + .filter(Self::is_change_line) + .collect(), + None => self.current_hunk_changes(), + } + } + + /// The hunks under the selection, or the cursor's hunk if there is none. + fn hunks_to_act_on(&self) -> Vec { + match &self.selected_lines { + Some(selection) => self.hunks_under_selection(selection.clone()), + None => self + .current_hunk_index() + .map(|i| vec![i]) + .unwrap_or_default(), } } @@ -3875,7 +3861,9 @@ impl Render for DiffViewer { let mut container = div() .id("diff-viewer") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + // Bindings scoped to `DiffViewer` resolve to `diff::*` actions the + // workspace root handles; this crate cannot name them itself. + .key_context("DiffViewer") .on_mouse_up(MouseButton::Left, cx.listener(Self::end_mouse_selection)) .on_mouse_up_out(MouseButton::Left, cx.listener(Self::end_mouse_selection)) .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { diff --git a/crates/rgitui_graph/src/lib.rs b/crates/rgitui_graph/src/lib.rs index c8b8394..f306238 100644 --- a/crates/rgitui_graph/src/lib.rs +++ b/crates/rgitui_graph/src/lib.rs @@ -7,10 +7,9 @@ use std::time::Duration; use gpui::prelude::*; use gpui::{ canvas, div, img, point, px, uniform_list, Animation, AnimationExt, App, Bounds, ClickEvent, - Context, CursorStyle, ElementId, Entity, EventEmitter, FocusHandle, Focusable, KeyDownEvent, - ListSizingBehavior, MouseButton, MouseDownEvent, MouseMoveEvent, ObjectFit, PathBuilder, - Pixels, Point, Render, ScrollStrategy, SharedString, Size, UniformListScrollHandle, WeakEntity, - Window, + Context, CursorStyle, ElementId, Entity, EventEmitter, FocusHandle, ListSizingBehavior, + MouseButton, MouseDownEvent, MouseMoveEvent, ObjectFit, PathBuilder, Pixels, Point, Render, + ScrollStrategy, SharedString, Size, UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::{compute_graph, CommitInfo, FileChangeKind, GraphEdge, GraphRow, RefLabel}; use rgitui_settings::{GraphStyle, SettingsState}; @@ -997,107 +996,90 @@ impl GraphView { } /// Handle key events on the focused search input. - fn handle_graph_key_down( - &mut self, - event: &KeyDownEvent, - window: &mut Window, - cx: &mut Context, - ) { - if self - .search_editor - .read(cx) - .focus_handle(cx) - .is_focused(window) - { - if event.keystroke.key.as_str() == "escape" { - // Cancel any in-progress drag-to-rebase. - self.dragging_oid = None; - self.drag_start_position = None; - self.drag_moved = false; - self.suppress_next_click = false; - self.show_search = false; - self.search_editor - .update(cx, |e: &mut rgitui_ui::TextInput, cx| e.clear(cx)); - self.filter_matches.clear(); - self.filter_match_set_arc = Arc::new(HashSet::new()); - self.current_match = 0; - self.graph_focus.focus(window, cx); - cx.notify(); - } - return; + /// Moves the selection by one row, scrolling it into view. + /// + /// The workspace drives this from the `graph::*` actions: this crate sits + /// below `rgitui_workspace` in the dependency graph and so cannot name them. + pub fn select_next_row(&mut self, cx: &mut Context) { + let total = self.total_list_items(); + let next = match self.selected_index { + Some(index) if index + 1 < total => index + 1, + None if total > 0 => 0, + _ => return, + }; + self.select_row(next, ScrollStrategy::Center, cx); + } + + /// Moves the selection up one row, scrolling it into view. + pub fn select_prev_row(&mut self, cx: &mut Context) { + let next = match self.selected_index { + Some(index) if index > 0 => index - 1, + None if self.total_list_items() > 0 => 0, + _ => return, + }; + self.select_row(next, ScrollStrategy::Center, cx); + } + + /// Selects the newest commit. + pub fn select_first_row(&mut self, cx: &mut Context) { + if self.total_list_items() > 0 { + self.select_row(0, ScrollStrategy::Top, cx); } - let keystroke = &event.keystroke; - let key = keystroke.key.as_str(); - // The platform's primary modifier: Command on macOS, Control elsewhere. - // Treating both as interchangeable made the Windows key act as Control. - let primary = keystroke.modifiers.secondary(); + } + /// Selects the oldest loaded commit. + pub fn select_last_row(&mut self, cx: &mut Context) { let total = self.total_list_items(); - match key { - "j" | "down" if !primary => { - let next = match self.selected_index { - Some(i) if i + 1 < total => i + 1, - None if total > 0 => 0, - _ => return, - }; - self.select_list_index(next, cx); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Center); - } - "k" | "up" if !primary => { - let next = match self.selected_index { - Some(i) if i > 0 => i - 1, - None if total > 0 => 0, - _ => return, - }; - self.select_list_index(next, cx); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Center); - } - "g" if !primary && !keystroke.modifiers.shift - && total > 0 => { - self.select_list_index(0, cx); - self.scroll_handle - .scroll_to_item(0, ScrollStrategy::Top); - } - "g" if keystroke.modifiers.shift - && total > 0 => { - let last = total - 1; - self.select_list_index(last, cx); - self.scroll_handle - .scroll_to_item(last, ScrollStrategy::Center); - } - "end" - if total > 0 => { - let last = total - 1; - self.select_list_index(last, cx); - self.scroll_handle - .scroll_to_item(last, ScrollStrategy::Center); - } - "home" - if total > 0 => { - self.select_list_index(0, cx); - self.scroll_handle - .scroll_to_item(0, ScrollStrategy::Top); - } - "/" if !primary => { - self.show_search = true; - self.search_editor.update(cx, |e: &mut rgitui_ui::TextInput, cx| e.focus(window, cx)); - cx.notify(); - } - "escape" - // Dismiss context menu or deselect - if self.context_menu.is_some() => { - self.dismiss_context_menu(cx); - } - "y" | "Y" if !primary && !keystroke.modifiers.shift => { - // Copy SHA of selected commit (standard GitKraken shortcut) - if let Some(commit) = self.selected_commit() { - let sha = format!("{}", commit.oid); - cx.emit(GraphViewEvent::CopyCommitSha(sha)); - } - } - _ => {} + if total > 0 { + self.select_row(total - 1, ScrollStrategy::Center, cx); + } + } + + fn select_row(&mut self, index: usize, strategy: ScrollStrategy, cx: &mut Context) { + self.select_list_index(index, cx); + self.scroll_handle.scroll_to_item(index, strategy); + } + + /// Closes the search field, or dismisses the context menu. + /// + /// Also cancels an in-progress drag-to-rebase, which a stray Esc must not + /// leave half-applied. + pub fn cancel(&mut self, window: &mut Window, cx: &mut Context) { + self.dragging_oid = None; + self.drag_start_position = None; + self.drag_moved = false; + self.suppress_next_click = false; + + if self.show_search { + self.show_search = false; + self.search_editor + .update(cx, |editor: &mut rgitui_ui::TextInput, cx| editor.clear(cx)); + self.filter_matches.clear(); + self.filter_match_set_arc = Arc::new(HashSet::new()); + self.current_match = 0; + self.graph_focus.focus(window, cx); + cx.notify(); + return; + } + + if self.context_menu.is_some() { + self.dismiss_context_menu(cx); + } + } + + /// Copies the selected commit's SHA to the clipboard. + pub fn copy_selected_sha(&mut self, cx: &mut Context) { + if let Some(commit) = self.selected_commit() { + let sha = commit.oid.to_string(); + cx.emit(GraphViewEvent::CopyCommitSha(sha)); + } + } + + /// Copies the selected commit's message to the clipboard. + pub fn copy_selected_message(&mut self, cx: &mut Context) { + if let Some(commit) = self.selected_commit() { + let message = commit.message.clone(); + cx.emit(GraphViewEvent::CopyCommitMessage(message)); } } } @@ -2216,8 +2198,9 @@ impl Render for GraphView { let mut container = div() .id("graph-view") .track_focus(&self.graph_focus) + // Bindings scoped to `GraphView` resolve to `graph::*` actions the + // workspace root handles; this crate cannot name them itself. .key_context("GraphView") - .on_key_down(cx.listener(Self::handle_graph_key_down)) .relative() .v_flex() .size_full() diff --git a/crates/rgitui_workspace/src/bisect_view.rs b/crates/rgitui_workspace/src/bisect_view.rs index fdcfc62..70e43a6 100644 --- a/crates/rgitui_workspace/src/bisect_view.rs +++ b/crates/rgitui_workspace/src/bisect_view.rs @@ -4,15 +4,17 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ div, px, uniform_list, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, - InteractiveElement, KeyDownEvent, ListSizingBehavior, MouseButton, MouseDownEvent, - ParentElement, Render, ScrollStrategy, SharedString, Styled, UniformListScrollHandle, - WeakEntity, Window, + InteractiveElement, ListSizingBehavior, MouseButton, MouseDownEvent, ParentElement, Render, + ScrollStrategy, SharedString, Styled, UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::{BisectDecision, BisectLogEntry}; use rgitui_settings::SettingsState; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, Tooltip}; +use crate::keymap; +use crate::CommandId; + const BISECT_ICON: IconName = IconName::GitMerge; /// Events emitted by the bisect view. @@ -90,71 +92,44 @@ impl BisectView { (good, bad, skip, start) } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - let modifiers = &event.keystroke.modifiers; + /// Runs a keyboard command scoped to the shared `List` group. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { let count = self.entries.len(); - if count == 0 { - return; - } - match key { - "j" | "down" => { - let next = self - .highlighted_row - .map(|r| (r + 1).min(count - 1)) - .unwrap_or(0); - self.highlighted_row = Some(next); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "k" | "up" => { - let prev = self - .highlighted_row - .map(|r| r.saturating_sub(1)) - .unwrap_or(0); - self.highlighted_row = Some(prev); - self.scroll_handle - .scroll_to_item(prev, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "enter" => { - if let Some(row) = self.highlighted_row { - if let Some(entry) = self.entries.get(row) { - cx.emit(BisectViewEvent::CommitSelected(entry.sha.clone())); - } - } - cx.stop_propagation(); - } - "escape" => { - cx.emit(BisectViewEvent::Dismissed); - cx.stop_propagation(); - } - "g" => { - if modifiers.shift { - let last = count - 1; - self.highlighted_row = Some(last); - self.scroll_handle - .scroll_to_item(last, ScrollStrategy::Bottom); - } else { - self.highlighted_row = Some(0); - self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); + match cmd { + CommandId::Cancel => cx.emit(BisectViewEvent::Dismissed), + _ if count == 0 => {} + CommandId::SelectNext => self.highlight_row( + self.highlighted_row + .map_or(0, |row| (row + 1).min(count - 1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectPrev => self.highlight_row( + self.highlighted_row.map_or(0, |row| row.saturating_sub(1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectFirst => self.highlight_row(0, ScrollStrategy::Top, cx), + CommandId::SelectLast => self.highlight_row(count - 1, ScrollStrategy::Bottom, cx), + CommandId::Confirm => { + if let Some(entry) = self.highlighted_row.and_then(|row| self.entries.get(row)) { + cx.emit(BisectViewEvent::CommitSelected(entry.sha.clone())); } - cx.notify(); - cx.stop_propagation(); } - _ => {} + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + /// Moves the keyboard highlight and scrolls it into view. + fn highlight_row(&mut self, row: usize, strategy: ScrollStrategy, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_handle.scroll_to_item(row, strategy); + cx.notify(); + } + fn render_header(&self, cx: &mut Context, count: usize) -> gpui::Div { let colors = cx.colors(); let (good, bad, skip, start) = self.decision_counts(); @@ -440,8 +415,15 @@ impl Render for BisectView { div() .id("bisect-view") .track_focus(&self.focus_handle) - .key_context("BisectView") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "BisectView List", + &["Menu", "BisectView"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .size_full() .bg(editor_bg) diff --git a/crates/rgitui_workspace/src/blame_view.rs b/crates/rgitui_workspace/src/blame_view.rs index c45e1fe..c031f4a 100644 --- a/crates/rgitui_workspace/src/blame_view.rs +++ b/crates/rgitui_workspace/src/blame_view.rs @@ -5,14 +5,17 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ div, px, uniform_list, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, - KeyDownEvent, ListSizingBehavior, MouseButton, MouseDownEvent, Render, ScrollStrategy, - SharedString, UniformListScrollHandle, WeakEntity, Window, + ListSizingBehavior, MouseButton, MouseDownEvent, Render, ScrollStrategy, SharedString, + UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::BlameLine; use rgitui_settings::SettingsState; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, Tooltip}; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the blame view. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BlameViewEvent { @@ -97,78 +100,48 @@ impl BlameView { map } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - let modifiers = &event.keystroke.modifiers; + /// Runs a keyboard command scoped to `BlameView` or to the shared `List` + /// group. Esc is a *navigation* here — back to the diff — which is why the + /// view owns `BlameShowDiff` instead of leaning on `menu::Cancel`. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { let line_count = self.lines.len(); - if line_count == 0 { - return; - } - match key { - "j" | "down" => { - let next = self - .highlighted_row - .map(|r| (r + 1).min(line_count - 1)) - .unwrap_or(0); - self.highlighted_row = Some(next); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "k" | "up" => { - let prev = self - .highlighted_row - .map(|r| r.saturating_sub(1)) - .unwrap_or(0); - self.highlighted_row = Some(prev); - self.scroll_handle - .scroll_to_item(prev, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "enter" => { - if let Some(row) = self.highlighted_row { - if let Some(line) = self.lines.get(row) { - let oid = line.entry.oid.to_string(); - cx.emit(BlameViewEvent::CommitSelected(oid)); - } - } - cx.stop_propagation(); - } - "escape" | "d" => { - cx.emit(BlameViewEvent::SwitchToDiff); - cx.stop_propagation(); - } - "h" => { - cx.emit(BlameViewEvent::SwitchToHistory); - cx.stop_propagation(); - } - "g" => { - if modifiers.shift { - // G (Shift+G) — jump to last line - let last = line_count - 1; - self.highlighted_row = Some(last); - self.scroll_handle - .scroll_to_item(last, ScrollStrategy::Bottom); - } else { - // g — jump to first line - self.highlighted_row = Some(0); - self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); + match cmd { + CommandId::BlameShowDiff => cx.emit(BlameViewEvent::SwitchToDiff), + CommandId::BlameShowHistory => cx.emit(BlameViewEvent::SwitchToHistory), + CommandId::Cancel => cx.emit(BlameViewEvent::Dismissed), + _ if line_count == 0 => {} + CommandId::SelectNext => self.highlight_row( + self.highlighted_row + .map_or(0, |row| (row + 1).min(line_count - 1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectPrev => self.highlight_row( + self.highlighted_row.map_or(0, |row| row.saturating_sub(1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectFirst => self.highlight_row(0, ScrollStrategy::Top, cx), + CommandId::SelectLast => self.highlight_row(line_count - 1, ScrollStrategy::Bottom, cx), + CommandId::Confirm => { + if let Some(line) = self.highlighted_row.and_then(|row| self.lines.get(row)) { + cx.emit(BlameViewEvent::CommitSelected(line.entry.oid.to_string())); } - cx.notify(); - cx.stop_propagation(); } - _ => {} + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + /// Moves the keyboard highlight and scrolls it into view. + fn highlight_row(&mut self, row: usize, strategy: ScrollStrategy, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_handle.scroll_to_item(row, strategy); + cx.notify(); + } + fn render_empty_state(&self, cx: &mut Context) -> gpui::AnyElement { let colors = cx.colors(); @@ -233,7 +206,7 @@ impl BlameView { impl Render for BlameView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let colors = cx.colors(); + let colors = cx.colors().clone(); if self.lines.is_empty() { return self.render_empty_state(cx); @@ -460,8 +433,15 @@ impl Render for BlameView { div() .id("blame-view") .track_focus(&self.focus_handle) - .key_context("BlameView") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "BlameView List", + &["Menu", "BlameView"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .size_full() .bg(editor_bg) diff --git a/crates/rgitui_workspace/src/command_palette.rs b/crates/rgitui_workspace/src/command_palette.rs index 69dc173..292e530 100644 --- a/crates/rgitui_workspace/src/command_palette.rs +++ b/crates/rgitui_workspace/src/command_palette.rs @@ -4,12 +4,13 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ div, px, uniform_list, App, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, - FontWeight, KeyDownEvent, Render, ScrollStrategy, SharedString, UniformListScrollHandle, - Window, + FontWeight, Render, ScrollStrategy, SharedString, UniformListScrollHandle, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInputEvent, Tooltip}; +use crate::keymap; + use crate::CommandId; /// Pre-computed git context used for context-sensitive command filtering. @@ -132,69 +133,12 @@ pub(crate) const fn in_progress_operation(ctx: CommandContext) -> bool { /// The command identifier, its gpui action and its default keybinding are /// generated by the `commands!` macro in `keymap::registry`. impl CommandId { - pub fn display_label(self) -> &'static str { - match self { - Self::Fetch => "fetch", - Self::Pull => "pull", - Self::Push => "push", - Self::PushAll => "push all", - Self::PullAll => "pull all", - Self::ForcePush => "force push", - Self::Commit => "commit", - Self::StageAll => "stage all", - Self::UnstageAll => "unstage all", - Self::StashSave => "stash save", - Self::StashPop => "stash pop", - Self::StashApply => "stash apply", - Self::StashDrop => "stash drop", - Self::CreateBranch => "create branch", - Self::DeleteBranch => "delete branch", - Self::RenameBranch => "rename branch", - Self::MergeBranch => "merge branch", - Self::CreateTag => "create tag", - Self::CreateWorktree => "create worktree", - Self::CreatePr => "create pull request", - Self::CherryPick => "cherry pick", - Self::RevertCommit => "revert commit", - Self::InteractiveRebase => "interactive rebase", - Self::DiscardAll => "discard all", - Self::CleanUntracked => "clean untracked", - Self::ResetHard => "reset hard", - Self::AbortOperation => "abort operation", - Self::ContinueMerge => "continue merge", - Self::ToggleDiffMode => "toggle diff mode", - Self::Search => "search", - Self::AiMessage => "ai message", - Self::Refresh => "refresh", - Self::Settings => "settings", - Self::OpenRepo => "open repo", - Self::WorkspaceHome => "workspace home", - Self::RestoreLastWorkspace => "restore last workspace", - Self::Shortcuts => "shortcuts", - Self::SwitchBranch => "switch branch", - Self::Blame => "blame file", - Self::Undo => "undo last operation", - Self::FileHistory => "file history", - Self::Reflog => "reflog", - Self::Submodules => "submodules", - Self::Bisect => "bisect log", - Self::BisectStart => "bisect start", - Self::BisectGood => "bisect good (current)", - Self::BisectBad => "bisect bad (current)", - Self::BisectReset => "bisect reset", - Self::BisectSkip => "bisect skip (current)", - Self::GlobalSearch => "global search", - Self::ToggleIssues => "toggle issues panel", - Self::TogglePullRequests => "toggle pull requests panel", - Self::ToggleBranchHealth => "toggle branch health panel", - Self::ToggleStashes => "toggle stashes panel", - Self::StashBranch => "create branch from stash", - Self::OpenThemeEditor => "edit theme", - Self::CommandPalette => "command palette", - Self::NextTab => "next tab", - Self::PrevTab => "previous tab", - Self::CloseTab => "close tab", - } + /// A lower-case, human-readable name for use inside a sentence. + /// + /// Derived from the stable command id rather than hand-maintained, so a new + /// command cannot be added without one. + pub fn display_label(self) -> String { + self.as_str().replace('_', " ") } } @@ -864,40 +808,38 @@ impl CommandPalette { } } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - - match key { - "escape" => { - self.dismiss(cx); - cx.stop_propagation(); - } - "up" => { - if self.selected_index > 0 { - self.selected_index -= 1; - self.scroll_handle - .scroll_to_item(self.selected_index, ScrollStrategy::Nearest); - cx.notify(); - } - cx.stop_propagation(); + /// Runs a keyboard command scoped to `CommandPalette` or to the shared + /// `List` group. + /// + /// Enter is propagated so the query field's own submission runs the + /// highlighted command exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + CommandId::SelectPrev => self.select_row(self.selected_index.saturating_sub(1), cx), + CommandId::SelectNext => self.select_row(self.selected_index + 1, cx), + CommandId::SelectFirst => self.select_row(0, cx), + CommandId::SelectLast => { + self.select_row(self.filtered_indices.len().saturating_sub(1), cx) } - "down" => { - if self.selected_index + 1 < self.filtered_indices.len() { - self.selected_index += 1; - self.scroll_handle - .scroll_to_item(self.selected_index, ScrollStrategy::Nearest); - cx.notify(); - } - cx.stop_propagation(); - } - _ => {} + _ => cx.propagate(), } } + + /// Moves the highlight, clamped to the filtered list, and scrolls to it. + fn select_row(&mut self, row: usize, cx: &mut Context) { + if self.filtered_indices.is_empty() { + return; + } + let row = row.min(self.filtered_indices.len() - 1); + if row == self.selected_index { + return; + } + self.selected_index = row; + self.scroll_handle + .scroll_to_item(row, ScrollStrategy::Nearest); + cx.notify(); + } } impl Render for CommandPalette { @@ -906,15 +848,22 @@ impl Render for CommandPalette { return div().id("command-palette").into_any_element(); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let query_is_empty = self.query_editor.read(cx).is_empty(); let filtered_count = self.filtered_indices.len(); let mut modal = div() .id("command-palette-modal") .track_focus(&self.focus_handle) - .key_context("CommandPalette") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "CommandPalette List", + &["Menu"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .w(px(720.)) .max_h(px(500.)) @@ -1254,10 +1203,7 @@ mod tests { fn command_id_stash_branch() { use super::CommandId; assert_eq!(CommandId::StashBranch.as_str(), "stash_branch"); - assert_eq!( - CommandId::StashBranch.display_label(), - "create branch from stash" - ); + assert_eq!(CommandId::StashBranch.display_label(), "stash branch"); } #[test] diff --git a/crates/rgitui_workspace/src/commit_panel.rs b/crates/rgitui_workspace/src/commit_panel.rs index 1b8c23c..29275f5 100644 --- a/crates/rgitui_workspace/src/commit_panel.rs +++ b/crates/rgitui_workspace/src/commit_panel.rs @@ -394,7 +394,6 @@ impl Render for CommitPanel { div() .id("commit-content-area") .track_focus(&self.focus_handle) - .key_context("CommitPanel") .v_flex() .flex_1() .min_h(px(120.)) diff --git a/crates/rgitui_workspace/src/file_history_view.rs b/crates/rgitui_workspace/src/file_history_view.rs index ddcc9e2..0c6c02a 100644 --- a/crates/rgitui_workspace/src/file_history_view.rs +++ b/crates/rgitui_workspace/src/file_history_view.rs @@ -4,14 +4,17 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ div, px, uniform_list, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, - KeyDownEvent, ListSizingBehavior, MouseButton, MouseDownEvent, Render, ScrollStrategy, - SharedString, UniformListScrollHandle, WeakEntity, Window, + ListSizingBehavior, MouseButton, MouseDownEvent, Render, ScrollStrategy, SharedString, + UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::CommitInfo; use rgitui_settings::SettingsState; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize}; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the file history view. #[derive(Debug, Clone, PartialEq)] pub enum FileHistoryViewEvent { @@ -84,78 +87,48 @@ impl FileHistoryView { self.focus_handle.is_focused(window) } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - let modifiers = &event.keystroke.modifiers; + /// Runs a keyboard command scoped to `FileHistoryView` or to the shared + /// `List` group. Esc navigates back to the diff rather than dismissing, so + /// the view owns `HistoryShowDiff` instead of leaning on `menu::Cancel`. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { let count = self.commits.len(); - if count == 0 { - return; - } - match key { - "j" | "down" => { - let next = self - .highlighted_row - .map(|r| (r + 1).min(count - 1)) - .unwrap_or(0); - self.highlighted_row = Some(next); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "k" | "up" => { - let prev = self - .highlighted_row - .map(|r| r.saturating_sub(1)) - .unwrap_or(0); - self.highlighted_row = Some(prev); - self.scroll_handle - .scroll_to_item(prev, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "enter" => { - if let Some(row) = self.highlighted_row { - if let Some(commit) = self.commits.get(row) { - let oid = commit.oid.to_string(); - cx.emit(FileHistoryViewEvent::CommitSelected(oid)); - } + match cmd { + CommandId::HistoryShowDiff => cx.emit(FileHistoryViewEvent::SwitchToDiff), + CommandId::HistoryShowBlame => cx.emit(FileHistoryViewEvent::SwitchToBlame), + CommandId::Cancel => cx.emit(FileHistoryViewEvent::Dismissed), + _ if count == 0 => {} + CommandId::SelectNext => self.highlight_row( + self.highlighted_row + .map_or(0, |row| (row + 1).min(count - 1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectPrev => self.highlight_row( + self.highlighted_row.map_or(0, |row| row.saturating_sub(1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectFirst => self.highlight_row(0, ScrollStrategy::Top, cx), + CommandId::SelectLast => self.highlight_row(count - 1, ScrollStrategy::Bottom, cx), + CommandId::Confirm => { + if let Some(commit) = self.highlighted_row.and_then(|row| self.commits.get(row)) { + cx.emit(FileHistoryViewEvent::CommitSelected(commit.oid.to_string())); } - cx.stop_propagation(); - } - "escape" | "d" => { - cx.emit(FileHistoryViewEvent::SwitchToDiff); - cx.stop_propagation(); } - "b" => { - cx.emit(FileHistoryViewEvent::SwitchToBlame); - cx.stop_propagation(); - } - "g" => { - if modifiers.shift { - // G (Shift+G) — jump to last commit - let last = count - 1; - self.highlighted_row = Some(last); - self.scroll_handle - .scroll_to_item(last, ScrollStrategy::Bottom); - } else { - // g — jump to first commit - self.highlighted_row = Some(0); - self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); - } - cx.notify(); - cx.stop_propagation(); - } - _ => {} + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + /// Moves the keyboard highlight and scrolls it into view. + fn highlight_row(&mut self, row: usize, strategy: ScrollStrategy, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_handle.scroll_to_item(row, strategy); + cx.notify(); + } + fn render_empty_state(&self, cx: &mut Context) -> gpui::AnyElement { let colors = cx.colors(); @@ -220,7 +193,7 @@ impl FileHistoryView { impl Render for FileHistoryView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let colors = cx.colors(); + let colors = cx.colors().clone(); if self.commits.is_empty() { return self.render_empty_state(cx); @@ -398,8 +371,15 @@ impl Render for FileHistoryView { div() .id("file-history-view") .track_focus(&self.focus_handle) - .key_context("FileHistoryView") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "FileHistoryView List", + &["Menu", "FileHistoryView"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .size_full() .bg(editor_bg) diff --git a/crates/rgitui_workspace/src/issues_panel.rs b/crates/rgitui_workspace/src/issues_panel.rs index d4952b3..43a2b02 100644 --- a/crates/rgitui_workspace/src/issues_panel.rs +++ b/crates/rgitui_workspace/src/issues_panel.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ - div, px, uniform_list, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, - KeyDownEvent, Render, ScrollStrategy, SharedString, UniformListScrollHandle, Window, + div, px, uniform_list, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Render, + ScrollStrategy, SharedString, UniformListScrollHandle, Window, }; use crate::github_api::GithubCollectionError; @@ -19,6 +19,9 @@ use rgitui_ui::{ TextInput, TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + #[derive(Clone, Debug)] pub struct Issue { pub number: u64, @@ -661,66 +664,57 @@ impl IssuesPanel { .detach(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - + /// Runs a keyboard command scoped to the shared `List` group. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { if self.view_mode == IssuesPanelView::Detail { - if key == "escape" { + if cmd == CommandId::Cancel { self.go_back(cx); - cx.stop_propagation(); } return; } - // While searching the text input owns the keyboard; list navigation - // must not steal j/k or hijack Enter from the search submission. - if self.is_searching { + // While the search field is open it owns Enter; propagating lets the + // field's own submission run the search instead of opening a row. + if self.is_searching && cmd == CommandId::Confirm { + cx.propagate(); return; } let count = self.issues.len(); if count == 0 { + cx.propagate(); return; } - match key { - "down" | "j" => { - let next = self - .selected_index - .map(|i| (i + 1).min(count - 1)) - .unwrap_or(0); - self.selected_index = Some(next); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "up" | "k" => { - let prev = self - .selected_index - .map(|i| i.saturating_sub(1)) - .unwrap_or(0); - self.selected_index = Some(prev); - self.scroll_handle - .scroll_to_item(prev, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); + match cmd { + CommandId::SelectNext => self.select_row( + self.selected_index.map_or(0, |i| (i + 1).min(count - 1)), + cx, + ), + CommandId::SelectPrev => { + self.select_row(self.selected_index.map_or(0, |i| i.saturating_sub(1)), cx) } - "enter" => { + CommandId::SelectFirst => self.select_row(0, cx), + CommandId::SelectLast => self.select_row(count - 1, cx), + CommandId::Confirm => { if let Some(index) = self.selected_index { self.select_issue(index, cx); - cx.stop_propagation(); } } - _ => {} + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + /// Moves the keyboard selection and scrolls it into view. + fn select_row(&mut self, row: usize, cx: &mut Context) { + self.selected_index = Some(row); + self.scroll_handle + .scroll_to_item(row, ScrollStrategy::Nearest); + cx.notify(); + } + fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { let colors = cx.colors(); @@ -1207,7 +1201,15 @@ impl Render for IssuesPanel { let mut panel = div() .id("issues-panel") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "IssuesPanel List", + &["Menu"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .size_full() .bg(panel_bg); diff --git a/crates/rgitui_workspace/src/keymap/generate.rs b/crates/rgitui_workspace/src/keymap/generate.rs index be40e5d..148f584 100644 --- a/crates/rgitui_workspace/src/keymap/generate.rs +++ b/crates/rgitui_workspace/src/keymap/generate.rs @@ -31,14 +31,44 @@ fn views() -> Vec<&'static str> { views } +/// Escapes a value for a Markdown table cell. +/// +/// GFM splits table rows on `|` even inside a code span, so an `||` context +/// predicate would silently break the row it sits in. +fn table_cell(value: &str) -> String { + value.replace('|', "\\|") +} + /// Renders a command's default keystrokes for a Markdown table cell. fn keystroke_cell(meta: &CommandMeta) -> String { - if meta.default_keystrokes.is_empty() { + if meta.default_bindings.is_empty() { return "_unbound_".to_owned(); } - meta.default_keystrokes + meta.default_bindings .iter() - .map(|keystroke| format!("`{keystroke}`")) + .map(|(keystroke, _)| format!("`{}`", table_cell(keystroke))) + .collect::>() + .join(" or ") +} + +/// Renders the key contexts a command's default bindings are scoped to. +/// +/// Keystrokes of one command may differ here — a vim-style letter usually +/// carries `&& !TextInput` where its arrow-key twin does not — so each distinct +/// context is listed once, in binding order. +fn context_cell(meta: &CommandMeta) -> String { + let mut contexts: Vec<&'static str> = Vec::new(); + for (_, context) in meta.default_bindings { + if !contexts.contains(context) { + contexts.push(context); + } + } + if contexts.is_empty() { + return "—".to_owned(); + } + contexts + .iter() + .map(|context| format!("`{}`", table_cell(context))) .collect::>() .join(" or ") } @@ -89,13 +119,14 @@ pub fn keybindings_markdown() -> String { for view in views() { let _ = write!(out, "\n## {view}\n\n"); - out.push_str("| Keystroke | Action | Description |\n"); - out.push_str("| --- | --- | --- |\n"); + out.push_str("| Keystroke | Context | Action | Description |\n"); + out.push_str("| --- | --- | --- | --- |\n"); for meta in ALL_COMMANDS.iter().filter(|meta| meta.view == view) { let _ = writeln!( out, - "| {} | `{}` | {} |", + "| {} | {} | `{}` | {} |", keystroke_cell(meta), + context_cell(meta), meta.action_name, meta.description() ); @@ -105,13 +136,23 @@ pub fn keybindings_markdown() -> String { let _ = write!( out, "\n## Key contexts\n\n\ + A binding fires when its context matches somewhere on the path from the \ + focused element to the window root, and the deepest matching binding \ + wins. That is what lets one keystroke mean different things in different \ + panels without any `if focused` checks.\n\n\ | Context | Set on |\n\ | --- | --- |\n\ | `Workspace` | the workspace root, so it is always in scope |\n\ + | `SettingsWindow` | the settings window root |\n\ | `modal` | added to the workspace root while any overlay or dialog is open |\n\ - | `TextInput` | any focused text field, so single-key shortcuts do not steal typing |\n\ + | `TextInput` | any text field, so single-key shortcuts do not steal typing |\n\ + | `List` | every panel, picker and dialog that owns a row selection |\n\ + | a view name | the panel, overlay or dialog of that name — see the tables above |\n\ \n\ - Contexts combine with `&&`, `||` and `!`, and `>` matches a descendant.\n" + Contexts combine with `&&`, `||` and `!`, and `>` matches a descendant. \ + `!TextInput` is false whenever a text field is anywhere on the focus \ + path, which is why the vim-style letters carry it and the arrow keys \ + do not.\n" ); out @@ -280,6 +321,25 @@ mod tests { ); } + /// A `||` context predicate must not split the table row it sits in. + #[test] + fn table_cells_escape_the_pipe_that_would_split_a_row() { + assert_eq!( + table_cell("Workspace || SettingsWindow"), + "Workspace \\|\\| SettingsWindow" + ); + + let markdown = keybindings_markdown(); + for line in markdown.lines().filter(|line| line.starts_with('|')) { + let without_escapes = line.replace("\\|", ""); + assert!( + !without_escapes.contains("||"), + "this table row contains an unescaped `||`, which splits it into empty cells: \ + {line}" + ); + } + } + #[test] fn the_doc_lists_every_command_exactly_once() { let markdown = keybindings_markdown(); diff --git a/crates/rgitui_workspace/src/keymap/loader.rs b/crates/rgitui_workspace/src/keymap/loader.rs index 432d666..daa8798 100644 --- a/crates/rgitui_workspace/src/keymap/loader.rs +++ b/crates/rgitui_workspace/src/keymap/loader.rs @@ -122,8 +122,8 @@ pub fn default_specs() -> Vec { ALL_COMMANDS .iter() .flat_map(|meta| { - meta.default_keystrokes.iter().map(|keystrokes| { - BindingSpec::default_binding(keystrokes, meta.context, meta.action_name) + meta.default_bindings.iter().map(|(keystrokes, context)| { + BindingSpec::default_binding(keystrokes, context, meta.action_name) }) }) .collect() diff --git a/crates/rgitui_workspace/src/keymap/macros.rs b/crates/rgitui_workspace/src/keymap/macros.rs index 2cee6dd..97c92ab 100644 --- a/crates/rgitui_workspace/src/keymap/macros.rs +++ b/crates/rgitui_workspace/src/keymap/macros.rs @@ -6,8 +6,8 @@ //! * a `gpui` [`Action`](gpui::Action) unit struct in the declared namespace, //! * the canonical keymap name (`namespace::PascalCase` — gpui panics if an //! action name contains `::` itself, so the namespace is the only separator), -//! * the default keystroke(s), -//! the key context the binding is scoped to, +//! * the default binding(s) — keystrokes paired with the key context each one +//! is scoped to, //! * the command-palette availability predicate, //! * whether the command is offered in the command palette at all, //! * and the doc comment, reused as the description in `docs/KEYBINDINGS.md` @@ -27,6 +27,8 @@ //! StageAll "secondary-s" if has_changes; //! /// Unstage everything. //! UnstageAll ["secondary-shift-s", "secondary-u"] if has_changes; +//! /// Move the selection down. +//! SelectNext ["down", "j" in "List && !TextInput"] in "List"; //! /// Pull from the tracked remote. //! Pull unbound if has_remotes; //! } @@ -39,32 +41,59 @@ //! * The keystroke slot takes one string, a bracketed list of strings (several //! default keystrokes for one command), or the bare word `unbound` for a //! command that is reachable only from the palette. -//! * `in ""` overrides the block's key context for one command. +//! * `in ""` overrides the block's key context. Written after the +//! keystroke slot it applies to the whole command; written after one keystroke +//! *inside* the bracketed list it applies to that keystroke alone. The latter +//! is what lets `down` stay live inside a text field while the vim-style `j` +//! for the same command stands down (`!TextInput`). //! * `if ` names a `fn(CommandContext) -> bool` in scope; //! the default is `always_show`. //! * `[hidden]` keeps the command out of the command palette. -/// Expands the keystroke slot of a `commands!` entry into a `&'static [&'static str]`. -macro_rules! command_keystrokes { - (unbound) => { - &[] as &[&'static str] - }; - ([$($keystroke:literal),* $(,)?]) => { - &[$($keystroke),*] as &[&'static str] +/// Picks the more specific of two key contexts: the override if one was +/// written, otherwise the inherited default. +macro_rules! command_context { + ($inherited:literal) => { + $inherited }; - ($keystroke:literal) => { - &[$keystroke] as &[&'static str] + ($inherited:literal, $override:literal) => { + $override }; } -/// Resolves a command's key context: the per-command override if present, -/// otherwise the enclosing `view` block's default. -macro_rules! command_context { - ($default:literal) => { - $default +/// Expands the keystroke slot of a `commands!` entry into the command's default +/// bindings: `&'static [(keystrokes, key context)]`. +/// +/// The leading argument(s) are the inherited context — the `view` block default, +/// then the per-command `in "..."` when one was written — and each keystroke may +/// narrow it further with its own `in "..."`. The two arities are spelled out +/// separately because `macro_rules!` cannot expand a repetition of the inherited +/// context inside the repetition over keystrokes. +macro_rules! command_bindings { + ($inherited:literal, unbound) => { + &[] as &[(&'static str, &'static str)] }; - ($default:literal, $override:literal) => { - $override + ($inherited:literal, [$($keystroke:literal $(in $context:literal)?),* $(,)?]) => { + &[$( + ($keystroke, command_context!($inherited $(, $context)?)), + )*] as &[(&'static str, &'static str)] + }; + ($inherited:literal, $keystroke:literal) => { + &[($keystroke, $inherited)] as &[(&'static str, &'static str)] + }; + ($view:literal, $command:literal, unbound) => { + &[] as &[(&'static str, &'static str)] + }; + ( + $view:literal, $command:literal, + [$($keystroke:literal $(in $context:literal)?),* $(,)?] + ) => { + &[$( + ($keystroke, command_context!($command $(, $context)?)), + )*] as &[(&'static str, &'static str)] + }; + ($view:literal, $command:literal, $keystroke:literal) => { + &[($keystroke, $command)] as &[(&'static str, &'static str)] }; } @@ -229,8 +258,9 @@ macro_rules! commands { view: stringify!($view), namespace: stringify!($namespace), action_name: concat!(stringify!($namespace), "::", stringify!($name)), - default_keystrokes: command_keystrokes!($keystrokes), - context: command_context!($view_context $(, $context)?), + default_bindings: command_bindings!( + $view_context $(, $context)?, $keystrokes + ), description: concat!("" $(, $doc)*), in_palette: command_in_palette!($([$hidden])?), availability: command_predicate!($($predicate)?), diff --git a/crates/rgitui_workspace/src/keymap/mod.rs b/crates/rgitui_workspace/src/keymap/mod.rs index 3d703a9..df3b193 100644 --- a/crates/rgitui_workspace/src/keymap/mod.rs +++ b/crates/rgitui_workspace/src/keymap/mod.rs @@ -28,6 +28,36 @@ use gpui::{App, Global}; pub use loader::keymap_path; pub use registry::{actions, attach_actions, CommandId, CommandMeta, ALL_COMMANDS}; +/// Declares a view's key context and attaches the commands it handles. +/// +/// `key_context` is the space-separated identifier list gpui matches binding +/// contexts against — a view's own name plus any group it joins, e.g. +/// `"BlameView List"`. `views` names the `commands!` blocks whose `on_action` +/// handlers to install, typically the shared `"Menu"` block plus the view's own. +/// +/// Every handler funnels into `dispatch`, so a view keeps one entry point for +/// keyboard-invoked commands. gpui stops propagating an action at the first +/// handler it reaches walking outwards from the focused element, which is what +/// makes `Esc`, `Enter` and `j`/`k` mean the right thing per view without any +/// `if focused` checks. +pub fn bind_actions( + element: E, + key_context: &'static str, + views: &[&'static str], + cx: &mut gpui::Context, + dispatch: fn(&mut V, CommandId, &mut gpui::Window, &mut gpui::Context), +) -> E +where + E: gpui::InteractiveElement, + V: 'static, +{ + let mut element = element.key_context(key_context); + for view in views { + element = attach_actions(element, view, cx, dispatch); + } + element +} + /// Debounce applied after a `keymap.json` change before reloading, so an editor /// writing the file in several steps triggers one reload. const RELOAD_DEBOUNCE: Duration = Duration::from_millis(200); diff --git a/crates/rgitui_workspace/src/keymap/registry.rs b/crates/rgitui_workspace/src/keymap/registry.rs index a097f60..aae5a34 100644 --- a/crates/rgitui_workspace/src/keymap/registry.rs +++ b/crates/rgitui_workspace/src/keymap/registry.rs @@ -9,6 +9,24 @@ //! the hand-rolled `any_overlay_active` gate for the commands listed here. //! `TextInput` is set by [`rgitui_ui::TextInput`], so `!TextInput` keeps //! unmodified single-key shortcuts from stealing typed characters. +//! +//! Every panel, picker and dialog that owns a row selection also sets `List` +//! alongside its own name, e.g. `key_context("BlameView List")`. The shared +//! `menu` commands below are bound once against `List`, and each view registers +//! its own handler for them; gpui dispatches an action from the focused element +//! outwards and the first handler consumes it, so the view that owns the +//! selection is the one that moves. The same mechanism resolves `menu::Cancel` +//! and `menu::Confirm` — there is no cascade of `if visible` checks anywhere. +//! +//! # Views in other crates +//! +//! `GraphView` (`rgitui_graph`) and `DiffViewer` (`rgitui_diff`) sit *below* +//! this crate in the dependency graph, so they cannot name these actions. Their +//! commands are therefore declared here in the `graph` and `diff` namespaces and +//! handled on the workspace root, which is an ancestor of both on every dispatch +//! path; the views themselves only declare their key context. That also means +//! they do not join the `List` group: a shared `menu` command has to be handled +//! by the element that owns the selection, and these two cannot handle anything. // The `if ` clause of `commands!` resolves these by name at the // invocation site; `always_show` is the default and is referenced through @@ -30,10 +48,11 @@ pub struct CommandMeta { pub namespace: &'static str, /// The full gpui action name, as written in `keymap.json`. pub action_name: &'static str, - /// Default keystrokes. Empty when the command has no default binding. - pub default_keystrokes: &'static [&'static str], - /// Key context predicate the default bindings are scoped to. - pub context: &'static str, + /// Default bindings as `(keystrokes, key context)`. Empty when the command + /// has no default binding. One command may bind several keystrokes, each in + /// its own context — that is how `down` stays live inside a text field while + /// the vim-style `j` for the same command stands down. + pub default_bindings: &'static [(&'static str, &'static str)], /// Doc comment from the declaration. Leading whitespace is not trimmed. pub description: &'static str, /// Whether the command is offered in the command palette. @@ -108,9 +127,9 @@ commands! { /// Continue the merge, rebase, cherry-pick or revert in progress. ContinueMerge unbound if in_progress_operation; /// Switch the diff viewer between unified and side-by-side. - ToggleDiffMode unbound; + ToggleDiffMode "shift-d" in "Workspace && !modal && !TextInput"; /// Search the commit graph. - Search unbound; + Search ["secondary-f", "/" in "Workspace && !modal && !TextInput"]; /// Generate a commit message with the configured AI provider. AiMessage "secondary-g" if has_staged; /// Reload the repository state from disk. @@ -150,7 +169,7 @@ commands! { /// Skip the current bisect commit. BisectSkip unbound if is_bisecting; /// Search the contents of the working tree. - GlobalSearch unbound; + GlobalSearch "secondary-shift-f"; /// Toggle the issues panel. ToggleIssues "alt-5"; /// Toggle the pull requests panel. @@ -171,6 +190,158 @@ commands! { PrevTab "secondary-shift-tab" [hidden]; /// Close the active repository tab. CloseTab "secondary-w" [hidden]; + /// Move keyboard focus to the sidebar. + FocusSidebar "alt-1" [hidden]; + /// Move keyboard focus to the commit graph. + FocusGraph "alt-2" [hidden]; + /// Move keyboard focus to the commit detail panel. + FocusDetailPanel "alt-3" [hidden]; + /// Move keyboard focus to the diff viewer. + FocusDiffViewer "alt-4" [hidden]; + /// Move keyboard focus to the next panel. + FocusNextPanel "tab" in "Workspace && !modal && !TextInput" [hidden]; + /// Move keyboard focus to the previous panel. + FocusPrevPanel "shift-tab" in "Workspace && !modal && !TextInput" [hidden]; + /// Narrow the detail panel. + ShrinkDetailPanel "secondary-[" [hidden]; + /// Widen the detail panel. + GrowDetailPanel "secondary-]" [hidden]; + /// Shorten the diff viewer. + ShrinkDiffViewer "secondary-up" [hidden]; + /// Heighten the diff viewer. + GrowDiffViewer "secondary-down" [hidden]; + } + + // Bound once and handled by whichever element owns the selection or the + // dismissal. See the module docs for how gpui resolves that. + view Menu in menu context "List" { + /// Dismiss the focused overlay, dialog, search or selection. + Cancel "escape" in "Workspace || SettingsWindow" [hidden]; + /// Activate the selected row, or submit the focused dialog. + Confirm ["enter" in "Workspace || SettingsWindow", "space" in "List && !TextInput"] + [hidden]; + /// Move the selection down one row. + SelectNext ["down", "j" in "List && !TextInput"] [hidden]; + /// Move the selection up one row. + SelectPrev ["up", "k" in "List && !TextInput"] [hidden]; + /// Move the selection to the first row. + SelectFirst ["home", "g" in "List && !TextInput"] [hidden]; + /// Move the selection to the last row. + SelectLast ["end", "shift-g" in "List && !TextInput"] [hidden]; + } + + view GraphView in graph context "GraphView && !TextInput" { + /// Select the next commit in the graph. + GraphSelectNext ["down" in "GraphView", "j"] [hidden]; + /// Select the previous commit in the graph. + GraphSelectPrev ["up" in "GraphView", "k"] [hidden]; + /// Select the newest commit in the graph. + GraphSelectFirst ["home" in "GraphView", "g"] [hidden]; + /// Select the oldest loaded commit in the graph. + GraphSelectLast ["end" in "GraphView", "shift-g"] [hidden]; + /// Close the graph search, or dismiss the graph context menu. + GraphCancel "escape" in "GraphView" [hidden]; + /// Copy the selected commit's SHA to the clipboard. + CopyCommitSha "y" [hidden]; + /// Copy the selected commit's message to the clipboard. + CopyCommitMessage "shift-c" [hidden]; + } + + // The diff viewer hosts no text field, but the bare letters below still carry + // `!TextInput` so that `bare_character_bindings_stand_down_for_text_input` + // holds for every binding in the registry without exceptions. + view DiffViewer in diff context "DiffViewer && !TextInput" { + /// Move the diff cursor down one row. + DiffSelectNext ["down", "j"] [hidden]; + /// Move the diff cursor up one row. + DiffSelectPrev ["up", "k"] [hidden]; + /// Move the diff cursor to the first row. + DiffSelectFirst ["home", "g"] [hidden]; + /// Move the diff cursor to the last row. + DiffSelectLast ["end", "shift-g"] [hidden]; + /// Jump to the next hunk. + NextHunk "]" [hidden]; + /// Jump to the previous hunk. + PrevHunk "[" [hidden]; + /// Cycle the diff viewer's display mode. + ToggleDiffDisplayMode "d" [hidden]; + /// Toggle line-level selection in the diff viewer. + TogglePartialSelection "p" [hidden]; + /// Stage the hunks or lines under the diff selection. + StageSelection ["s", "shift-s"] [hidden]; + /// Unstage the hunks or lines under the diff selection. + UnstageSelection ["u", "shift-u"] [hidden]; + /// Stage the hunk under the diff cursor. + StageCurrentHunk "alt-s" [hidden]; + /// Unstage the hunk under the diff cursor. + UnstageCurrentHunk "alt-u" [hidden]; + /// Copy the selected diff lines to the clipboard. + CopyDiffSelection "secondary-c" [hidden]; + /// Select every line in the diff. + SelectAllDiffLines "secondary-a" [hidden]; + } + + view DetailPanel in detail context "DetailPanel && !TextInput" { + /// Switch the changed-files list between the flat and tree layouts. + ToggleFileTree "v" [hidden]; + /// Show the previous commit's details. + PrevCommitDetails "[" [hidden]; + /// Show the next commit's details. + NextCommitDetails "]" [hidden]; + /// Filter the changed-files list. + FileSearch ["/", "secondary-f" in "DetailPanel"] [hidden]; + } + + view Sidebar in sidebar context "Sidebar && !TextInput" { + /// Stage or unstage the selected file. + ToggleStageRow "s" [hidden]; + /// Discard the selected change, or delete the selected branch, tag or stash. + DiscardRow ["x", "delete" in "Sidebar"] [hidden]; + /// Filter the branch list. + FilterBranches ["/", "secondary-f" in "Sidebar"] [hidden]; + } + + view BlameView in blame context "BlameView && !TextInput" { + /// Leave the blame view and go back to the diff. + BlameShowDiff ["escape" in "BlameView", "d"] [hidden]; + /// Show the blamed file's commit history. + BlameShowHistory "h" [hidden]; + } + + view FileHistoryView in history context "FileHistoryView && !TextInput" { + /// Leave the file history and go back to the diff. + HistoryShowDiff ["escape" in "FileHistoryView", "d"] [hidden]; + /// Blame the file whose history is shown. + HistoryShowBlame "b" [hidden]; + } + + view InteractiveRebase in rebase context "InteractiveRebase && !TextInput" { + /// Move the selected commit earlier in the rebase plan. + RebaseMoveUp "secondary-up" in "InteractiveRebase" [hidden]; + /// Move the selected commit later in the rebase plan. + RebaseMoveDown "secondary-down" in "InteractiveRebase" [hidden]; + /// Keep the selected commit as it is. + RebasePick "p" [hidden]; + /// Reword the selected commit's message. + RebaseReword "r" [hidden]; + /// Squash the selected commit into the previous one. + RebaseSquash "s" [hidden]; + /// Squash the selected commit into the previous one, discarding its message. + RebaseFixup "f" [hidden]; + /// Drop the selected commit. + RebaseDrop "d" [hidden]; + } + + view ThemeEditor in theme context "ThemeEditor" { + /// Focus the next field in the theme editor. + ThemeEditorNextField "tab" [hidden]; + /// Focus the previous field in the theme editor. + ThemeEditorPrevField "shift-tab" [hidden]; + } + + view CreatePrDialog in pr context "CreatePrDialog" { + /// Open the pull request described in the dialog. + SubmitPullRequest "shift-enter" [hidden]; } } @@ -184,14 +355,9 @@ impl CommandId { .expect("every CommandId has metadata") } - /// The key context the default bindings for this command are scoped to. - pub fn context(self) -> &'static str { - self.meta().context - } - - /// This command's default keystrokes, or an empty slice when it has none. - pub fn default_keystrokes(self) -> &'static [&'static str] { - self.meta().default_keystrokes + /// This command's default bindings, or an empty slice when it has none. + pub fn default_bindings(self) -> &'static [(&'static str, &'static str)] { + self.meta().default_bindings } /// A one-line description of what this command does. @@ -413,6 +579,174 @@ mod tests { } } + /// Every `(keystrokes, context, action)` triple the registry declares. + fn default_bindings() -> Vec<(&'static str, &'static str, &'static str)> { + ALL_COMMANDS + .iter() + .flat_map(|meta| { + meta.default_bindings + .iter() + .map(move |(keystrokes, context)| (*keystrokes, *context, meta.action_name)) + }) + .collect() + } + + /// The actions a keystroke is bound to, paired with the context of each. + fn bindings_for(keystrokes: &str) -> Vec<(&'static str, &'static str)> { + default_bindings() + .into_iter() + .filter(|(keys, _, _)| *keys == keystrokes) + .map(|(_, context, action)| (action, context)) + .collect() + } + + /// Whether a keystroke is one a user could be typing into a text field: no + /// modifier beyond shift, and a single printable key. + fn is_typeable(keystrokes: &str) -> bool { + let mut parts = keystrokes.split_whitespace(); + let (Some(single), None) = (parts.next(), parts.next()) else { + // A chord always starts with a modifier in this registry. + return false; + }; + let Ok(keystroke) = gpui::Keystroke::parse(single) else { + return false; + }; + let modifiers = keystroke.modifiers; + if modifiers.control || modifiers.alt || modifiers.platform || modifiers.function { + return false; + } + keystroke.key.chars().count() == 1 || keystroke.key == "space" + } + + /// The whole point of Phase B: gpui dispatches bindings deepest-context + /// first, so one letter can mean different things in different panels. Each + /// of these is bound several times over, and every binding must name a + /// distinct action in a distinct context — otherwise one of them is dead. + #[test] + fn ambiguous_letters_resolve_to_one_action_per_context() { + for keystrokes in ["d", "s", "p", "b", "h", "j", "k", "g", "y", "/", "[", "]"] { + let bindings = bindings_for(keystrokes); + assert!( + !bindings.is_empty(), + "`{keystrokes}` is documented as context-sensitive but is not bound at all" + ); + + let actions: HashSet<&str> = bindings.iter().map(|(action, _)| *action).collect(); + assert_eq!( + actions.len(), + bindings.len(), + "`{keystrokes}` binds the same action twice: {bindings:?}" + ); + + let contexts: HashSet<&str> = bindings.iter().map(|(_, context)| *context).collect(); + assert_eq!( + contexts.len(), + bindings.len(), + "`{keystrokes}` binds two actions in the same context, so one never fires: \ + {bindings:?}" + ); + } + } + + /// The four letters the migration called out by name, pinned to the view that + /// owns each so a future block cannot quietly steal one. + #[test] + fn the_overloaded_letters_are_owned_by_the_expected_views() { + let expected: &[(&str, &[&str])] = &[ + ( + "d", + &[ + "diff::ToggleDiffDisplayMode", + "blame::BlameShowDiff", + "history::HistoryShowDiff", + "rebase::RebaseDrop", + ], + ), + ( + "s", + &[ + "diff::StageSelection", + "sidebar::ToggleStageRow", + "rebase::RebaseSquash", + ], + ), + ("p", &["diff::TogglePartialSelection", "rebase::RebasePick"]), + ("b", &["history::HistoryShowBlame"]), + ("h", &["blame::BlameShowHistory"]), + ]; + + for (keystrokes, actions) in expected { + let bound: HashSet<&str> = bindings_for(keystrokes) + .into_iter() + .map(|(action, _)| action) + .collect(); + let want: HashSet<&str> = actions.iter().copied().collect(); + assert_eq!(bound, want, "the owners of `{keystrokes}` changed"); + } + } + + /// gpui dispatches keymap bindings *before* `on_key_down`, so any binding a + /// user could type must stand down while a text field is focused. `!TextInput` + /// is false whenever a text field is anywhere on the focus path, which is + /// exactly the guarantee needed. + #[test] + fn bare_character_bindings_stand_down_for_text_input() { + for (keystrokes, context, action) in default_bindings() { + if !is_typeable(keystrokes) { + continue; + } + assert!( + context.contains("!TextInput"), + "`{keystrokes}` is bound to {action} in context `{context}`, which would swallow \ + the character while a text field has focus; add `&& !TextInput`" + ); + } + } + + /// The shared dismissal must be reachable from both windows, since each owns + /// its own Esc handling and Esc never crosses windows. + #[test] + fn cancel_and_confirm_are_bound_across_both_window_roots() { + for (id, keystrokes) in [(CommandId::Cancel, "escape"), (CommandId::Confirm, "enter")] { + let context = id + .default_bindings() + .iter() + .find(|(keys, _)| *keys == keystrokes) + .map(|(_, context)| *context) + .unwrap_or_else(|| panic!("{id} is not bound to `{keystrokes}`")); + assert!(context.contains("Workspace"), "{id}: `{context}`"); + assert!(context.contains("SettingsWindow"), "{id}: `{context}`"); + } + } + + /// The per-keystroke `in "..."` override: `down` stays live inside a text + /// field while the vim-style `j` for the same command stands down. + #[test] + fn one_command_can_bind_keystrokes_in_different_contexts() { + assert_eq!( + CommandId::SelectNext.default_bindings(), + &[("down", "List"), ("j", "List && !TextInput")] + ); + assert_eq!( + CommandId::SelectLast.default_bindings(), + &[("end", "List"), ("shift-g", "List && !TextInput")] + ); + } + + /// A view block's `context` is the default for its commands, and a + /// per-command `in "..."` overrides it. + #[test] + fn a_view_context_is_inherited_unless_overridden() { + assert_eq!( + CommandId::BlameShowDiff.default_bindings(), + &[("escape", "BlameView"), ("d", "BlameView && !TextInput")] + ); + assert_eq!( + CommandId::BlameShowHistory.default_bindings(), + &[("h", "BlameView && !TextInput")] + ); + } + #[test] fn hidden_commands_are_the_ones_absent_from_the_palette() { let palette: HashSet = crate::command_palette::palette_commands() diff --git a/crates/rgitui_workspace/src/prs_panel.rs b/crates/rgitui_workspace/src/prs_panel.rs index 8d73fb2..2fbd585 100644 --- a/crates/rgitui_workspace/src/prs_panel.rs +++ b/crates/rgitui_workspace/src/prs_panel.rs @@ -6,8 +6,8 @@ use futures::AsyncReadExt; use gpui::prelude::*; use gpui::{ div, http_client::AsyncBody, px, uniform_list, App, ClickEvent, Context, ElementId, Entity, - EventEmitter, FocusHandle, KeyDownEvent, Render, ScrollStrategy, SharedString, - UniformListScrollHandle, WeakEntity, Window, + EventEmitter, FocusHandle, Render, ScrollStrategy, SharedString, UniformListScrollHandle, + WeakEntity, Window, }; use http_client::HttpClient; @@ -23,6 +23,9 @@ use rgitui_ui::{ TextInput, TintColor, }; +use crate::keymap; +use crate::CommandId; + #[derive(Clone, Debug)] pub struct PullRequest { pub number: u64, @@ -733,60 +736,50 @@ impl PrsPanel { .child(el) } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - + /// Runs a keyboard command scoped to the shared `List` group. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { if self.view_mode == PrsPanelView::Detail { - if key == "escape" { + if cmd == CommandId::Cancel { self.go_back(cx); - cx.stop_propagation(); } return; } let count = self.prs.len(); if count == 0 { + cx.propagate(); return; } - match key { - "down" | "j" => { - let next = self - .selected_index - .map(|i| (i + 1).min(count - 1)) - .unwrap_or(0); - self.selected_index = Some(next); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "up" | "k" => { - let prev = self - .selected_index - .map(|i| i.saturating_sub(1)) - .unwrap_or(0); - self.selected_index = Some(prev); - self.scroll_handle - .scroll_to_item(prev, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); + match cmd { + CommandId::SelectNext => self.select_row( + self.selected_index.map_or(0, |i| (i + 1).min(count - 1)), + cx, + ), + CommandId::SelectPrev => { + self.select_row(self.selected_index.map_or(0, |i| i.saturating_sub(1)), cx) } - "enter" => { + CommandId::SelectFirst => self.select_row(0, cx), + CommandId::SelectLast => self.select_row(count - 1, cx), + CommandId::Confirm => { if let Some(index) = self.selected_index { self.select_pr(index, cx); - cx.stop_propagation(); } } - _ => {} + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + /// Moves the keyboard selection and scrolls it into view. + fn select_row(&mut self, row: usize, cx: &mut Context) { + self.selected_index = Some(row); + self.scroll_handle + .scroll_to_item(row, ScrollStrategy::Nearest); + cx.notify(); + } + fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { if self.view_mode == PrsPanelView::Detail { return div() @@ -1272,7 +1265,9 @@ impl Render for PrsPanel { let mut panel = div() .id("prs-panel") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "PrsPanel List", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .size_full() .bg(panel_bg); diff --git a/crates/rgitui_workspace/src/reflog_view.rs b/crates/rgitui_workspace/src/reflog_view.rs index da8c3a1..d0fc040 100644 --- a/crates/rgitui_workspace/src/reflog_view.rs +++ b/crates/rgitui_workspace/src/reflog_view.rs @@ -4,14 +4,17 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ div, px, uniform_list, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, - KeyDownEvent, ListSizingBehavior, MouseButton, MouseDownEvent, Render, ScrollStrategy, - SharedString, UniformListScrollHandle, WeakEntity, Window, + ListSizingBehavior, MouseButton, MouseDownEvent, Render, ScrollStrategy, SharedString, + UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::ReflogEntryInfo; use rgitui_settings::SettingsState; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, Tooltip}; +use crate::keymap; +use crate::CommandId; + // Use Clock icon for reflog since History icon doesn't exist const REFLOG_ICON: IconName = IconName::Clock; @@ -77,74 +80,44 @@ impl ReflogView { self.focus_handle.is_focused(window) } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - let modifiers = &event.keystroke.modifiers; + /// Runs a keyboard command scoped to the shared `List` group. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { let count = self.entries.len(); - if count == 0 { - return; - } - match key { - "j" | "down" => { - let next = self - .highlighted_row - .map(|r| (r + 1).min(count - 1)) - .unwrap_or(0); - self.highlighted_row = Some(next); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "k" | "up" => { - let prev = self - .highlighted_row - .map(|r| r.saturating_sub(1)) - .unwrap_or(0); - self.highlighted_row = Some(prev); - self.scroll_handle - .scroll_to_item(prev, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "enter" => { - if let Some(row) = self.highlighted_row { - if let Some(entry) = self.entries.get(row) { - let oid = entry.new_oid.to_string(); - cx.emit(ReflogViewEvent::CommitSelected(oid)); - } + match cmd { + CommandId::Cancel => cx.emit(ReflogViewEvent::Dismissed), + _ if count == 0 => {} + CommandId::SelectNext => self.highlight_row( + self.highlighted_row + .map_or(0, |row| (row + 1).min(count - 1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectPrev => self.highlight_row( + self.highlighted_row.map_or(0, |row| row.saturating_sub(1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectFirst => self.highlight_row(0, ScrollStrategy::Top, cx), + CommandId::SelectLast => self.highlight_row(count - 1, ScrollStrategy::Bottom, cx), + CommandId::Confirm => { + if let Some(entry) = self.highlighted_row.and_then(|row| self.entries.get(row)) { + cx.emit(ReflogViewEvent::CommitSelected(entry.new_oid.to_string())); } - cx.stop_propagation(); - } - "escape" => { - cx.emit(ReflogViewEvent::Dismissed); - cx.stop_propagation(); } - "g" => { - if modifiers.shift { - // G (Shift+G) — jump to last entry - let last = count - 1; - self.highlighted_row = Some(last); - self.scroll_handle - .scroll_to_item(last, ScrollStrategy::Bottom); - } else { - // g — jump to first entry - self.highlighted_row = Some(0); - self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); - } - cx.notify(); - cx.stop_propagation(); - } - _ => {} + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + /// Moves the keyboard highlight and scrolls it into view. + fn highlight_row(&mut self, row: usize, strategy: ScrollStrategy, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_handle.scroll_to_item(row, strategy); + cx.notify(); + } + fn render_empty_state(&self, cx: &mut Context) -> gpui::AnyElement { let colors = cx.colors(); @@ -209,7 +182,7 @@ impl ReflogView { impl Render for ReflogView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let colors = cx.colors(); + let colors = cx.colors().clone(); if self.entries.is_empty() { return self.render_empty_state(cx); @@ -394,8 +367,15 @@ impl Render for ReflogView { div() .id("reflog-view") .track_focus(&self.focus_handle) - .key_context("ReflogView") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "ReflogView List", + &["Menu", "ReflogView"], + cx, + Self::dispatch_command, + ) + }) .on_mouse_down( MouseButton::Left, move |_: &MouseDownEvent, _: &mut Window, _cx: &mut App| { diff --git a/crates/rgitui_workspace/src/search_panel.rs b/crates/rgitui_workspace/src/search_panel.rs index cf2eca8..1f082a6 100644 --- a/crates/rgitui_workspace/src/search_panel.rs +++ b/crates/rgitui_workspace/src/search_panel.rs @@ -4,13 +4,16 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ - div, px, App, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, KeyDownEvent, - Render, ScrollHandle, SharedString, Window, + div, px, App, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, Render, + ScrollHandle, SharedString, Window, }; use rgitui_git::SearchResult; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInputEvent}; +use crate::keymap; +use crate::CommandId; + const SEARCH_RESULT_ROW_HEIGHT: f32 = 28.0; const SEARCH_RESULTS_PAGE_SIZE: usize = 250; @@ -269,81 +272,57 @@ impl GlobalSearchView { } } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); + /// Runs a keyboard command scoped to the shared `List` group. + /// + /// Enter opens the highlighted match; with nothing highlighted it is + /// propagated so the query field's own submission runs the search. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { let count = self.results.len(); - match key { - "j" | "down" => { + match cmd { + CommandId::Cancel => cx.emit(GlobalSearchViewEvent::Dismissed), + CommandId::Confirm => match self.highlighted_row.and_then(|r| self.results.get(r)) { + Some(result) => cx.emit(GlobalSearchViewEvent::ResultSelected { + path: result.path.to_string_lossy().to_string(), + line_number: result.line_number, + }), + None => cx.propagate(), + }, + _ if count == 0 => {} + CommandId::SelectNext => { let next = self .highlighted_row - .map(|r| (r + 1).min(count.saturating_sub(1))) - .unwrap_or(0); - if count > 0 { - if next >= self.visible_result_count { - self.visible_result_count = - expanded_visible_result_count(self.visible_result_count, count); - } - self.highlighted_row = Some(next); - self.scroll_handle.scroll_to_item(next); - cx.notify(); + .map_or(0, |row| (row + 1).min(count - 1)); + // Walking past the rendered page is an explicit request for more. + if next >= self.visible_result_count { + self.visible_result_count = + expanded_visible_result_count(self.visible_result_count, count); } - cx.stop_propagation(); + self.highlight_row(next, cx); } - "k" | "up" => { - let prev = self - .highlighted_row - .map(|r| r.saturating_sub(1)) - .unwrap_or(0); - if count > 0 { - self.highlighted_row = Some(prev); - self.scroll_handle.scroll_to_item(prev); - cx.notify(); - } - cx.stop_propagation(); + CommandId::SelectPrev => { + let prev = self.highlighted_row.map_or(0, |row| row.saturating_sub(1)); + self.highlight_row(prev, cx); } - "enter" => { - if let Some(row) = self.highlighted_row { - if let Some(result) = self.results.get(row) { - cx.emit(GlobalSearchViewEvent::ResultSelected { - path: result.path.to_string_lossy().to_string(), - line_number: result.line_number, - }); - } - } - cx.stop_propagation(); - } - "escape" => { - cx.emit(GlobalSearchViewEvent::Dismissed); - cx.stop_propagation(); - } - "g" => { - if event.keystroke.modifiers.shift { - // Shift+G — jump to last - if count > 0 { - // Jumping to the final match is an explicit request to - // materialize the remaining pages. - self.visible_result_count = count; - self.highlighted_row = Some(count - 1); - self.scroll_handle.scroll_to_item(count - 1); - cx.notify(); - } - } else if count > 0 { - // g — jump to first - self.highlighted_row = Some(0); - self.scroll_handle.scroll_to_item(0); - cx.notify(); - } - cx.stop_propagation(); + CommandId::SelectFirst => self.highlight_row(0, cx), + CommandId::SelectLast => { + // Jumping to the final match is an explicit request to + // materialize the remaining pages. + self.visible_result_count = count; + self.highlight_row(count - 1, cx); } - _ => {} + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + + /// Moves the keyboard highlight and scrolls it into view. + fn highlight_row(&mut self, row: usize, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_handle.scroll_to_item(row); + cx.notify(); + } } impl Render for GlobalSearchView { @@ -366,7 +345,16 @@ impl Render for GlobalSearchView { .min_h_0() .overflow_hidden() .bg(colors.editor_background) - .on_key_down(cx.listener(Self::handle_key_down)) + .track_focus(&self.focus_handle) + .map(|el| { + keymap::bind_actions( + el, + "SearchPanel List", + &["Menu"], + cx, + Self::dispatch_command, + ) + }) .child(self.render_toolbar(result_count, loading, cx)); if content_state == SearchContentState::Results { diff --git a/crates/rgitui_workspace/src/sidebar.rs b/crates/rgitui_workspace/src/sidebar.rs index 021960d..3914916 100644 --- a/crates/rgitui_workspace/src/sidebar.rs +++ b/crates/rgitui_workspace/src/sidebar.rs @@ -9,9 +9,9 @@ use std::time::{Duration, Instant}; use gpui::prelude::*; use gpui::{ canvas, div, px, uniform_list, App, Bounds, ClickEvent, Context, ElementId, Entity, - EventEmitter, FocusHandle, KeyDownEvent, ListSizingBehavior, MouseButton, MouseDownEvent, - MouseMoveEvent, Pixels, Point, Render, ScrollStrategy, SharedString, Size, - UniformListScrollHandle, WeakEntity, Window, + EventEmitter, FocusHandle, ListSizingBehavior, MouseButton, MouseDownEvent, MouseMoveEvent, + Pixels, Point, Render, ScrollStrategy, SharedString, Size, UniformListScrollHandle, WeakEntity, + Window, }; use rgitui_git::{ BranchInfo, FileChangeKind, FileStatus, RemoteInfo, StashEntry, TagInfo, WorktreeInfo, @@ -23,6 +23,9 @@ use rgitui_ui::{ TextInputEvent, Tooltip, }; +use crate::keymap; +use crate::CommandId; + /// Events from the sidebar. #[derive(Debug, Clone)] pub enum SidebarEvent { @@ -645,149 +648,118 @@ impl Sidebar { } /// Handle keyboard events for sidebar navigation. - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - let primary = event.keystroke.modifiers.secondary(); - - if self.cached_nav_items.is_empty() { - return; - } - - let wants_filter = (key == "/" && !primary) || (key == "f" && primary); - if wants_filter && !self.branch_filter_active { + /// Runs a keyboard command scoped to `Sidebar` or to the shared `List` group. + fn dispatch_command(&mut self, cmd: CommandId, window: &mut Window, cx: &mut Context) { + if cmd == CommandId::FilterBranches { self.branch_filter_active = true; - self.branch_filter_editor.update(cx, |editor, cx| { - editor.focus(_window, cx); - }); + self.branch_filter_editor + .update(cx, |editor, cx| editor.focus(window, cx)); cx.notify(); - cx.stop_propagation(); return; } - // Block Ctrl+F (graph search) when branch filter is active - // so Ctrl+F re-focuses the filter input instead. - if primary && self.branch_filter_active { - cx.stop_propagation(); + if self.cached_nav_items.is_empty() { + cx.propagate(); return; } - if primary { + let last = self.cached_nav_items.len().saturating_sub(1); + match cmd { + CommandId::Cancel => self.clear_branch_filter(cx), + // Bounded section lists follow keyboard selection through their own + // scroll handles; the outer sidebar still owns movement between + // section viewports. + CommandId::SelectPrev => { + self.select_row(self.keyboard_index.map_or(0, |i| i.saturating_sub(1)), cx) + } + CommandId::SelectNext => { + self.select_row(self.keyboard_index.map_or(0, |i| (i + 1).min(last)), cx) + } + CommandId::SelectFirst => self.select_row(0, cx), + CommandId::SelectLast => self.select_row(last, cx), + CommandId::Confirm => self.activate_keyboard_item(cx), + CommandId::ToggleStageRow => self.toggle_stage_selected_row(cx), + CommandId::DiscardRow => self.discard_selected_row(cx), + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), + } + } + + /// Moves the keyboard selection and scrolls it into view. + fn select_row(&mut self, row: usize, cx: &mut Context) { + self.keyboard_index = Some(row); + self.scroll_keyboard_row_into_view(); + cx.notify(); + } + + /// Closes the branch filter and restores the unfiltered lists. + fn clear_branch_filter(&mut self, cx: &mut Context) { + if !self.branch_filter_active && self.branch_filter.is_empty() { return; } + self.branch_filter_active = false; + self.branch_filter.clear(); + self.branch_filter_editor + .update(cx, |editor, cx| editor.clear(cx)); + self.rebuild_flattened_branches(); + self.rebuild_nav_items(); + cx.notify(); + } - match key { - "escape" => { - if self.branch_filter_active || !self.branch_filter.is_empty() { - self.branch_filter_active = false; - self.branch_filter.clear(); - self.branch_filter_editor.update(cx, |editor, cx| { - editor.clear(cx); - }); - self.rebuild_flattened_branches(); - self.rebuild_nav_items(); - cx.notify(); - cx.stop_propagation(); + /// Stages the selected unstaged file, or unstages the selected staged one. + fn toggle_stage_selected_row(&mut self, cx: &mut Context) { + let Some(item) = self.selected_nav_item() else { + return; + }; + match item { + SidebarItem::StagedFile(i) => { + if let Some(file) = self.staged.get(i) { + cx.emit(SidebarEvent::UnstageFile(file.path.display().to_string())); } } - // Bounded section lists follow keyboard selection through - // their own scroll handles; the outer sidebar still owns movement - // between section viewports. - "up" | "k" => { - let new_idx = match self.keyboard_index { - Some(i) if i > 0 => i - 1, - Some(_) => 0, - None => 0, - }; - self.keyboard_index = Some(new_idx); - self.scroll_keyboard_row_into_view(); - cx.notify(); - } - "down" | "j" => { - let max = self.cached_nav_items.len().saturating_sub(1); - let new_idx = match self.keyboard_index { - Some(i) => (i + 1).min(max), - None => 0, - }; - self.keyboard_index = Some(new_idx); - self.scroll_keyboard_row_into_view(); - cx.notify(); - } - "enter" | " " => { - self.activate_keyboard_item(cx); - } - "home" => { - self.keyboard_index = Some(0); - self.scroll_keyboard_row_into_view(); - cx.notify(); + SidebarItem::UnstagedFile(i) => { + if let Some(file) = self.unstaged.get(i) { + cx.emit(SidebarEvent::StageFile(file.path.display().to_string())); + } } - "end" => { - self.keyboard_index = Some(self.cached_nav_items.len().saturating_sub(1)); - self.scroll_keyboard_row_into_view(); - cx.notify(); + _ => {} + } + } + + /// Discards the selected change, or deletes the selected branch, tag or stash. + fn discard_selected_row(&mut self, cx: &mut Context) { + let Some(item) = self.selected_nav_item() else { + return; + }; + match item { + SidebarItem::Tag(i) => { + if let Some(tag) = self.tags.get(i) { + cx.emit(SidebarEvent::TagDelete(tag.name.clone())); + } } - "s" => { - if let Some(idx) = self.keyboard_index { - if let Some(item) = self.cached_nav_items.get(idx).cloned() { - match item { - SidebarItem::StagedFile(i) => { - if let Some(file) = self.staged.get(i) { - cx.emit(SidebarEvent::UnstageFile( - file.path.display().to_string(), - )); - } - } - SidebarItem::UnstagedFile(i) => { - if let Some(file) = self.unstaged.get(i) { - cx.emit(SidebarEvent::StageFile( - file.path.display().to_string(), - )); - } - } - _ => {} - } + SidebarItem::Stash(i) => cx.emit(SidebarEvent::StashDrop(i)), + SidebarItem::LocalBranch(i) => { + if let Some(branch) = self.local_branches.get(i) { + if !branch.is_head { + cx.emit(SidebarEvent::BranchDelete(branch.name.clone())); } } } - "x" | "delete" => { - if let Some(idx) = self.keyboard_index { - if let Some(item) = self.cached_nav_items.get(idx).cloned() { - match item { - SidebarItem::Tag(i) => { - if let Some(tag) = self.tags.get(i) { - cx.emit(SidebarEvent::TagDelete(tag.name.clone())); - } - } - SidebarItem::Stash(i) => { - cx.emit(SidebarEvent::StashDrop(i)); - } - SidebarItem::LocalBranch(i) => { - if let Some(branch) = self.local_branches.get(i) { - if !branch.is_head { - cx.emit(SidebarEvent::BranchDelete(branch.name.clone())); - } - } - } - SidebarItem::UnstagedFile(i) => { - if let Some(file) = self.unstaged.get(i) { - cx.emit(SidebarEvent::DiscardFile( - file.path.display().to_string(), - )); - } - } - _ => {} - } - } + SidebarItem::UnstagedFile(i) => { + if let Some(file) = self.unstaged.get(i) { + cx.emit(SidebarEvent::DiscardFile(file.path.display().to_string())); } } _ => {} } } + /// The nav item the keyboard selection points at, if any. + fn selected_nav_item(&self) -> Option { + self.cached_nav_items.get(self.keyboard_index?).cloned() + } + /// Focus the sidebar for keyboard navigation. pub fn focus(&self, window: &mut Window, cx: &mut Context) { self.focus_handle.focus(window, cx); @@ -1486,8 +1458,15 @@ impl Render for Sidebar { let panel = div() .id("sidebar-panel") .track_focus(&self.focus_handle) - .key_context("Sidebar") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "Sidebar List", + &["Menu", "Sidebar"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .w_full() .h_full() diff --git a/crates/rgitui_workspace/src/submodule_view.rs b/crates/rgitui_workspace/src/submodule_view.rs index 0a4d620..088c91e 100644 --- a/crates/rgitui_workspace/src/submodule_view.rs +++ b/crates/rgitui_workspace/src/submodule_view.rs @@ -3,14 +3,17 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ - div, px, uniform_list, App, Context, ElementId, EventEmitter, FocusHandle, KeyDownEvent, - ListSizingBehavior, MouseButton, MouseDownEvent, Render, ScrollStrategy, SharedString, - UniformListScrollHandle, WeakEntity, Window, + div, px, uniform_list, App, Context, ElementId, EventEmitter, FocusHandle, ListSizingBehavior, + MouseButton, MouseDownEvent, Render, ScrollStrategy, SharedString, UniformListScrollHandle, + WeakEntity, Window, }; use rgitui_git::SubmoduleInfo; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, Tooltip}; +use crate::keymap; +use crate::CommandId; + const SUBMODULE_ICON: IconName = IconName::GitBranch; /// Events emitted by the submodule view. @@ -69,55 +72,39 @@ impl SubmoduleView { self.focus_handle.is_focused(window) } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); + /// Runs a keyboard command scoped to the shared `List` group. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { let count = self.submodules.len(); - if count == 0 { - return; - } - match key { - "j" | "down" => { - let next = self - .highlighted_row - .map(|r| (r + 1).min(count - 1)) - .unwrap_or(0); - self.highlighted_row = Some(next); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "k" | "up" => { - let prev = self - .highlighted_row - .map(|r| r.saturating_sub(1)) - .unwrap_or(0); - self.highlighted_row = Some(prev); - self.scroll_handle - .scroll_to_item(prev, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "escape" => { - cx.emit(SubmoduleViewEvent::Dismissed); - cx.stop_propagation(); - } - "g" => { - self.highlighted_row = Some(0); - self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); - cx.notify(); - cx.stop_propagation(); - } - _ => {} + match cmd { + CommandId::Cancel => cx.emit(SubmoduleViewEvent::Dismissed), + _ if count == 0 => {} + CommandId::SelectNext => self.highlight_row( + self.highlighted_row + .map_or(0, |row| (row + 1).min(count - 1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectPrev => self.highlight_row( + self.highlighted_row.map_or(0, |row| row.saturating_sub(1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectFirst => self.highlight_row(0, ScrollStrategy::Top, cx), + CommandId::SelectLast => self.highlight_row(count - 1, ScrollStrategy::Bottom, cx), + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + /// Moves the keyboard highlight and scrolls it into view. + fn highlight_row(&mut self, row: usize, strategy: ScrollStrategy, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_handle.scroll_to_item(row, strategy); + cx.notify(); + } + fn format_status(sub: &SubmoduleInfo) -> String { sub.status().to_string() } @@ -186,7 +173,7 @@ impl SubmoduleView { impl Render for SubmoduleView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let colors = cx.colors(); + let colors = cx.colors().clone(); if self.submodules.is_empty() { return self.render_empty_state(cx); @@ -367,8 +354,15 @@ impl Render for SubmoduleView { div() .id("submodule-view") .track_focus(&self.focus_handle) - .key_context("SubmoduleView") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "SubmoduleView List", + &["Menu", "SubmoduleView"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .size_full() .bg(editor_bg) diff --git a/crates/rgitui_workspace/src/workspace/commands.rs b/crates/rgitui_workspace/src/workspace/commands.rs index e4500d4..527453a 100644 --- a/crates/rgitui_workspace/src/workspace/commands.rs +++ b/crates/rgitui_workspace/src/workspace/commands.rs @@ -2,11 +2,19 @@ use gpui::{Context, Window}; use crate::{CommandId, CommitPanelEvent, ConfirmAction, ToastKind}; +use super::layout::{ + MAX_DETAIL_PANEL_WIDTH, MAX_DIFF_VIEWER_HEIGHT, MIN_DETAIL_PANEL_WIDTH, MIN_DIFF_VIEWER_HEIGHT, +}; use super::{ BottomPanelMode, FocusedPanel, ProjectTab, RightPanelMode, ViewCacheEntry, ViewCacheKey, ViewCaches, Workspace, }; +/// Pixels the detail panel grows or shrinks by per keystroke. +const DETAIL_PANEL_STEP: f32 = 20.0; +/// Pixels the diff viewer grows or shrinks by per keystroke. +const DIFF_VIEWER_STEP: f32 = 30.0; + impl Workspace { /// Entry point for keyboard-invoked commands. /// @@ -47,10 +55,129 @@ impl Workspace { CommandId::SwitchBranch => { self.focus_panel(FocusedPanel::Sidebar, window, cx); } + CommandId::FocusSidebar => self.focus_panel(FocusedPanel::Sidebar, window, cx), + CommandId::FocusGraph => self.focus_panel(FocusedPanel::Graph, window, cx), + CommandId::FocusDetailPanel => self.focus_panel(FocusedPanel::DetailPanel, window, cx), + CommandId::FocusDiffViewer => self.focus_panel(FocusedPanel::DiffViewer, window, cx), + CommandId::FocusNextPanel => self.focus_next_panel(window, cx), + CommandId::FocusPrevPanel => self.focus_prev_panel(window, cx), + CommandId::Search => self.toggle_graph_search(window, cx), + CommandId::GlobalSearch => self.toggle_global_search(window, cx), cmd => self.execute_command(cmd, cx), } } + /// Runs a `graph::*` command against the active tab's commit graph. + /// + /// `GraphView` lives in `rgitui_graph`, which cannot depend on this crate and + /// therefore cannot name the actions. The workspace root is an ancestor of + /// the graph on every dispatch path, so handling them here still means the + /// bindings only fire while the graph holds focus — that is what the + /// `GraphView` key context on its root element is for. + pub(super) fn dispatch_graph_command( + &mut self, + cmd: CommandId, + window: &mut Window, + cx: &mut Context, + ) { + let Some(graph) = self.tabs.get(self.active_tab).map(|tab| tab.graph.clone()) else { + cx.propagate(); + return; + }; + graph.update(cx, |graph, cx| match cmd { + CommandId::GraphSelectNext => graph.select_next_row(cx), + CommandId::GraphSelectPrev => graph.select_prev_row(cx), + CommandId::GraphSelectFirst => graph.select_first_row(cx), + CommandId::GraphSelectLast => graph.select_last_row(cx), + CommandId::GraphCancel => graph.cancel(window, cx), + CommandId::CopyCommitSha => graph.copy_selected_sha(cx), + CommandId::CopyCommitMessage => graph.copy_selected_message(cx), + _ => cx.propagate(), + }); + } + + /// Runs a `diff::*` command against the active tab's diff viewer. + /// + /// Handled here for the same reason as [`Self::dispatch_graph_command`]: + /// `rgitui_diff` sits below this crate and cannot name the actions. + pub(super) fn dispatch_diff_command( + &mut self, + cmd: CommandId, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(diff) = self + .tabs + .get(self.active_tab) + .map(|tab| tab.diff_viewer.clone()) + else { + cx.propagate(); + return; + }; + diff.update(cx, |diff, cx| match cmd { + CommandId::DiffSelectNext => diff.select_next_row(cx), + CommandId::DiffSelectPrev => diff.select_prev_row(cx), + CommandId::DiffSelectFirst => diff.select_first_row(cx), + CommandId::DiffSelectLast => diff.select_last_row(cx), + CommandId::NextHunk => diff.select_next_hunk(cx), + CommandId::PrevHunk => diff.select_prev_hunk(cx), + CommandId::ToggleDiffDisplayMode => diff.toggle_display_mode(cx), + CommandId::TogglePartialSelection => diff.toggle_partial_mode(cx), + CommandId::StageSelection => diff.stage_selection(cx), + CommandId::UnstageSelection => diff.unstage_selection(cx), + CommandId::StageCurrentHunk => diff.stage_current_hunk(cx), + CommandId::UnstageCurrentHunk => diff.unstage_current_hunk(cx), + CommandId::CopyDiffSelection => diff.copy_selection(cx), + CommandId::SelectAllDiffLines => diff.select_all_lines(cx), + _ => cx.propagate(), + }); + } + + /// Toggles the commit graph's search field, focusing it when it opens. + fn toggle_graph_search(&mut self, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.tabs.get(self.active_tab) else { + return; + }; + let graph = tab.graph.clone(); + graph.update(cx, |graph, cx| { + graph.toggle_search_focused(window, cx); + }); + } + + /// Widens (positive `delta`) or narrows the right-hand detail panel. + fn resize_detail_panel(&mut self, delta: f32, cx: &mut Context) { + self.layout.detail_panel_width = (self.layout.detail_panel_width + delta) + .clamp(MIN_DETAIL_PANEL_WIDTH, MAX_DETAIL_PANEL_WIDTH); + self.schedule_layout_save(cx); + cx.notify(); + } + + /// Heightens (positive `delta`) or shortens the bottom diff viewer. + fn resize_diff_viewer(&mut self, delta: f32, cx: &mut Context) { + self.layout.diff_viewer_height = (self.layout.diff_viewer_height + delta) + .clamp(MIN_DIFF_VIEWER_HEIGHT, MAX_DIFF_VIEWER_HEIGHT); + self.schedule_layout_save(cx); + cx.notify(); + } + + /// Swaps the bottom panel between the diff viewer and the working-tree search. + fn toggle_global_search(&mut self, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.tabs.get_mut(self.active_tab) else { + return; + }; + if tab.bottom_panel_mode == BottomPanelMode::GlobalSearch { + tab.global_search_view + .update(cx, |search, cx| search.hide(cx)); + tab.bottom_panel_mode = BottomPanelMode::Diff; + } else { + tab.bottom_panel_mode = BottomPanelMode::GlobalSearch; + tab.global_search_view.update(cx, |search, cx| { + search.show(window, cx); + }); + } + cx.notify(); + } + pub(super) fn execute_command(&mut self, cmd: CommandId, cx: &mut Context) { match cmd { CommandId::Settings => { @@ -138,10 +265,21 @@ impl Workspace { self.close_tab(self.active_tab, cx); } } + CommandId::ShrinkDetailPanel => self.resize_detail_panel(-DETAIL_PANEL_STEP, cx), + CommandId::GrowDetailPanel => self.resize_detail_panel(DETAIL_PANEL_STEP, cx), + CommandId::ShrinkDiffViewer => self.resize_diff_viewer(-DIFF_VIEWER_STEP, cx), + CommandId::GrowDiffViewer => self.resize_diff_viewer(DIFF_VIEWER_STEP, cx), // Toggling the palette needs a `Window`, so it is handled in // `dispatch_command`. It is `[hidden]`, so the palette never - // dispatches it to itself. - CommandId::CommandPalette => {} + // dispatches it to itself. The panel-focus commands likewise need a + // `Window` and are `[hidden]`. + CommandId::CommandPalette + | CommandId::FocusSidebar + | CommandId::FocusGraph + | CommandId::FocusDetailPanel + | CommandId::FocusDiffViewer + | CommandId::FocusNextPanel + | CommandId::FocusPrevPanel => {} cmd => { let Some(tab) = self.tabs.get(self.active_tab).cloned() else { return; @@ -569,7 +707,72 @@ impl Workspace { | CommandId::CommandPalette | CommandId::NextTab | CommandId::PrevTab - | CommandId::CloseTab => {} + | CommandId::CloseTab + | CommandId::FocusSidebar + | CommandId::FocusGraph + | CommandId::FocusDetailPanel + | CommandId::FocusDiffViewer + | CommandId::FocusNextPanel + | CommandId::FocusPrevPanel + | CommandId::ShrinkDetailPanel + | CommandId::GrowDetailPanel + | CommandId::ShrinkDiffViewer + | CommandId::GrowDiffViewer => {} + // View-owned commands. Each is handled by the panel, overlay or + // dialog whose key context scopes it — the shared `menu` commands on + // whichever element holds the selection, the `graph` and `diff` ones + // on the workspace root (see `dispatch_view_command`) because those + // two views live in crates that cannot name these actions. They are + // listed rather than swept up by a wildcard so that adding a command + // forces a decision about where it is handled. + CommandId::Cancel + | CommandId::Confirm + | CommandId::SelectNext + | CommandId::SelectPrev + | CommandId::SelectFirst + | CommandId::SelectLast + | CommandId::GraphSelectNext + | CommandId::GraphSelectPrev + | CommandId::GraphSelectFirst + | CommandId::GraphSelectLast + | CommandId::GraphCancel + | CommandId::CopyCommitSha + | CommandId::CopyCommitMessage + | CommandId::DiffSelectNext + | CommandId::DiffSelectPrev + | CommandId::DiffSelectFirst + | CommandId::DiffSelectLast + | CommandId::NextHunk + | CommandId::PrevHunk + | CommandId::ToggleDiffDisplayMode + | CommandId::TogglePartialSelection + | CommandId::StageSelection + | CommandId::UnstageSelection + | CommandId::StageCurrentHunk + | CommandId::UnstageCurrentHunk + | CommandId::CopyDiffSelection + | CommandId::SelectAllDiffLines + | CommandId::ToggleFileTree + | CommandId::PrevCommitDetails + | CommandId::NextCommitDetails + | CommandId::FileSearch + | CommandId::ToggleStageRow + | CommandId::DiscardRow + | CommandId::FilterBranches + | CommandId::BlameShowDiff + | CommandId::BlameShowHistory + | CommandId::HistoryShowDiff + | CommandId::HistoryShowBlame + | CommandId::RebaseMoveUp + | CommandId::RebaseMoveDown + | CommandId::RebasePick + | CommandId::RebaseReword + | CommandId::RebaseSquash + | CommandId::RebaseFixup + | CommandId::RebaseDrop + | CommandId::ThemeEditorNextField + | CommandId::ThemeEditorPrevField + | CommandId::SubmitPullRequest => {} } } diff --git a/crates/rgitui_workspace/src/workspace/key_handler.rs b/crates/rgitui_workspace/src/workspace/key_handler.rs index 78c5461..4ffe7cd 100644 --- a/crates/rgitui_workspace/src/workspace/key_handler.rs +++ b/crates/rgitui_workspace/src/workspace/key_handler.rs @@ -1,34 +1,41 @@ -//! Workspace-window keyboard handler. +//! How the workspace window resolves keystrokes. //! -//! Esc precedence: each window owns its Esc handling and Esc never bubbles -//! cross-window. The handler in this file dismisses the topmost overlay or -//! dialog within the workspace window — command palette, branch dialog, -//! confirm dialog, etc. +//! There is no keyboard handler here any more. Every shortcut is declared by +//! `commands!` in [`crate::keymap::registry`], bound to a gpui action scoped to a +//! key context, and handled by an `on_action` listener on the element that owns +//! the behaviour. Two mechanisms replace what used to be hand-rolled here: //! -//! The settings window has its own Esc handler in -//! `SettingsWindow::handle_key_down` (see -//! [`crate::SettingsWindow`]) that calls `window.remove_window()`. -//! Workspace overlays are not visible from there and cannot be dismissed -//! from there. +//! * **Esc and Enter.** `menu::Cancel` and `menu::Confirm` are each bound once. +//! gpui dispatches an action outwards from the focused element and the first +//! listener consumes it, so the innermost open overlay, dialog or panel is the +//! one that responds — no ordered cascade of `if visible` checks. A view that +//! does not want a command calls `cx.propagate()` and it carries on outwards, +//! ending at the focused text field. Blame and file history deliberately keep +//! their own `escape` binding, because there Esc is a navigation (back to the +//! diff) rather than a dismissal, and a deeper context wins. //! -//! When introducing a new dialog or overlay, decide which window it lives -//! in and add Esc dismissal to that window's handler. +//! * **Bare letters.** Each is scoped to the view that owns it, and the ones that +//! collide across views (`d`, `s`, `p`, `b`, `h`) simply appear in more than one +//! block with different contexts. They also carry `!TextInput`, which gpui +//! evaluates false whenever a text field is anywhere on the focus path, so a +//! shortcut can never steal a typed character. //! -//! The workspace's *global* shortcuts no longer live here: they are declared by -//! `commands!` in [`crate::keymap::registry`] and dispatched as gpui actions, so -//! users can rebind them from `keymap.json`. What is left is the Esc cascade and -//! the view-local and panel-focus shortcuts, which Phase B will migrate too. - -use gpui::{ClipboardItem, Context, KeyDownEvent, Window}; +//! When adding a dialog or overlay, give its root element a key context and bind +//! its commands in a `commands!` block; there is nothing to register here. +//! +//! The settings window has its own root context (`SettingsWindow`) and its own +//! `menu::Cancel` listener that calls `window.remove_window()`. Each window owns +//! its dismissal and Esc never crosses windows: workspace overlays are not +//! visible from the settings window and cannot be dismissed from it. -use crate::{CommandId, ToastKind}; +use gpui::Context; -use super::{BottomPanelMode, FocusedPanel, Workspace}; +use super::Workspace; impl Workspace { /// Whether any overlay or dialog that suppresses panel shortcuts is open. /// - /// Also drives the `modal` key context on the workspace root, which is how + /// Drives the `modal` key context on the workspace root, which is how /// `Workspace && !modal` keeps rebindable global shortcuts from firing while /// a modal is up. pub(super) fn any_overlay_active(&self, cx: &Context) -> bool { @@ -45,380 +52,4 @@ impl Workspace { || self.overlays.global_search.read(cx).is_visible() || self.overlays.shortcuts_help.read(cx).is_visible() } - - pub(super) fn handle_key_down( - &mut self, - event: &KeyDownEvent, - window: &mut Window, - cx: &mut Context, - ) { - let keystroke = &event.keystroke; - let key = keystroke.key.as_str(); - let modifiers = &keystroke.modifiers; - - // 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| { - ir.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss confirm dialog on Escape - if key == "escape" && self.dialogs.confirm_dialog.read(cx).is_visible() { - self.dialogs.confirm_dialog.update(cx, |cd, cx| { - cd.cancel(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss branch dialog on Escape - if key == "escape" && self.dialogs.branch_dialog.read(cx).is_visible() { - self.dialogs.branch_dialog.update(cx, |bd, cx| { - bd.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss tag dialog on Escape - if key == "escape" && self.dialogs.tag_dialog.read(cx).is_visible() { - self.dialogs.tag_dialog.update(cx, |td, cx| { - td.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss stash branch dialog on Escape - if key == "escape" && self.dialogs.stash_branch_dialog.read(cx).is_visible() { - self.dialogs.stash_branch_dialog.update(cx, |d, cx| { - d.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss worktree dialog on Escape - if key == "escape" && self.dialogs.worktree_dialog.read(cx).is_visible() { - self.dialogs.worktree_dialog.update(cx, |wd, cx| { - wd.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss rename dialog on Escape - if key == "escape" && self.dialogs.rename_dialog.read(cx).is_visible() { - self.dialogs.rename_dialog.update(cx, |rd, cx| { - rd.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss repo opener on Escape - if key == "escape" && self.overlays.repo_opener.read(cx).is_visible() { - self.overlays.repo_opener.update(cx, |ro, cx| { - ro.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss shortcuts help on Escape - if key == "escape" && self.overlays.shortcuts_help.read(cx).is_visible() { - self.overlays.shortcuts_help.update(cx, |sh, cx| { - sh.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss theme editor on Escape - if key == "escape" && self.overlays.theme_editor.read(cx).is_visible() { - self.overlays.theme_editor.update(cx, |te, cx| { - te.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // When an overlay is active, only allow Escape (above) plus the - // rebindable global actions, whose `!modal` context handles this for - // them. Block all panel-specific shortcuts (j/k, Alt+1/2/3/4, Tab, - // resize, etc.) - // TODO(audit): QUAL-10 (phase B) — the remaining per-view - // on_key_down string-matchers should also migrate to `commands!` + - // KeyBinding, letting the focus/key_context tree resolve overlay - // precedence instead of this manual `any_overlay_active` gate. - let any_overlay_active = self.any_overlay_active(cx); - - // Ctrl+Shift+F to toggle global search - if !any_overlay_active && modifiers.secondary() && modifiers.shift && key == "f" { - if let Some(tab) = self.tabs.get_mut(self.active_tab) { - if tab.bottom_panel_mode == BottomPanelMode::GlobalSearch { - tab.global_search_view - .update(cx, |search, cx| search.hide(cx)); - tab.bottom_panel_mode = BottomPanelMode::Diff; - } else { - tab.bottom_panel_mode = BottomPanelMode::GlobalSearch; - tab.global_search_view.update(cx, |search, cx| { - search.show(window, cx); - }); - } - cx.notify(); - } - return; - } - - // Ctrl+F to toggle graph search - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "f" { - if let Some(tab) = self.tabs.get(self.active_tab) { - let graph = tab.graph.clone(); - graph.update(cx, |g, cx| { - g.toggle_search_focused(window, cx); - }); - } - return; - } - - // / to start graph search - if !any_overlay_active && key == "/" { - if let Some(tab) = self.tabs.get(self.active_tab) { - let graph = tab.graph.clone(); - graph.update(cx, |g, cx| { - g.toggle_search_focused(window, cx); - }); - } - return; - } - - // j/k vim-style navigation in the commit graph (skip when graph or detail panel - // is focused, since they handle their own j/k to avoid double-movement) - if !any_overlay_active - && !modifiers.control - && !modifiers.alt - && !modifiers.shift - && !modifiers.platform - { - let panel_has_focus = self - .tabs - .get(self.active_tab) - .map(|tab| { - tab.graph.read(cx).is_focused(window) - || tab.detail_panel.read(cx).is_focused(window) - || tab.diff_viewer.read(cx).is_focused(window) - || tab.blame_view.read(cx).is_focused(window) - }) - .unwrap_or(false); - - if !panel_has_focus { - match key { - "j" => { - if let Some(tab) = self.tabs.get(self.active_tab) { - let graph = tab.graph.clone(); - graph.update(cx, |g, cx| { - let next = g - .selected_index() - .map(|i| (i + 1).min(g.row_count().saturating_sub(1))) - .unwrap_or(0); - g.select_index(next, cx); - }); - } - } - "k" => { - if let Some(tab) = self.tabs.get(self.active_tab) { - let graph = tab.graph.clone(); - graph.update(cx, |g, cx| { - if let Some(i) = g.selected_index() { - if i > 0 { - g.select_index(i - 1, cx); - } - } - }); - } - } - _ => {} - } - } - } - - // 'd' to switch to diff view (from blame/history) - if !any_overlay_active - && key == "d" - && !modifiers.control - && !modifiers.alt - && !modifiers.shift - && !modifiers.platform - { - let sidebar_has_focus = self - .tabs - .get(self.active_tab) - .map(|tab| tab.sidebar.read(cx).is_focused(window)) - .unwrap_or(false); - if !sidebar_has_focus { - if let Some(tab) = self.tabs.get_mut(self.active_tab) { - if tab.bottom_panel_mode != BottomPanelMode::Diff { - tab.bottom_panel_mode = BottomPanelMode::Diff; - cx.notify(); - return; - } - } - } - } - // Shift+D to toggle diff display mode (unified/side-by-side) - if !any_overlay_active - && key == "d" - && !modifiers.control - && !modifiers.alt - && modifiers.shift - && !modifiers.platform - { - self.execute_command(CommandId::ToggleDiffMode, cx); - return; - } - - // 'b' to toggle blame view (not when sidebar has focus — user might be typing) - if !any_overlay_active - && key == "b" - && !modifiers.control - && !modifiers.alt - && !modifiers.shift - && !modifiers.platform - { - let sidebar_focused = self - .tabs - .get(self.active_tab) - .map(|tab| tab.sidebar.read(cx).is_focused(window)) - .unwrap_or(false); - if !sidebar_focused { - self.execute_command(CommandId::Blame, cx); - return; - } - } - - // 'y' to copy SHA of selected commit - if !any_overlay_active - && key == "y" - && !modifiers.control - && !modifiers.alt - && !modifiers.shift - && !modifiers.platform - { - if let Some(tab) = self.tabs.get(self.active_tab) { - let graph = tab.graph.clone(); - if let Some(commit) = graph.read(cx).selected_commit() { - let sha = commit.oid.to_string(); - cx.write_to_clipboard(ClipboardItem::new_string(sha.clone())); - let short = &sha[..7.min(sha.len())]; - self.show_toast(format!("Copied SHA: {}", short), ToastKind::Success, cx); - } - } - return; - } - - // 'Shift+C' to copy commit message of selected commit - if !any_overlay_active - && key == "c" - && !modifiers.control - && !modifiers.alt - && modifiers.shift - && !modifiers.platform - { - if let Some(tab) = self.tabs.get(self.active_tab) { - let graph = tab.graph.clone(); - if let Some(commit) = graph.read(cx).selected_commit() { - let msg = commit.message.clone(); - cx.write_to_clipboard(ClipboardItem::new_string(msg.clone())); - let first_line = msg.lines().next().unwrap_or(&msg); - let preview = if first_line.chars().count() > 40 { - format!("{}...", first_line.chars().take(40).collect::()) - } else { - first_line.to_string() - }; - self.show_toast(format!("Copied: {}", preview), ToastKind::Success, cx); - } - } - return; - } - - // 'h' to toggle file history view for selected file - if !any_overlay_active - && key == "h" - && !modifiers.control - && !modifiers.alt - && !modifiers.shift - && !modifiers.platform - { - self.execute_command(CommandId::FileHistory, cx); - return; - } - - // Ctrl+[ / Ctrl+] to resize detail panel width - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && !modifiers.alt { - match key { - "[" | "bracketleft" => { - self.layout.detail_panel_width = (self.layout.detail_panel_width - 20.0) - .max(super::layout::MIN_DETAIL_PANEL_WIDTH); - self.schedule_layout_save(cx); - cx.notify(); - } - "]" | "bracketright" => { - self.layout.detail_panel_width = (self.layout.detail_panel_width + 20.0) - .min(super::layout::MAX_DETAIL_PANEL_WIDTH); - self.schedule_layout_save(cx); - cx.notify(); - } - // Ctrl+Up / Ctrl+Down to resize diff viewer height - "up" => { - self.layout.diff_viewer_height = (self.layout.diff_viewer_height - 30.0) - .max(super::layout::MIN_DIFF_VIEWER_HEIGHT); - self.schedule_layout_save(cx); - cx.notify(); - } - "down" => { - self.layout.diff_viewer_height = (self.layout.diff_viewer_height + 30.0) - .min(super::layout::MAX_DIFF_VIEWER_HEIGHT); - self.schedule_layout_save(cx); - cx.notify(); - } - _ => {} - } - } - - // Alt+1/2/3/4 to focus sidebar/graph/detail/diff panel - if !any_overlay_active && modifiers.alt && !modifiers.secondary() { - match key { - "1" => { - self.focus_panel(FocusedPanel::Sidebar, window, cx); - return; - } - "2" => { - self.focus_panel(FocusedPanel::Graph, window, cx); - return; - } - "3" => { - self.focus_panel(FocusedPanel::DetailPanel, window, cx); - return; - } - "4" => { - self.focus_panel(FocusedPanel::DiffViewer, window, cx); - return; - } - _ => {} - } - } - - // Tab / Shift+Tab to cycle between panels (only when no overlay is active) - if !any_overlay_active && !modifiers.secondary() && !modifiers.alt && key == "tab" { - if modifiers.shift { - self.focus_prev_panel(window, cx); - } else { - self.focus_next_panel(window, cx); - } - } - } } diff --git a/crates/rgitui_workspace/src/workspace/layout.rs b/crates/rgitui_workspace/src/workspace/layout.rs index 21cf183..f5ff870 100644 --- a/crates/rgitui_workspace/src/workspace/layout.rs +++ b/crates/rgitui_workspace/src/workspace/layout.rs @@ -27,9 +27,6 @@ const WORKSPACE_KEY_CONTEXT: &str = "Workspace"; /// Key context for the workspace root while an overlay or dialog is open. const WORKSPACE_MODAL_KEY_CONTEXT: &str = "Workspace modal"; -/// The `view` name whose `commands!` block the workspace root binds. -const WORKSPACE_VIEW: &str = "Workspace"; - /// Resize bounds for the right detail panel, shared by the drag handle and the /// Ctrl+[ / Ctrl+] keyboard shortcuts so both input paths clamp identically. pub(super) const MIN_DETAIL_PANEL_WIDTH: f32 = 180.0; @@ -86,26 +83,37 @@ impl Workspace { } /// The workspace root element, carrying the `Workspace` key context and one - /// `on_action` handler per `Workspace` command declared by `commands!`. + /// `on_action` handler per command in the `Workspace`, `GraphView` and + /// `DiffViewer` blocks of `commands!`. /// /// Being on the root means the handlers are in the dispatch path of whatever - /// child holds focus, so a global shortcut works from any panel. + /// child holds focus, so a global shortcut works from any panel. The last two + /// blocks are bound here rather than on those views' own elements because + /// they live in crates that cannot name the generated actions (see + /// [`crate::keymap::registry`]); their bindings are still scoped to the + /// `GraphView` and `DiffViewer` key contexts, so they only fire when those + /// panels hold focus. fn workspace_root( &self, cx: &mut Context, ui_font: gpui::Font, background: gpui::Hsla, ) -> gpui::Stateful { - let root = div() + let mut root = div() .id("workspace-root") .key_context(self.workspace_key_context(cx)) .size_full() .font(ui_font) - .bg(background) - .on_key_down(cx.listener(Self::handle_key_down)); + .bg(background); - keymap::attach_actions(root, WORKSPACE_VIEW, cx, |workspace, cmd, window, cx| { + root = keymap::attach_actions(root, "Workspace", cx, |workspace, cmd, window, cx| { workspace.dispatch_command(cmd, window, cx); + }); + root = keymap::attach_actions(root, "GraphView", cx, |workspace, cmd, window, cx| { + workspace.dispatch_graph_command(cmd, window, cx); + }); + keymap::attach_actions(root, "DiffViewer", cx, |workspace, cmd, window, cx| { + workspace.dispatch_diff_command(cmd, window, cx); }) } } diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index 76ef129..f83a07b 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -28,75 +28,188 @@ The file is reloaded when you save it. Bindings you add win over the defaults. T ## Workspace -| Keystroke | Action | Description | -| --- | --- | --- | -| `secondary-shift-r` | `rgitui::Fetch` | Download objects and refs from the tracked remote. | -| _unbound_ | `rgitui::Pull` | Fetch from and integrate with the tracked remote branch. | -| _unbound_ | `rgitui::Push` | Update the remote ref along with associated objects. | -| _unbound_ | `rgitui::PushAll` | Push every open repository. | -| _unbound_ | `rgitui::PullAll` | Pull every open repository. | -| _unbound_ | `rgitui::ForcePush` | Overwrite the remote branch with the local one. | -| `secondary-enter` | `rgitui::Commit` | Commit the staged changes using the message in the commit panel. | -| `secondary-s` | `rgitui::StageAll` | Stage every change in the working tree. | -| `secondary-shift-s` or `secondary-u` | `rgitui::UnstageAll` | Unstage everything currently staged. | -| `secondary-z` | `rgitui::StashSave` | Stash the working tree and index. | -| `secondary-shift-z` | `rgitui::StashPop` | Apply the latest stash entry and drop it. | -| _unbound_ | `rgitui::StashApply` | Apply the latest stash entry and keep it. | -| _unbound_ | `rgitui::StashDrop` | Delete a stash entry. | -| `secondary-b` | `rgitui::CreateBranch` | Create a new branch. | -| _unbound_ | `rgitui::DeleteBranch` | Delete a branch. | -| _unbound_ | `rgitui::RenameBranch` | Rename a branch. | -| _unbound_ | `rgitui::MergeBranch` | Merge another branch into the current one. | -| _unbound_ | `rgitui::CreateTag` | Create a tag. | -| _unbound_ | `rgitui::CreateWorktree` | Create a linked worktree. | -| _unbound_ | `rgitui::CreatePr` | Open a pull request for the current branch. | -| _unbound_ | `rgitui::CherryPick` | Cherry-pick a commit onto the current branch. | -| _unbound_ | `rgitui::RevertCommit` | Revert a commit. | -| _unbound_ | `rgitui::InteractiveRebase` | Start an interactive rebase. | -| _unbound_ | `rgitui::DiscardAll` | Discard every uncommitted change. | -| _unbound_ | `rgitui::CleanUntracked` | Delete untracked files. | -| _unbound_ | `rgitui::ResetHard` | Reset the working tree and index to HEAD. | -| _unbound_ | `rgitui::AbortOperation` | Abort the merge, rebase, cherry-pick or revert in progress. | -| _unbound_ | `rgitui::ContinueMerge` | Continue the merge, rebase, cherry-pick or revert in progress. | -| _unbound_ | `rgitui::ToggleDiffMode` | Switch the diff viewer between unified and side-by-side. | -| _unbound_ | `rgitui::Search` | Search the commit graph. | -| `secondary-g` | `rgitui::AiMessage` | Generate a commit message with the configured AI provider. | -| `f5` | `rgitui::Refresh` | Reload the repository state from disk. | -| `secondary-,` | `rgitui::Settings` | Open the settings window. | -| `secondary-o` | `rgitui::OpenRepo` | Open the repository picker. | -| `secondary-h` | `rgitui::WorkspaceHome` | Close every tab and return to the workspace home screen. | -| _unbound_ | `rgitui::RestoreLastWorkspace` | Reopen the most recently saved workspace. | -| `?` | `rgitui::Shortcuts` | Show the keyboard shortcut reference. | -| `secondary-shift-b` | `rgitui::SwitchBranch` | Focus the sidebar to switch branches. | -| _unbound_ | `rgitui::Blame` | Blame the selected file. | -| _unbound_ | `rgitui::Undo` | Undo the last git operation. | -| _unbound_ | `rgitui::FileHistory` | Show the commit history of the selected file. | -| _unbound_ | `rgitui::Reflog` | Show the reflog. | -| _unbound_ | `rgitui::Submodules` | Show the submodule list. | -| _unbound_ | `rgitui::Bisect` | Show the bisect log. | -| _unbound_ | `rgitui::BisectStart` | Start a bisect session. | -| _unbound_ | `rgitui::BisectGood` | Mark the current bisect commit as good. | -| _unbound_ | `rgitui::BisectBad` | Mark the current bisect commit as bad. | -| _unbound_ | `rgitui::BisectReset` | End the bisect session and restore HEAD. | -| _unbound_ | `rgitui::BisectSkip` | Skip the current bisect commit. | -| _unbound_ | `rgitui::GlobalSearch` | Search the contents of the working tree. | -| `alt-5` | `rgitui::ToggleIssues` | Toggle the issues panel. | -| `alt-6` | `rgitui::TogglePullRequests` | Toggle the pull requests panel. | -| `alt-7` | `rgitui::ToggleBranchHealth` | Toggle the branch health panel. | -| `alt-8` | `rgitui::ToggleStashes` | Toggle the stashes panel. | -| _unbound_ | `rgitui::StashBranch` | Create a branch from a stash entry. | -| `secondary-shift-t` or `alt-9` | `rgitui::OpenThemeEditor` | Open the theme editor. | -| `secondary-shift-p` | `rgitui::CommandPalette` | Toggle the command palette. | -| `secondary-tab` | `rgitui::NextTab` | Activate the next repository tab. | -| `secondary-shift-tab` | `rgitui::PrevTab` | Activate the previous repository tab. | -| `secondary-w` | `rgitui::CloseTab` | Close the active repository tab. | +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `secondary-shift-r` | `Workspace && !modal` | `rgitui::Fetch` | Download objects and refs from the tracked remote. | +| _unbound_ | — | `rgitui::Pull` | Fetch from and integrate with the tracked remote branch. | +| _unbound_ | — | `rgitui::Push` | Update the remote ref along with associated objects. | +| _unbound_ | — | `rgitui::PushAll` | Push every open repository. | +| _unbound_ | — | `rgitui::PullAll` | Pull every open repository. | +| _unbound_ | — | `rgitui::ForcePush` | Overwrite the remote branch with the local one. | +| `secondary-enter` | `Workspace && !modal` | `rgitui::Commit` | Commit the staged changes using the message in the commit panel. | +| `secondary-s` | `Workspace && !modal` | `rgitui::StageAll` | Stage every change in the working tree. | +| `secondary-shift-s` or `secondary-u` | `Workspace && !modal` | `rgitui::UnstageAll` | Unstage everything currently staged. | +| `secondary-z` | `Workspace && !modal` | `rgitui::StashSave` | Stash the working tree and index. | +| `secondary-shift-z` | `Workspace && !modal` | `rgitui::StashPop` | Apply the latest stash entry and drop it. | +| _unbound_ | — | `rgitui::StashApply` | Apply the latest stash entry and keep it. | +| _unbound_ | — | `rgitui::StashDrop` | Delete a stash entry. | +| `secondary-b` | `Workspace && !modal` | `rgitui::CreateBranch` | Create a new branch. | +| _unbound_ | — | `rgitui::DeleteBranch` | Delete a branch. | +| _unbound_ | — | `rgitui::RenameBranch` | Rename a branch. | +| _unbound_ | — | `rgitui::MergeBranch` | Merge another branch into the current one. | +| _unbound_ | — | `rgitui::CreateTag` | Create a tag. | +| _unbound_ | — | `rgitui::CreateWorktree` | Create a linked worktree. | +| _unbound_ | — | `rgitui::CreatePr` | Open a pull request for the current branch. | +| _unbound_ | — | `rgitui::CherryPick` | Cherry-pick a commit onto the current branch. | +| _unbound_ | — | `rgitui::RevertCommit` | Revert a commit. | +| _unbound_ | — | `rgitui::InteractiveRebase` | Start an interactive rebase. | +| _unbound_ | — | `rgitui::DiscardAll` | Discard every uncommitted change. | +| _unbound_ | — | `rgitui::CleanUntracked` | Delete untracked files. | +| _unbound_ | — | `rgitui::ResetHard` | Reset the working tree and index to HEAD. | +| _unbound_ | — | `rgitui::AbortOperation` | Abort the merge, rebase, cherry-pick or revert in progress. | +| _unbound_ | — | `rgitui::ContinueMerge` | Continue the merge, rebase, cherry-pick or revert in progress. | +| `shift-d` | `Workspace && !modal && !TextInput` | `rgitui::ToggleDiffMode` | Switch the diff viewer between unified and side-by-side. | +| `secondary-f` or `/` | `Workspace && !modal` or `Workspace && !modal && !TextInput` | `rgitui::Search` | Search the commit graph. | +| `secondary-g` | `Workspace && !modal` | `rgitui::AiMessage` | Generate a commit message with the configured AI provider. | +| `f5` | `Workspace && !modal` | `rgitui::Refresh` | Reload the repository state from disk. | +| `secondary-,` | `Workspace` | `rgitui::Settings` | Open the settings window. | +| `secondary-o` | `Workspace` | `rgitui::OpenRepo` | Open the repository picker. | +| `secondary-h` | `Workspace && !modal` | `rgitui::WorkspaceHome` | Close every tab and return to the workspace home screen. | +| _unbound_ | — | `rgitui::RestoreLastWorkspace` | Reopen the most recently saved workspace. | +| `?` | `Workspace && !TextInput` | `rgitui::Shortcuts` | Show the keyboard shortcut reference. | +| `secondary-shift-b` | `Workspace && !modal` | `rgitui::SwitchBranch` | Focus the sidebar to switch branches. | +| _unbound_ | — | `rgitui::Blame` | Blame the selected file. | +| _unbound_ | — | `rgitui::Undo` | Undo the last git operation. | +| _unbound_ | — | `rgitui::FileHistory` | Show the commit history of the selected file. | +| _unbound_ | — | `rgitui::Reflog` | Show the reflog. | +| _unbound_ | — | `rgitui::Submodules` | Show the submodule list. | +| _unbound_ | — | `rgitui::Bisect` | Show the bisect log. | +| _unbound_ | — | `rgitui::BisectStart` | Start a bisect session. | +| _unbound_ | — | `rgitui::BisectGood` | Mark the current bisect commit as good. | +| _unbound_ | — | `rgitui::BisectBad` | Mark the current bisect commit as bad. | +| _unbound_ | — | `rgitui::BisectReset` | End the bisect session and restore HEAD. | +| _unbound_ | — | `rgitui::BisectSkip` | Skip the current bisect commit. | +| `secondary-shift-f` | `Workspace && !modal` | `rgitui::GlobalSearch` | Search the contents of the working tree. | +| `alt-5` | `Workspace && !modal` | `rgitui::ToggleIssues` | Toggle the issues panel. | +| `alt-6` | `Workspace && !modal` | `rgitui::TogglePullRequests` | Toggle the pull requests panel. | +| `alt-7` | `Workspace && !modal` | `rgitui::ToggleBranchHealth` | Toggle the branch health panel. | +| `alt-8` | `Workspace && !modal` | `rgitui::ToggleStashes` | Toggle the stashes panel. | +| _unbound_ | — | `rgitui::StashBranch` | Create a branch from a stash entry. | +| `secondary-shift-t` or `alt-9` | `Workspace` | `rgitui::OpenThemeEditor` | Open the theme editor. | +| `secondary-shift-p` | `Workspace` | `rgitui::CommandPalette` | Toggle the command palette. | +| `secondary-tab` | `Workspace && !modal` | `rgitui::NextTab` | Activate the next repository tab. | +| `secondary-shift-tab` | `Workspace && !modal` | `rgitui::PrevTab` | Activate the previous repository tab. | +| `secondary-w` | `Workspace && !modal` | `rgitui::CloseTab` | Close the active repository tab. | +| `alt-1` | `Workspace && !modal` | `rgitui::FocusSidebar` | Move keyboard focus to the sidebar. | +| `alt-2` | `Workspace && !modal` | `rgitui::FocusGraph` | Move keyboard focus to the commit graph. | +| `alt-3` | `Workspace && !modal` | `rgitui::FocusDetailPanel` | Move keyboard focus to the commit detail panel. | +| `alt-4` | `Workspace && !modal` | `rgitui::FocusDiffViewer` | Move keyboard focus to the diff viewer. | +| `tab` | `Workspace && !modal && !TextInput` | `rgitui::FocusNextPanel` | Move keyboard focus to the next panel. | +| `shift-tab` | `Workspace && !modal && !TextInput` | `rgitui::FocusPrevPanel` | Move keyboard focus to the previous panel. | +| `secondary-[` | `Workspace && !modal` | `rgitui::ShrinkDetailPanel` | Narrow the detail panel. | +| `secondary-]` | `Workspace && !modal` | `rgitui::GrowDetailPanel` | Widen the detail panel. | +| `secondary-up` | `Workspace && !modal` | `rgitui::ShrinkDiffViewer` | Shorten the diff viewer. | +| `secondary-down` | `Workspace && !modal` | `rgitui::GrowDiffViewer` | Heighten the diff viewer. | + +## Menu + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `escape` | `Workspace \|\| SettingsWindow` | `menu::Cancel` | Dismiss the focused overlay, dialog, search or selection. | +| `enter` or `space` | `Workspace \|\| SettingsWindow` or `List && !TextInput` | `menu::Confirm` | Activate the selected row, or submit the focused dialog. | +| `down` or `j` | `List` or `List && !TextInput` | `menu::SelectNext` | Move the selection down one row. | +| `up` or `k` | `List` or `List && !TextInput` | `menu::SelectPrev` | Move the selection up one row. | +| `home` or `g` | `List` or `List && !TextInput` | `menu::SelectFirst` | Move the selection to the first row. | +| `end` or `shift-g` | `List` or `List && !TextInput` | `menu::SelectLast` | Move the selection to the last row. | + +## GraphView + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `down` or `j` | `GraphView` or `GraphView && !TextInput` | `graph::GraphSelectNext` | Select the next commit in the graph. | +| `up` or `k` | `GraphView` or `GraphView && !TextInput` | `graph::GraphSelectPrev` | Select the previous commit in the graph. | +| `home` or `g` | `GraphView` or `GraphView && !TextInput` | `graph::GraphSelectFirst` | Select the newest commit in the graph. | +| `end` or `shift-g` | `GraphView` or `GraphView && !TextInput` | `graph::GraphSelectLast` | Select the oldest loaded commit in the graph. | +| `escape` | `GraphView` | `graph::GraphCancel` | Close the graph search, or dismiss the graph context menu. | +| `y` | `GraphView && !TextInput` | `graph::CopyCommitSha` | Copy the selected commit's SHA to the clipboard. | +| `shift-c` | `GraphView && !TextInput` | `graph::CopyCommitMessage` | Copy the selected commit's message to the clipboard. | + +## DiffViewer + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `down` or `j` | `DiffViewer && !TextInput` | `diff::DiffSelectNext` | Move the diff cursor down one row. | +| `up` or `k` | `DiffViewer && !TextInput` | `diff::DiffSelectPrev` | Move the diff cursor up one row. | +| `home` or `g` | `DiffViewer && !TextInput` | `diff::DiffSelectFirst` | Move the diff cursor to the first row. | +| `end` or `shift-g` | `DiffViewer && !TextInput` | `diff::DiffSelectLast` | Move the diff cursor to the last row. | +| `]` | `DiffViewer && !TextInput` | `diff::NextHunk` | Jump to the next hunk. | +| `[` | `DiffViewer && !TextInput` | `diff::PrevHunk` | Jump to the previous hunk. | +| `d` | `DiffViewer && !TextInput` | `diff::ToggleDiffDisplayMode` | Cycle the diff viewer's display mode. | +| `p` | `DiffViewer && !TextInput` | `diff::TogglePartialSelection` | Toggle line-level selection in the diff viewer. | +| `s` or `shift-s` | `DiffViewer && !TextInput` | `diff::StageSelection` | Stage the hunks or lines under the diff selection. | +| `u` or `shift-u` | `DiffViewer && !TextInput` | `diff::UnstageSelection` | Unstage the hunks or lines under the diff selection. | +| `alt-s` | `DiffViewer && !TextInput` | `diff::StageCurrentHunk` | Stage the hunk under the diff cursor. | +| `alt-u` | `DiffViewer && !TextInput` | `diff::UnstageCurrentHunk` | Unstage the hunk under the diff cursor. | +| `secondary-c` | `DiffViewer && !TextInput` | `diff::CopyDiffSelection` | Copy the selected diff lines to the clipboard. | +| `secondary-a` | `DiffViewer && !TextInput` | `diff::SelectAllDiffLines` | Select every line in the diff. | + +## DetailPanel + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `v` | `DetailPanel && !TextInput` | `detail::ToggleFileTree` | Switch the changed-files list between the flat and tree layouts. | +| `[` | `DetailPanel && !TextInput` | `detail::PrevCommitDetails` | Show the previous commit's details. | +| `]` | `DetailPanel && !TextInput` | `detail::NextCommitDetails` | Show the next commit's details. | +| `/` or `secondary-f` | `DetailPanel && !TextInput` or `DetailPanel` | `detail::FileSearch` | Filter the changed-files list. | + +## Sidebar + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `s` | `Sidebar && !TextInput` | `sidebar::ToggleStageRow` | Stage or unstage the selected file. | +| `x` or `delete` | `Sidebar && !TextInput` or `Sidebar` | `sidebar::DiscardRow` | Discard the selected change, or delete the selected branch, tag or stash. | +| `/` or `secondary-f` | `Sidebar && !TextInput` or `Sidebar` | `sidebar::FilterBranches` | Filter the branch list. | + +## BlameView + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `escape` or `d` | `BlameView` or `BlameView && !TextInput` | `blame::BlameShowDiff` | Leave the blame view and go back to the diff. | +| `h` | `BlameView && !TextInput` | `blame::BlameShowHistory` | Show the blamed file's commit history. | + +## FileHistoryView + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `escape` or `d` | `FileHistoryView` or `FileHistoryView && !TextInput` | `history::HistoryShowDiff` | Leave the file history and go back to the diff. | +| `b` | `FileHistoryView && !TextInput` | `history::HistoryShowBlame` | Blame the file whose history is shown. | + +## InteractiveRebase + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `secondary-up` | `InteractiveRebase` | `rebase::RebaseMoveUp` | Move the selected commit earlier in the rebase plan. | +| `secondary-down` | `InteractiveRebase` | `rebase::RebaseMoveDown` | Move the selected commit later in the rebase plan. | +| `p` | `InteractiveRebase && !TextInput` | `rebase::RebasePick` | Keep the selected commit as it is. | +| `r` | `InteractiveRebase && !TextInput` | `rebase::RebaseReword` | Reword the selected commit's message. | +| `s` | `InteractiveRebase && !TextInput` | `rebase::RebaseSquash` | Squash the selected commit into the previous one. | +| `f` | `InteractiveRebase && !TextInput` | `rebase::RebaseFixup` | Squash the selected commit into the previous one, discarding its message. | +| `d` | `InteractiveRebase && !TextInput` | `rebase::RebaseDrop` | Drop the selected commit. | + +## ThemeEditor + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `tab` | `ThemeEditor` | `theme::ThemeEditorNextField` | Focus the next field in the theme editor. | +| `shift-tab` | `ThemeEditor` | `theme::ThemeEditorPrevField` | Focus the previous field in the theme editor. | + +## CreatePrDialog + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `shift-enter` | `CreatePrDialog` | `pr::SubmitPullRequest` | Open the pull request described in the dialog. | ## Key contexts +A binding fires when its context matches somewhere on the path from the focused element to the window root, and the deepest matching binding wins. That is what lets one keystroke mean different things in different panels without any `if focused` checks. + | Context | Set on | | --- | --- | | `Workspace` | the workspace root, so it is always in scope | +| `SettingsWindow` | the settings window root | | `modal` | added to the workspace root while any overlay or dialog is open | -| `TextInput` | any focused text field, so single-key shortcuts do not steal typing | +| `TextInput` | any text field, so single-key shortcuts do not steal typing | +| `List` | every panel, picker and dialog that owns a row selection | +| a view name | the panel, overlay or dialog of that name — see the tables above | -Contexts combine with `&&`, `||` and `!`, and `>` matches a descendant. +Contexts combine with `&&`, `||` and `!`, and `>` matches a descendant. `!TextInput` is false whenever a text field is anywhere on the focus path, which is why the vim-style letters carry it and the arrow keys do not. diff --git a/docs/keymap.schema.json b/docs/keymap.schema.json index 3d77d2b..11fd0e9 100644 --- a/docs/keymap.schema.json +++ b/docs/keymap.schema.json @@ -299,6 +299,238 @@ { "const": "rgitui::CloseTab", "description": "Close the active repository tab. Command id: `close_tab`." + }, + { + "const": "rgitui::FocusSidebar", + "description": "Move keyboard focus to the sidebar. Command id: `focus_sidebar`." + }, + { + "const": "rgitui::FocusGraph", + "description": "Move keyboard focus to the commit graph. Command id: `focus_graph`." + }, + { + "const": "rgitui::FocusDetailPanel", + "description": "Move keyboard focus to the commit detail panel. Command id: `focus_detail_panel`." + }, + { + "const": "rgitui::FocusDiffViewer", + "description": "Move keyboard focus to the diff viewer. Command id: `focus_diff_viewer`." + }, + { + "const": "rgitui::FocusNextPanel", + "description": "Move keyboard focus to the next panel. Command id: `focus_next_panel`." + }, + { + "const": "rgitui::FocusPrevPanel", + "description": "Move keyboard focus to the previous panel. Command id: `focus_prev_panel`." + }, + { + "const": "rgitui::ShrinkDetailPanel", + "description": "Narrow the detail panel. Command id: `shrink_detail_panel`." + }, + { + "const": "rgitui::GrowDetailPanel", + "description": "Widen the detail panel. Command id: `grow_detail_panel`." + }, + { + "const": "rgitui::ShrinkDiffViewer", + "description": "Shorten the diff viewer. Command id: `shrink_diff_viewer`." + }, + { + "const": "rgitui::GrowDiffViewer", + "description": "Heighten the diff viewer. Command id: `grow_diff_viewer`." + }, + { + "const": "menu::Cancel", + "description": "Dismiss the focused overlay, dialog, search or selection. Command id: `cancel`." + }, + { + "const": "menu::Confirm", + "description": "Activate the selected row, or submit the focused dialog. Command id: `confirm`." + }, + { + "const": "menu::SelectNext", + "description": "Move the selection down one row. Command id: `select_next`." + }, + { + "const": "menu::SelectPrev", + "description": "Move the selection up one row. Command id: `select_prev`." + }, + { + "const": "menu::SelectFirst", + "description": "Move the selection to the first row. Command id: `select_first`." + }, + { + "const": "menu::SelectLast", + "description": "Move the selection to the last row. Command id: `select_last`." + }, + { + "const": "graph::GraphSelectNext", + "description": "Select the next commit in the graph. Command id: `graph_select_next`." + }, + { + "const": "graph::GraphSelectPrev", + "description": "Select the previous commit in the graph. Command id: `graph_select_prev`." + }, + { + "const": "graph::GraphSelectFirst", + "description": "Select the newest commit in the graph. Command id: `graph_select_first`." + }, + { + "const": "graph::GraphSelectLast", + "description": "Select the oldest loaded commit in the graph. Command id: `graph_select_last`." + }, + { + "const": "graph::GraphCancel", + "description": "Close the graph search, or dismiss the graph context menu. Command id: `graph_cancel`." + }, + { + "const": "graph::CopyCommitSha", + "description": "Copy the selected commit's SHA to the clipboard. Command id: `copy_commit_sha`." + }, + { + "const": "graph::CopyCommitMessage", + "description": "Copy the selected commit's message to the clipboard. Command id: `copy_commit_message`." + }, + { + "const": "diff::DiffSelectNext", + "description": "Move the diff cursor down one row. Command id: `diff_select_next`." + }, + { + "const": "diff::DiffSelectPrev", + "description": "Move the diff cursor up one row. Command id: `diff_select_prev`." + }, + { + "const": "diff::DiffSelectFirst", + "description": "Move the diff cursor to the first row. Command id: `diff_select_first`." + }, + { + "const": "diff::DiffSelectLast", + "description": "Move the diff cursor to the last row. Command id: `diff_select_last`." + }, + { + "const": "diff::NextHunk", + "description": "Jump to the next hunk. Command id: `next_hunk`." + }, + { + "const": "diff::PrevHunk", + "description": "Jump to the previous hunk. Command id: `prev_hunk`." + }, + { + "const": "diff::ToggleDiffDisplayMode", + "description": "Cycle the diff viewer's display mode. Command id: `toggle_diff_display_mode`." + }, + { + "const": "diff::TogglePartialSelection", + "description": "Toggle line-level selection in the diff viewer. Command id: `toggle_partial_selection`." + }, + { + "const": "diff::StageSelection", + "description": "Stage the hunks or lines under the diff selection. Command id: `stage_selection`." + }, + { + "const": "diff::UnstageSelection", + "description": "Unstage the hunks or lines under the diff selection. Command id: `unstage_selection`." + }, + { + "const": "diff::StageCurrentHunk", + "description": "Stage the hunk under the diff cursor. Command id: `stage_current_hunk`." + }, + { + "const": "diff::UnstageCurrentHunk", + "description": "Unstage the hunk under the diff cursor. Command id: `unstage_current_hunk`." + }, + { + "const": "diff::CopyDiffSelection", + "description": "Copy the selected diff lines to the clipboard. Command id: `copy_diff_selection`." + }, + { + "const": "diff::SelectAllDiffLines", + "description": "Select every line in the diff. Command id: `select_all_diff_lines`." + }, + { + "const": "detail::ToggleFileTree", + "description": "Switch the changed-files list between the flat and tree layouts. Command id: `toggle_file_tree`." + }, + { + "const": "detail::PrevCommitDetails", + "description": "Show the previous commit's details. Command id: `prev_commit_details`." + }, + { + "const": "detail::NextCommitDetails", + "description": "Show the next commit's details. Command id: `next_commit_details`." + }, + { + "const": "detail::FileSearch", + "description": "Filter the changed-files list. Command id: `file_search`." + }, + { + "const": "sidebar::ToggleStageRow", + "description": "Stage or unstage the selected file. Command id: `toggle_stage_row`." + }, + { + "const": "sidebar::DiscardRow", + "description": "Discard the selected change, or delete the selected branch, tag or stash. Command id: `discard_row`." + }, + { + "const": "sidebar::FilterBranches", + "description": "Filter the branch list. Command id: `filter_branches`." + }, + { + "const": "blame::BlameShowDiff", + "description": "Leave the blame view and go back to the diff. Command id: `blame_show_diff`." + }, + { + "const": "blame::BlameShowHistory", + "description": "Show the blamed file's commit history. Command id: `blame_show_history`." + }, + { + "const": "history::HistoryShowDiff", + "description": "Leave the file history and go back to the diff. Command id: `history_show_diff`." + }, + { + "const": "history::HistoryShowBlame", + "description": "Blame the file whose history is shown. Command id: `history_show_blame`." + }, + { + "const": "rebase::RebaseMoveUp", + "description": "Move the selected commit earlier in the rebase plan. Command id: `rebase_move_up`." + }, + { + "const": "rebase::RebaseMoveDown", + "description": "Move the selected commit later in the rebase plan. Command id: `rebase_move_down`." + }, + { + "const": "rebase::RebasePick", + "description": "Keep the selected commit as it is. Command id: `rebase_pick`." + }, + { + "const": "rebase::RebaseReword", + "description": "Reword the selected commit's message. Command id: `rebase_reword`." + }, + { + "const": "rebase::RebaseSquash", + "description": "Squash the selected commit into the previous one. Command id: `rebase_squash`." + }, + { + "const": "rebase::RebaseFixup", + "description": "Squash the selected commit into the previous one, discarding its message. Command id: `rebase_fixup`." + }, + { + "const": "rebase::RebaseDrop", + "description": "Drop the selected commit. Command id: `rebase_drop`." + }, + { + "const": "theme::ThemeEditorNextField", + "description": "Focus the next field in the theme editor. Command id: `theme_editor_next_field`." + }, + { + "const": "theme::ThemeEditorPrevField", + "description": "Focus the previous field in the theme editor. Command id: `theme_editor_prev_field`." + }, + { + "const": "pr::SubmitPullRequest", + "description": "Open the pull request described in the dialog. Command id: `submit_pull_request`." } ] } From 0885a794a47684342b4158f64ebc98bcf4b7c04d Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:38:19 +0100 Subject: [PATCH 06/16] refactor(keymap): replace the Esc cascade with menu::Cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace's key handler owned a ten-step `if key == "escape" && ... is_visible()` chain that had to be extended, in the right order, for every new overlay. It also duplicated the Esc handling each dialog already had, which only ever mattered when a dialog was open without focus. Now each overlay, dialog and window root declares a key context and listens for the shared `menu::Cancel`. gpui dispatches the action outwards from the focused element and the first listener consumes it, so the innermost thing that can be dismissed is the one that goes — the ordering falls out of the element tree instead of being maintained by hand. Blame and file history keep their own `escape` bindings, because there Esc is a navigation back to the diff rather than a dismissal, and a deeper context wins over `Workspace`. Thirteen of these had no key context at all; they all have one now, which is also what makes them addressable from `keymap.json`. Enter is handled the same way. Dialogs whose Enter already went through a text field's `Submit` event propagate `menu::Confirm` so it still fires exactly once; the confirm dialog and the theme editor, which have no such field to submit, listen for it directly. The create-PR dialog keeps `shift-enter` as its own command so plain Enter still inserts a newline in the multi-line body. The settings window gets a `SettingsWindow` context and its own Cancel listener: each window owns its dismissal and Esc never crosses windows. The shortcut reference is corrected while it is open: it advertised Ctrl+Shift+F as Fetch (that is now the working-tree search; Fetch is Ctrl+Shift+R) and listed `b`, `h` and `d` as window-wide. Co-Authored-By: Claude Fable 5 --- crates/rgitui_workspace/src/branch_dialog.rs | 28 ++++--- crates/rgitui_workspace/src/confirm_dialog.rs | 36 +++++---- .../rgitui_workspace/src/create_pr_dialog.rs | 37 +++++---- crates/rgitui_workspace/src/rename_dialog.rs | 28 ++++--- .../rgitui_workspace/src/repo_clone_dialog.rs | 25 ++++--- crates/rgitui_workspace/src/repo_opener.rs | 75 ++++++++++--------- .../src/settings_window/window.rs | 26 ++++--- crates/rgitui_workspace/src/shortcuts_help.rs | 60 ++++++++------- .../src/stash_branch_dialog.rs | 29 +++++-- .../rgitui_workspace/src/stash_save_dialog.rs | 37 +++++---- crates/rgitui_workspace/src/tag_dialog.rs | 26 ++++--- .../src/theme_editor_dialog.rs | 42 ++++++----- .../rgitui_workspace/src/worktree_dialog.rs | 29 +++---- 13 files changed, 275 insertions(+), 203 deletions(-) diff --git a/crates/rgitui_workspace/src/branch_dialog.rs b/crates/rgitui_workspace/src/branch_dialog.rs index 4f0620d..11539d3 100644 --- a/crates/rgitui_workspace/src/branch_dialog.rs +++ b/crates/rgitui_workspace/src/branch_dialog.rs @@ -1,7 +1,6 @@ use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, Render, - SharedString, Window, + div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Render, SharedString, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ @@ -9,6 +8,9 @@ use rgitui_ui::{ TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the branch creation dialog. #[derive(Debug, Clone)] pub enum BranchDialogEvent { @@ -158,14 +160,14 @@ impl BranchDialog { cx.notify(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - if event.keystroke.key.as_str() == "escape" { - self.dismiss(cx); + /// Runs a keyboard command scoped to `BranchDialog`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + _ => cx.propagate(), } } } @@ -181,7 +183,7 @@ impl Render for BranchDialog { self.editor.update(cx, |e, cx| e.focus(window, cx)); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let branch_name = self.editor.read(cx).text().to_string(); let has_error = self.error_message.is_some(); let can_create = !branch_name.is_empty() && !has_error; @@ -197,7 +199,9 @@ impl Render for BranchDialog { let mut modal = div() .id("branch-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "BranchDialog", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .w(px(440.)) .elevation_3(cx) diff --git a/crates/rgitui_workspace/src/confirm_dialog.rs b/crates/rgitui_workspace/src/confirm_dialog.rs index b8a1253..63d6f0a 100644 --- a/crates/rgitui_workspace/src/confirm_dialog.rs +++ b/crates/rgitui_workspace/src/confirm_dialog.rs @@ -1,11 +1,11 @@ use gpui::prelude::*; -use gpui::{ - div, px, ClickEvent, Context, EventEmitter, FocusHandle, KeyDownEvent, Render, SharedString, - Window, -}; +use gpui::{div, px, ClickEvent, Context, EventEmitter, FocusHandle, Render, SharedString, Window}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Button, ButtonSize, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize}; +use crate::keymap; +use crate::CommandId; + /// The action that was confirmed (so the workspace knows what to do). #[derive(Debug, Clone, PartialEq)] pub enum ConfirmAction { @@ -107,16 +107,12 @@ impl ConfirmDialog { cx.notify(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - match event.keystroke.key.as_str() { - "escape" => self.cancel(cx), - "enter" => self.confirm(cx), - _ => {} + /// Runs a keyboard command scoped to `ConfirmDialog`. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.cancel(cx), + CommandId::Confirm => self.confirm(cx), + _ => cx.propagate(), } } @@ -184,7 +180,7 @@ impl Render for ConfirmDialog { self.focus_handle.focus(window, cx); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let title: SharedString = self.title.clone().into(); let message: SharedString = self.message.clone().into(); let icon = self.severity_icon(); @@ -218,7 +214,15 @@ impl Render for ConfirmDialog { div() .id("confirm-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "ConfirmDialog", + &["Menu"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .w(px(420.)) .elevation_3(cx) diff --git a/crates/rgitui_workspace/src/create_pr_dialog.rs b/crates/rgitui_workspace/src/create_pr_dialog.rs index 81e050a..e9f1cbd 100644 --- a/crates/rgitui_workspace/src/create_pr_dialog.rs +++ b/crates/rgitui_workspace/src/create_pr_dialog.rs @@ -4,8 +4,7 @@ use futures::AsyncReadExt; use gpui::http_client::AsyncBody; use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, Render, - SharedString, Window, + div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Render, SharedString, Window, }; use http_client::HttpClient; use rgitui_theme::{ActiveTheme, Color, StyledExt}; @@ -14,6 +13,9 @@ use rgitui_ui::{ LabelSize, TextInput, TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the PR creation dialog. #[derive(Debug, Clone)] pub enum CreatePrDialogEvent { @@ -223,16 +225,15 @@ impl CreatePrDialog { .detach(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - match event.keystroke.key.as_str() { - "escape" => self.cancel(cx), - "enter" if event.keystroke.modifiers.shift => self.submit(cx), - _ => {} + /// Runs a keyboard command scoped to `CreatePrDialog`. + /// + /// Plain Enter is propagated so it inserts a newline in the multi-line body; + /// submitting is `shift-enter`. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.cancel(cx), + CommandId::SubmitPullRequest => self.submit(cx), + _ => cx.propagate(), } } @@ -248,7 +249,7 @@ impl Render for CreatePrDialog { return div().id("create-pr-dialog").into_any_element(); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let head_label: SharedString = self.head_branch.clone().into(); let base_label: SharedString = self.base_branch.clone().into(); @@ -275,7 +276,15 @@ impl Render for CreatePrDialog { div() .id("create-pr-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "CreatePrDialog", + &["Menu", "CreatePrDialog"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .w(px(520.)) .max_h(px(600.)) diff --git a/crates/rgitui_workspace/src/rename_dialog.rs b/crates/rgitui_workspace/src/rename_dialog.rs index a5e852a..157ddd5 100644 --- a/crates/rgitui_workspace/src/rename_dialog.rs +++ b/crates/rgitui_workspace/src/rename_dialog.rs @@ -1,7 +1,6 @@ use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, Render, - SharedString, Window, + div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Render, SharedString, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ @@ -9,6 +8,9 @@ use rgitui_ui::{ TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the rename dialog. #[derive(Debug, Clone)] pub enum RenameDialogEvent { @@ -151,14 +153,14 @@ impl RenameDialog { cx.notify(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - if event.keystroke.key.as_str() == "escape" { - self.dismiss(cx); + /// Runs a keyboard command scoped to `RenameDialog`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + _ => cx.propagate(), } } } @@ -174,7 +176,7 @@ impl Render for RenameDialog { self.editor.update(cx, |e, cx| e.focus(window, cx)); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let new_name = self.editor.read(cx).text().to_string(); let is_empty = new_name.is_empty(); let has_error = self.error_message.is_some(); @@ -192,7 +194,9 @@ impl Render for RenameDialog { let mut modal = div() .id("rename-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "RenameDialog", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .w(px(440.)) .elevation_3(cx) diff --git a/crates/rgitui_workspace/src/repo_clone_dialog.rs b/crates/rgitui_workspace/src/repo_clone_dialog.rs index ca2648f..773decd 100644 --- a/crates/rgitui_workspace/src/repo_clone_dialog.rs +++ b/crates/rgitui_workspace/src/repo_clone_dialog.rs @@ -2,14 +2,16 @@ use std::path::PathBuf; use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, FontWeight, KeyDownEvent, - Render, Window, + div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, FontWeight, Render, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ Button, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + #[derive(Debug, Clone)] pub enum RepoCloneEvent { CloneRepo { url: String, path: PathBuf }, @@ -179,11 +181,14 @@ impl RepoCloneDialog { .detach(); } - fn handle_key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context) { - // Enter is handled solely via each editor's `Submit` event so it fires - // exactly once; here we only need the modal-level Escape-to-dismiss. - if event.keystroke.key.as_str() == "escape" { - self.hide(cx); + /// Runs a keyboard command scoped to `RepoCloneDialog`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.hide(cx), + _ => cx.propagate(), } } } @@ -218,7 +223,7 @@ impl Render for RepoCloneDialog { self.url_editor.update(cx, |e, cx| e.focus(window, cx)); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let url = self.url_editor.read(cx).text().trim().to_string(); let path = self.path_editor.read(cx).text().trim().to_string(); let can_clone = !url.is_empty() && !path.is_empty(); @@ -232,7 +237,9 @@ impl Render for RepoCloneDialog { let mut modal = div() .id("repo-clone-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "RepoCloneDialog", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .w(px(500.)) .elevation_3(cx) diff --git a/crates/rgitui_workspace/src/repo_opener.rs b/crates/rgitui_workspace/src/repo_opener.rs index 6fd314d..44d05cc 100644 --- a/crates/rgitui_workspace/src/repo_opener.rs +++ b/crates/rgitui_workspace/src/repo_opener.rs @@ -2,14 +2,17 @@ use std::path::PathBuf; use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, FontWeight, - KeyDownEvent, Render, SharedString, Window, + div, px, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, FontWeight, Render, + SharedString, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ Button, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + #[derive(Debug, Clone)] pub enum RepoOpenerEvent { OpenRepo(PathBuf), @@ -182,46 +185,46 @@ impl RepoOpener { .detach(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - - match key { - "escape" => { - self.dismiss(cx); - cx.stop_propagation(); - } - "up" => { - if self.filtered_indices.is_empty() { - return; - } - self.selected_index = Some(match self.selected_index { + /// Runs a keyboard command scoped to `RepoOpener` or to the shared `List` + /// group. + /// + /// Enter is propagated so the path field's own submission opens the + /// highlighted repository exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + CommandId::SelectPrev => self.select_row( + match self.selected_index { Some(index) if index > 0 => index - 1, Some(index) => index, None => self.filtered_indices.len().saturating_sub(1), - }); - cx.notify(); - cx.stop_propagation(); - } - "down" => { - if self.filtered_indices.is_empty() { - return; - } - self.selected_index = Some(match self.selected_index { + }, + cx, + ), + CommandId::SelectNext => self.select_row( + match self.selected_index { Some(index) if index + 1 < self.filtered_indices.len() => index + 1, Some(index) => index, None => 0, - }); - cx.notify(); - cx.stop_propagation(); + }, + cx, + ), + CommandId::SelectFirst => self.select_row(0, cx), + CommandId::SelectLast => { + self.select_row(self.filtered_indices.len().saturating_sub(1), cx) } - _ => {} + _ => cx.propagate(), } } + + /// Moves the highlight within the filtered list. + fn select_row(&mut self, row: usize, cx: &mut Context) { + if self.filtered_indices.is_empty() { + return; + } + self.selected_index = Some(row.min(self.filtered_indices.len() - 1)); + cx.notify(); + } } impl Render for RepoOpener { @@ -230,12 +233,14 @@ impl Render for RepoOpener { return div().id("repo-opener").into_any_element(); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let mut modal = div() .id("repo-opener-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "RepoOpener List", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .w(px(500.)) .max_h(px(480.)) diff --git a/crates/rgitui_workspace/src/settings_window/window.rs b/crates/rgitui_workspace/src/settings_window/window.rs index 7da5d97..1f96f65 100644 --- a/crates/rgitui_workspace/src/settings_window/window.rs +++ b/crates/rgitui_workspace/src/settings_window/window.rs @@ -1,7 +1,6 @@ use gpui::prelude::*; use gpui::{ - div, px, Bounds, ClickEvent, Context, Entity, FocusHandle, KeyDownEvent, Pixels, Render, - Subscription, Window, + div, px, Bounds, ClickEvent, Context, Entity, FocusHandle, Pixels, Render, Subscription, Window, }; use rgitui_settings::{SavedWindowBounds, SettingsState}; use rgitui_theme::{ActiveTheme, StyledExt, ThemeState}; @@ -23,6 +22,9 @@ pub struct SettingsWindow { _view_subscription: Subscription, } +use crate::keymap; +use crate::CommandId; + impl SettingsWindow { /// Construct a `SettingsWindow`. Must be called inside the new window's /// `cx.open_window` callback so the inner `SettingsView` (and its @@ -92,14 +94,14 @@ impl SettingsWindow { &self.view } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - window: &mut Window, - _cx: &mut Context, - ) { - if event.keystroke.key.as_str() == "escape" { - window.remove_window(); + /// Runs a keyboard command scoped to `SettingsWindow`. + /// + /// Each window owns its own dismissal: workspace overlays are not visible + /// from here and cannot be dismissed from here. + fn dispatch_command(&mut self, cmd: CommandId, window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => window.remove_window(), + _ => cx.propagate(), } } } @@ -117,7 +119,9 @@ impl Render for SettingsWindow { div() .id("settings-window-root") .track_focus(&self.focus) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "SettingsWindow", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .size_full() .bg(background) diff --git a/crates/rgitui_workspace/src/shortcuts_help.rs b/crates/rgitui_workspace/src/shortcuts_help.rs index d020c36..b581e69 100644 --- a/crates/rgitui_workspace/src/shortcuts_help.rs +++ b/crates/rgitui_workspace/src/shortcuts_help.rs @@ -1,13 +1,13 @@ use std::borrow::Cow; use gpui::prelude::*; -use gpui::{ - div, px, ClickEvent, Context, EventEmitter, FocusHandle, FontWeight, KeyDownEvent, Render, - Window, -}; +use gpui::{div, px, ClickEvent, Context, EventEmitter, FocusHandle, FontWeight, Render, Window}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize}; +use crate::keymap; +use crate::CommandId; + #[derive(Debug, Clone)] pub enum ShortcutsHelpEvent { Dismissed, @@ -87,15 +87,14 @@ impl ShortcutsHelp { cx.notify(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - if event.keystroke.key.as_str() == "escape" { - self.dismiss(cx); - cx.stop_propagation(); + /// Runs a keyboard command scoped to `ShortcutsHelp`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + _ => cx.propagate(), } } @@ -116,10 +115,13 @@ impl ShortcutsHelp { }, ShortcutCategory { title: "Navigation", - description: "Focus a panel first. Plain-letter shortcuts are context-sensitive.", + description: "Focus a panel first — plain letters only act on the focused panel.", shortcuts: &[ - ("j / k", "Move up / down in the focused panel"), - ("g / G", "Jump to first / last item"), + ( + "j / k or Up / Down", + "Move the selection in the focused panel", + ), + ("g / G or Home / End", "Jump to first / last item"), ("Tab / Shift+Tab", "Cycle focused panel"), ("Alt+1 / 2 / 3 / 4", "Focus sidebar / graph / detail / diff"), ( @@ -135,22 +137,26 @@ impl ShortcutsHelp { }, ShortcutCategory { title: "Views & Search", - description: "Fast access to panels and graph-specific tools.", + description: "Panels and graph tools. Letters act on the panel that has focus.", shortcuts: &[ - ("Ctrl+F", "Toggle commit graph search"), - ("Ctrl+Shift+F", "Toggle global search across the repository"), - ("/", "Start in-graph search"), - ("d", "Toggle diff mode (unified / split)"), - ("b", "Toggle blame view for selected file"), - ("h", "Toggle file history view for selected file"), - ("y", "Copy SHA of selected commit"), - ("Shift+C", "Copy commit message of selected commit"), + ("Ctrl+F or /", "Toggle commit graph search"), + ("Ctrl+Shift+F", "Search the working tree"), + ("Shift+D", "Toggle diff mode (unified / split)"), + ("d", "In the diff viewer: cycle display mode"), + ("[ / ]", "In the diff viewer: previous / next hunk"), + ( + "y / Shift+C", + "In the graph: copy the commit's SHA / message", + ), + ("d", "In blame or file history: back to the diff"), + ("h / b", "In blame / file history: switch to the other view"), + ("/ or Ctrl+F", "In the detail panel: filter changed files"), ( "Mouse drag / Shift+click", "Select lines in the diff viewer", ), ("Ctrl+C", "Copy selected diff lines"), - ("Esc", "Close the active overlay or modal"), + ("Esc", "Close the innermost overlay, modal or search"), ], }, ShortcutCategory { @@ -351,7 +357,7 @@ impl Render for ShortcutsHelp { let modal = div() .id("shortcuts-help-container") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| keymap::bind_actions(el, "ShortcutsHelp", &["Menu"], cx, Self::dispatch_command)) .v_flex() .w(modal_width) .h(modal_height) diff --git a/crates/rgitui_workspace/src/stash_branch_dialog.rs b/crates/rgitui_workspace/src/stash_branch_dialog.rs index 0994d07..0ff9629 100644 --- a/crates/rgitui_workspace/src/stash_branch_dialog.rs +++ b/crates/rgitui_workspace/src/stash_branch_dialog.rs @@ -5,8 +5,7 @@ use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, Render, - SharedString, Window, + div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Render, SharedString, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ @@ -14,6 +13,9 @@ use rgitui_ui::{ TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the stash branch dialog. #[derive(Debug, Clone)] pub enum StashBranchDialogEvent { @@ -143,9 +145,14 @@ impl StashBranchDialog { None } - fn handle_key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context) { - if event.keystroke.key.as_str() == "escape" { - self.dismiss(cx); + /// Runs a keyboard command scoped to `StashBranchDialog`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + _ => cx.propagate(), } } @@ -183,7 +190,7 @@ impl Render for StashBranchDialog { self.editor.update(cx, |e, cx| e.focus(window, cx)); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let branch_name = self.editor.read(cx).text().to_string(); let has_error = self.error_message.is_some(); let can_create = !branch_name.is_empty() && !has_error; @@ -203,7 +210,15 @@ impl Render for StashBranchDialog { let mut modal = div() .id("stash-branch-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "StashBranchDialog", + &["Menu"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .w(px(440.)) .elevation_3(cx) diff --git a/crates/rgitui_workspace/src/stash_save_dialog.rs b/crates/rgitui_workspace/src/stash_save_dialog.rs index 713406d..d6bb242 100644 --- a/crates/rgitui_workspace/src/stash_save_dialog.rs +++ b/crates/rgitui_workspace/src/stash_save_dialog.rs @@ -4,15 +4,16 @@ //! field; pressing Enter with an empty field creates `git stash push` (default "WIP" message). use gpui::prelude::*; -use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, Render, Window, -}; +use gpui::{div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Render, Window}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ Button, ButtonSize, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInputEvent, TintColor, }; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the stash save dialog. #[derive(Debug, Clone, PartialEq)] pub enum StashSaveDialogEvent { @@ -110,16 +111,14 @@ impl StashSaveDialog { cx.notify(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - // Enter is handled solely via the editor's `Submit` event so it fires - // exactly once; here we only need the modal-level Escape-to-dismiss. - if event.keystroke.key.as_str() == "escape" { - self.dismiss(cx); + /// Runs a keyboard command scoped to `StashSaveDialog`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + _ => cx.propagate(), } } @@ -142,7 +141,7 @@ impl Render for StashSaveDialog { self.editor.update(cx, |e, cx| e.focus(window, cx)); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let accent_color = Color::Accent.color(cx); let icon_bg = gpui::Hsla { @@ -173,7 +172,15 @@ impl Render for StashSaveDialog { div() .id("stash-save-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "StashSaveDialog", + &["Menu"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .w(px(480.)) .elevation_3(cx) diff --git a/crates/rgitui_workspace/src/tag_dialog.rs b/crates/rgitui_workspace/src/tag_dialog.rs index f8f8f35..e38bdd4 100644 --- a/crates/rgitui_workspace/src/tag_dialog.rs +++ b/crates/rgitui_workspace/src/tag_dialog.rs @@ -1,7 +1,6 @@ use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, Render, - SharedString, Window, + div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Render, SharedString, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ @@ -9,6 +8,9 @@ use rgitui_ui::{ TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the tag creation dialog. #[derive(Debug, Clone)] pub enum TagDialogEvent { @@ -151,14 +153,14 @@ impl TagDialog { } } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - if event.keystroke.key.as_str() == "escape" { - self.dismiss(cx); + /// Runs a keyboard command scoped to `TagDialog`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + _ => cx.propagate(), } } } @@ -174,7 +176,7 @@ impl Render for TagDialog { self.editor.update(cx, |e, cx| e.focus(window, cx)); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let tag_name = self.editor.read(cx).text().to_string(); let has_error = self.error_message.is_some(); let can_create = !tag_name.is_empty() && !has_error; @@ -190,7 +192,7 @@ impl Render for TagDialog { let mut modal = div() .id("tag-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| keymap::bind_actions(el, "TagDialog", &["Menu"], cx, Self::dispatch_command)) .v_flex() .w(px(440.)) .elevation_3(cx) diff --git a/crates/rgitui_workspace/src/theme_editor_dialog.rs b/crates/rgitui_workspace/src/theme_editor_dialog.rs index e22628b..7eeb2eb 100644 --- a/crates/rgitui_workspace/src/theme_editor_dialog.rs +++ b/crates/rgitui_workspace/src/theme_editor_dialog.rs @@ -1,7 +1,7 @@ use gpui::prelude::*; use gpui::{ div, px, relative, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, Hsla, - InteractiveElement, IntoElement, KeyDownEvent, ParentElement, Render, Window, + InteractiveElement, IntoElement, ParentElement, Render, Window, }; use rgitui_theme::{ hex_to_hsla_strict, hsla_to_hex, json_theme::save_theme_to_file, ActiveTheme, Appearance, @@ -9,6 +9,9 @@ use rgitui_theme::{ }; use rgitui_theme::{StatusColors, ThemeColors}; use rgitui_ui::{Button, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize, TextInput}; + +use crate::keymap; +use crate::CommandId; use std::sync::Arc; #[derive(Debug, Clone)] @@ -272,23 +275,14 @@ impl ThemeEditorDialog { self.status_inputs = status_inputs; } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event.keystroke.key.as_str() { - "escape" => { - self.dismiss(cx); - } - "enter" => { - self.save(cx); - } - "tab" => { - self.advance_focus(event.keystroke.modifiers.shift, window, cx); - } - _ => {} + /// Runs a keyboard command scoped to `ThemeEditor`. + fn dispatch_command(&mut self, cmd: CommandId, window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + CommandId::Confirm => self.save(cx), + CommandId::ThemeEditorNextField => self.advance_focus(false, window, cx), + CommandId::ThemeEditorPrevField => self.advance_focus(true, window, cx), + _ => cx.propagate(), } } @@ -412,7 +406,7 @@ impl Render for ThemeEditorDialog { } } - let colors = cx.colors(); + let colors = cx.colors().clone(); let invalid_border = cx.status().error; let valid_border = colors.border_transparent; let theme = self.editable_theme.clone(); @@ -442,7 +436,15 @@ impl Render for ThemeEditorDialog { div() .id("theme-editor-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "ThemeEditor", + &["Menu", "ThemeEditor"], + cx, + Self::dispatch_command, + ) + }) .on_click(|_: &ClickEvent, _, cx| { cx.stop_propagation(); }) diff --git a/crates/rgitui_workspace/src/worktree_dialog.rs b/crates/rgitui_workspace/src/worktree_dialog.rs index 9c25cd5..1f4c8d1 100644 --- a/crates/rgitui_workspace/src/worktree_dialog.rs +++ b/crates/rgitui_workspace/src/worktree_dialog.rs @@ -1,13 +1,14 @@ use gpui::prelude::*; -use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, Render, Window, -}; +use gpui::{div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Render, Window}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ Button, ButtonSize, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInputEvent, TintColor, }; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the worktree creation dialog. #[derive(Debug, Clone)] pub enum WorktreeDialogEvent { @@ -188,14 +189,14 @@ impl WorktreeDialog { cx.notify(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - if event.keystroke.key.as_str() == "escape" { - self.dismiss(cx); + /// Runs a keyboard command scoped to `WorktreeDialog`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + _ => cx.propagate(), } } } @@ -206,7 +207,7 @@ impl Render for WorktreeDialog { return div().id("worktree-dialog").into_any_element(); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let name_text = self.name_editor.read(cx).text().to_string(); let path_text = self.path_editor.read(cx).text().to_string(); let has_error = self.error_message.is_some(); @@ -221,7 +222,9 @@ impl Render for WorktreeDialog { let mut modal = div() .id("worktree-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "WorktreeDialog", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .w(px(480.)) .elevation_3(cx) From 9a5b644eaf2c5976788c5c738231eab0923acaf5 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:40:11 +0100 Subject: [PATCH 07/16] refactor: move the file search and reword editors onto TextInput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two views hand-rolled a text editor inside the same key handler that owned their command shortcuts, and both had the bug that arrangement invites. The detail panel's changed-files filter consumed every printable character in the handler that also owned `j`/`k`/`v`/`[`/`]`, and rendered the query as a `Label` with no cursor. The interactive rebase's reword mode kept `(index, message, cursor)` in a tuple, painted its own cursor out of three `Label`s, and swallowed the very `p`/`r`/`s`/`f`/`d` letters that are commands in normal mode. Both now use the shared `TextInput`, which already handles printable characters, backspace, delete, arrow keys, word motion, selection and clipboard — and which sets the `TextInput` key context. That last part is the point: the bare-letter commands are scoped `!TextInput`, which gpui evaluates false whenever a text field is anywhere on the focus path, so there is no longer an "am I typing?" branch to keep correct. It is the problem class that goes away, not just the bug. Reword state shrinks to `Option` plus the shared field, so the index bookkeeping in move-up/move-down and drag reorder no longer has to carry a message and a cursor along with it. Esc while rewording leaves reword mode and returns focus to the list before it means "dismiss the dialog", and Enter commits the message before it means "run the rebase". Co-Authored-By: Claude Fable 5 --- crates/rgitui_workspace/src/detail_panel.rs | 212 ++++----- .../src/interactive_rebase.rs | 415 ++++++------------ 2 files changed, 235 insertions(+), 392 deletions(-) diff --git a/crates/rgitui_workspace/src/detail_panel.rs b/crates/rgitui_workspace/src/detail_panel.rs index c6ae18e..50fc23f 100644 --- a/crates/rgitui_workspace/src/detail_panel.rs +++ b/crates/rgitui_workspace/src/detail_panel.rs @@ -4,8 +4,8 @@ use std::time::{Duration, Instant}; use gpui::prelude::*; use gpui::{ - div, img, px, uniform_list, App, ClickEvent, ClipboardItem, Context, ElementId, EventEmitter, - FocusHandle, KeyDownEvent, ListSizingBehavior, ObjectFit, Render, SharedString, WeakEntity, + div, img, px, uniform_list, App, ClickEvent, ClipboardItem, Context, ElementId, Entity, + EventEmitter, FocusHandle, ListSizingBehavior, ObjectFit, Render, SharedString, WeakEntity, Window, }; use rgitui_diff::DiffSource; @@ -16,10 +16,12 @@ use rgitui_settings::SettingsState; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ AvatarCache, Badge, ButtonSize, ButtonStyle, DiffStat, Icon, IconButton, IconName, IconSize, - Label, LabelSize, + Label, LabelSize, TextInput, TextInputEvent, }; +use crate::keymap; use crate::markdown_view::render_markdown; +use crate::CommandId; /// Rows the changed-files list keeps when the header and commit message leave it /// little room. Once it is this short the panel scrolls as one region instead. @@ -446,6 +448,12 @@ pub struct DetailPanel { selected_file_index: Option, focus_handle: FocusHandle, copied_field: Option<(&'static str, Instant)>, + /// The changed-files filter field. Owns the text, the cursor and the + /// selection, so the panel's own key handling never has to ask whether the + /// user is typing. + file_search_editor: Entity, + /// Mirror of the filter field's text, kept in sync by its `Changed` event so + /// the pure filtering helpers can stay `cx`-free. file_search_query: Option, file_search_active: bool, file_view_mode: FileViewMode, @@ -459,6 +467,22 @@ impl EventEmitter for DetailPanel {} impl DetailPanel { pub fn new(cx: &mut Context) -> Self { + let file_search_editor = cx.new(|cx| { + let mut input = TextInput::new(cx); + input.set_placeholder("Filter files..."); + input + }); + cx.subscribe( + &file_search_editor, + |this: &mut Self, _, event: &TextInputEvent, cx| { + if let TextInputEvent::Changed(text) = event { + this.file_search_query = (!text.is_empty()).then(|| text.clone()); + cx.notify(); + } + }, + ) + .detach(); + Self { commit: None, commit_diff: None, @@ -468,6 +492,7 @@ impl DetailPanel { selected_file_index: None, focus_handle: cx.focus_handle(), copied_field: None, + file_search_editor, file_search_query: None, file_search_active: false, file_view_mode: FileViewMode::default(), @@ -525,121 +550,81 @@ impl DetailPanel { .unwrap_or(0) } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - let modifiers = &event.keystroke.modifiers; + /// Runs a keyboard command scoped to `DetailPanel` or to the shared `List` + /// group. + /// + /// There is no "am I typing?" branch: the filter field is a real + /// [`TextInput`], so the `!TextInput` half of the bare-letter contexts keeps + /// `v`, `[`, `]` and `/` out of the way while it has focus, and the arrow + /// keys keep working because they are not scoped that way. + fn dispatch_command(&mut self, cmd: CommandId, window: &mut Window, cx: &mut Context) { let file_count = self.file_count(); - // Commit prev/next navigation — works regardless of file count - match key { - "[" => { + match cmd { + // Commit navigation works regardless of file count. + CommandId::PrevCommitDetails => { if self.commit.is_some() { cx.emit(DetailPanelEvent::NavigatePrevCommit); } } - "]" => { + CommandId::NextCommitDetails => { if self.commit.is_some() { cx.emit(DetailPanelEvent::NavigateNextCommit); } } - _ => {} - } - - // File search toggle: / or Ctrl+F - if (key == "/" && !modifiers.secondary()) || (modifiers.secondary() && key == "f") { - self.file_search_active = true; - cx.notify(); - return; - } - - // Escape clears search - if key == "escape" { - if self.file_search_query.is_some() || self.file_search_active { - self.file_search_query = None; - self.file_search_active = false; + CommandId::FileSearch => { + self.file_search_active = true; + self.file_search_editor + .update(cx, |editor, cx| editor.focus(window, cx)); cx.notify(); } - return; + CommandId::Cancel => self.clear_file_search(cx), + _ if file_count == 0 => {} + CommandId::SelectNext => self.select_file( + match self.selected_file_index { + Some(i) if i + 1 < file_count => Some(i + 1), + None => Some(0), + other => other, + }, + cx, + ), + CommandId::SelectPrev => self.select_file( + match self.selected_file_index { + Some(i) if i > 0 => Some(i - 1), + None => Some(0), + other => other, + }, + cx, + ), + CommandId::SelectFirst => self.select_file(Some(0), cx), + CommandId::SelectLast => self.select_file(Some(file_count - 1), cx), + CommandId::ToggleFileTree => self.request_file_view_mode_toggle(cx), + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } + } - // When search is active, capture printable characters as query - if self.file_search_active { - // Use key_char for printable input (same as TextInput) - if let Some(kc) = &event.keystroke.key_char { - let ch = kc.to_lowercase().chars().next().unwrap_or(' '); - let new_query = match &mut self.file_search_query { - Some(q) => { - q.push(ch); - q.clone() - } - None => ch.to_string(), - }; - self.file_search_query = Some(new_query); - cx.notify(); - } else if key == "backspace" { - if let Some(q) = &mut self.file_search_query { - q.pop(); - if q.is_empty() { - self.file_search_query = None; - } - } - cx.notify(); - } + /// Moves the changed-files selection, emitting only on an actual change. + fn select_file(&mut self, index: Option, cx: &mut Context) { + if index == self.selected_file_index { return; } + self.selected_file_index = index; + self.emit_file_selected(cx); + cx.notify(); + } - if file_count == 0 { + /// Closes the changed-files filter and clears its query. + fn clear_file_search(&mut self, cx: &mut Context) { + if self.file_search_query.is_none() && !self.file_search_active { return; } - - match key { - "j" | "down" => { - let next = match self.selected_file_index { - Some(i) if i + 1 < file_count => Some(i + 1), - None => Some(0), - other => other, - }; - if next != self.selected_file_index { - self.selected_file_index = next; - self.emit_file_selected(cx); - cx.notify(); - } - } - "k" | "up" => { - let next = match self.selected_file_index { - Some(i) if i > 0 => Some(i - 1), - None if file_count > 0 => Some(0), - other => other, - }; - if next != self.selected_file_index { - self.selected_file_index = next; - self.emit_file_selected(cx); - cx.notify(); - } - } - "home" | "g" if self.selected_file_index != Some(0) => { - self.selected_file_index = Some(0); - self.emit_file_selected(cx); - cx.notify(); - } - "end" => { - let last = file_count.saturating_sub(1); - if self.selected_file_index != Some(last) { - self.selected_file_index = Some(last); - self.emit_file_selected(cx); - cx.notify(); - } - } - "v" => { - self.request_file_view_mode_toggle(cx); - } - _ => {} - } + self.file_search_query = None; + self.file_search_active = false; + self.file_search_editor + .update(cx, |editor, cx| editor.clear(cx)); + cx.notify(); } fn request_file_view_mode_toggle(&mut self, cx: &mut Context) { @@ -1335,8 +1320,15 @@ impl Render for DetailPanel { let mut panel = div() .id("detail-panel") .track_focus(&self.focus_handle) - .key_context("DetailPanel") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "DetailPanel List", + &["Menu", "DetailPanel"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .size_full() .bg(colors.panel_background); @@ -1819,38 +1811,24 @@ impl Render for DetailPanel { ]; if is_searching { - let query_clone = query_str.clone(); let search_input: gpui::AnyElement = div() .flex_1() .h_flex() .items_center() - .px_2() .gap_1() - .bg(colors.ghost_element_selected) - .border_1() - .border_color(colors.text_accent) - .rounded(px(4.)) .child( Icon::new(IconName::Search) .size(IconSize::XSmall) .color(Color::Muted), ) - .child( - div().flex_1().child( - Label::new(query_clone.clone()) - .size(LabelSize::XSmall) - .color(Color::Default), - ), - ) + .child(div().flex_1().child(self.file_search_editor.clone())) .child( IconButton::new("clear-search", IconName::X) .size(ButtonSize::Compact) .style(ButtonStyle::Transparent) .tooltip("Clear search (Esc)") .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.file_search_query = None; - this.file_search_active = false; - cx.notify(); + this.clear_file_search(cx); })) .into_any_element(), ) diff --git a/crates/rgitui_workspace/src/interactive_rebase.rs b/crates/rgitui_workspace/src/interactive_rebase.rs index d2940ff..9f61486 100644 --- a/crates/rgitui_workspace/src/interactive_rebase.rs +++ b/crates/rgitui_workspace/src/interactive_rebase.rs @@ -1,11 +1,14 @@ use gpui::prelude::*; use gpui::{ - canvas, div, px, App, Bounds, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, - FontWeight, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, Pixels, Render, + canvas, div, px, App, Bounds, ClickEvent, Context, ElementId, Entity, EventEmitter, + FocusHandle, FontWeight, MouseButton, MouseDownEvent, MouseMoveEvent, Pixels, Render, SharedString, WeakEntity, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; -use rgitui_ui::{Button, ButtonSize, ButtonStyle, Label, LabelSize, Tooltip}; +use rgitui_ui::{Button, ButtonSize, ButtonStyle, Label, LabelSize, TextInput, Tooltip}; + +use crate::keymap; +use crate::CommandId; /// The action to perform on a commit during interactive rebase. #[derive(Debug, Clone, PartialEq)] @@ -74,8 +77,14 @@ pub struct InteractiveRebase { entries: Vec, target_ref: String, selected_index: usize, - /// When editing a reword message, this holds (entry index, message, cursor position). - editing_reword: Option<(usize, String, usize)>, + /// Index of the entry whose reword message is being edited, if any. + /// + /// The message itself lives in [`Self::reword_editor`]: reusing the shared + /// text field means normal mode's `p`/`r`/`s`/`f`/`d` are simply scoped + /// `!TextInput` instead of being swallowed by a hand-rolled editor. + editing_reword: Option, + /// Text field backing reword editing. + reword_editor: Entity, focus_handle: FocusHandle, // Drag-to-reorder state @@ -109,6 +118,11 @@ impl InteractiveRebase { target_ref: String::new(), selected_index: 0, editing_reword: None, + reword_editor: cx.new(|cx| { + let mut input = TextInput::new(cx); + input.set_placeholder("Enter new commit message..."); + input + }), focus_handle: cx.focus_handle(), dragging_index: None, drag_hover_index: None, @@ -170,13 +184,7 @@ impl InteractiveRebase { } fn execute(&mut self, cx: &mut Context) { - // Finalize any in-progress reword editing - if let Some((idx, ref msg, _)) = self.editing_reword { - if idx < self.entries.len() { - self.entries[idx].action = RebaseAction::Reword(msg.clone()); - } - } - self.editing_reword = None; + self.commit_reword(cx); // Cancel any in-progress drag self.dragging_index = None; @@ -189,53 +197,83 @@ impl InteractiveRebase { cx.notify(); } - fn set_action_on_selected(&mut self, action: RebaseAction, cx: &mut Context) { - if let Some(entry) = self.entries.get_mut(self.selected_index) { - // If switching to reword, start editing - if matches!(&action, RebaseAction::Reword(_)) { - let msg = entry.original_message.clone(); - let len = msg.len(); - self.editing_reword = Some((self.selected_index, msg, len)); - } else { - // If we were editing this entry's reword, cancel editing - if let Some((edit_idx, _, _)) = &self.editing_reword { - if *edit_idx == self.selected_index { - self.editing_reword = None; - } + fn set_action_on_selected( + &mut self, + action: RebaseAction, + window: Option<&mut Window>, + cx: &mut Context, + ) { + self.set_action_on(self.selected_index, action, window, cx); + } + + fn cycle_action(&mut self, index: usize, cx: &mut Context) { + let Some(entry) = self.entries.get(index) else { + return; + }; + let next = entry.action.next(); + self.set_action_on(index, next, None, cx); + } + + /// Applies `action` to the entry at `index`, starting or stopping reword + /// editing to match. Passing a `window` focuses the reword field, which is + /// what makes the bare-letter action shortcuts stand down while typing. + fn set_action_on( + &mut self, + index: usize, + action: RebaseAction, + window: Option<&mut Window>, + cx: &mut Context, + ) { + let Some(entry) = self.entries.get_mut(index) else { + return; + }; + let original_message = entry.original_message.clone(); + let starts_reword = matches!(&action, RebaseAction::Reword(_)); + entry.action = action; + + if starts_reword { + self.editing_reword = Some(index); + self.reword_editor.update(cx, |editor, cx| { + editor.set_text(original_message, cx); + if let Some(window) = window { + editor.focus(window, cx); } - } - entry.action = action; - cx.notify(); + }); + } else if self.editing_reword == Some(index) { + self.editing_reword = None; } + cx.notify(); } - fn cycle_action(&mut self, index: usize, cx: &mut Context) { + /// Writes the reword field's text back into the entry being edited and + /// leaves reword mode. + fn commit_reword(&mut self, cx: &mut Context) { + let Some(index) = self.editing_reword.take() else { + return; + }; + let message = self.reword_editor.read(cx).text().to_string(); if let Some(entry) = self.entries.get_mut(index) { - let next = entry.action.next(); - // If cycling to reword, start editing - if matches!(&next, RebaseAction::Reword(_)) { - let msg = entry.original_message.clone(); - let len = msg.len(); - self.editing_reword = Some((index, msg, len)); - } else { - // If we were editing this entry's reword, cancel editing - if let Some((edit_idx, _, _)) = &self.editing_reword { - if *edit_idx == index { - self.editing_reword = None; - } - } - } - entry.action = next; - cx.notify(); + entry.action = RebaseAction::Reword(message); } } + /// Abandons reword editing, restoring the entry to `pick`. + fn abandon_reword(&mut self, cx: &mut Context) { + let Some(index) = self.editing_reword.take() else { + return; + }; + if let Some(entry) = self.entries.get_mut(index) { + entry.action = RebaseAction::Pick; + } + cx.notify(); + } + fn move_entry_up(&mut self, cx: &mut Context) { if self.selected_index > 0 && self.selected_index < self.entries.len() { self.entries .swap(self.selected_index, self.selected_index - 1); // Update editing index if needed - if let Some((ref mut edit_idx, _, _)) = self.editing_reword { + if let Some(edit_idx) = self.editing_reword.as_mut() { if *edit_idx == self.selected_index { *edit_idx -= 1; } else if *edit_idx == self.selected_index - 1 { @@ -252,7 +290,7 @@ impl InteractiveRebase { self.entries .swap(self.selected_index, self.selected_index + 1); // Update editing index if needed - if let Some((ref mut edit_idx, _, _)) = self.editing_reword { + if let Some(edit_idx) = self.editing_reword.as_mut() { if *edit_idx == self.selected_index { *edit_idx += 1; } else if *edit_idx == self.selected_index + 1 { @@ -340,13 +378,9 @@ impl InteractiveRebase { }; if drag_idx != hover_idx { - let mut edit_idx: Option = self.editing_reword.as_ref().map(|(i, _, _)| *i); + let mut edit_idx = self.editing_reword; Self::apply_drag_reorder(&mut self.entries, drag_idx, hover_idx, &mut edit_idx); - if let Some(ref mut editing) = self.editing_reword { - if let Some(idx) = edit_idx { - editing.0 = idx; - } - } + self.editing_reword = edit_idx; // Keep selection on the moved entry self.selected_index = hover_idx; } @@ -357,105 +391,21 @@ impl InteractiveRebase { cx.notify(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let keystroke = &event.keystroke; - let key = keystroke.key.as_str(); - let modifiers = &keystroke.modifiers; - - // If editing a reword message, handle text input - if let Some((_, ref mut msg, ref mut cursor)) = self.editing_reword { - match key { - "escape" => { - let Some((idx, _, _)) = self.editing_reword.take() else { - return; - }; - if let Some(entry) = self.entries.get_mut(idx) { - entry.action = RebaseAction::Pick; - } - cx.notify(); - return; - } - "enter" => { - let Some((idx, msg, _)) = self.editing_reword.take() else { - return; - }; - if let Some(entry) = self.entries.get_mut(idx) { - entry.action = RebaseAction::Reword(msg); - } - cx.notify(); - return; - } - "backspace" => { - if *cursor > 0 { - *cursor -= 1; - msg.remove(*cursor); - } - cx.notify(); - return; - } - "delete" => { - if *cursor < msg.len() { - msg.remove(*cursor); - } - cx.notify(); - return; - } - "left" => { - if *cursor > 0 { - *cursor -= 1; - } - cx.notify(); - return; - } - "right" => { - if *cursor < msg.len() { - *cursor += 1; - } - cx.notify(); - return; - } - "home" => { - *cursor = 0; - cx.notify(); - return; - } - "end" => { - *cursor = msg.len(); - cx.notify(); - return; - } - _ => { - if let Some(key_char) = &keystroke.key_char { - msg.insert_str(*cursor, key_char); - *cursor += key_char.len(); - cx.notify(); - return; - } else if key.len() == 1 && !modifiers.control && !modifiers.platform { - let Some(ch) = key.chars().next() else { - return; - }; - if ch.is_ascii_graphic() || ch == ' ' { - msg.insert(*cursor, ch); - *cursor += 1; - cx.notify(); - return; - } - } - return; - } - } - } - - // Normal mode key handling - match key { - "escape" => { - // Cancel any in-progress drag, then dismiss - if self.dragging_index.is_some() { + /// Runs a keyboard command scoped to `InteractiveRebase` or to the shared + /// `List` group. + /// + /// Reword editing needs no branch here: the message field is a real + /// [`TextInput`], so while it has focus the `!TextInput` half of the + /// bare-letter contexts keeps `p`/`r`/`s`/`f`/`d` out of the way, and Esc and + /// Enter fall through to the arms below only after it has taken what it needs. + fn dispatch_command(&mut self, cmd: CommandId, window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => { + if self.editing_reword.is_some() { + // Leaving reword mode is the first meaning of Esc here. + self.abandon_reword(cx); + self.focus_handle.focus(window, cx); + } else if self.dragging_index.is_some() { self.dragging_index = None; self.drag_hover_index = None; cx.notify(); @@ -463,51 +413,52 @@ impl InteractiveRebase { self.dismiss(cx); } } - "enter" => { - self.execute(cx); + CommandId::Confirm => { + if self.editing_reword.is_some() { + self.commit_reword(cx); + self.focus_handle.focus(window, cx); + cx.notify(); + } else { + self.execute(cx); + } } - "up" | "k" if !modifiers.secondary() => { + CommandId::SelectPrev => { if self.selected_index > 0 { self.selected_index -= 1; cx.notify(); } } - "down" | "j" if !modifiers.secondary() => { + CommandId::SelectNext => { if self.selected_index + 1 < self.entries.len() { self.selected_index += 1; cx.notify(); } } - "up" if modifiers.secondary() => { - self.move_entry_up(cx); + CommandId::SelectFirst => { + self.selected_index = 0; + cx.notify(); } - "down" if modifiers.secondary() => { - self.move_entry_down(cx); + CommandId::SelectLast => { + self.selected_index = self.entries.len().saturating_sub(1); + cx.notify(); } - _ => { - // Action shortcuts - if !modifiers.control && !modifiers.platform { - if let Some(key_char) = keystroke.key_char.as_deref().or(Some(key)) { - match key_char { - "p" => self.set_action_on_selected(RebaseAction::Pick, cx), - "r" => { - self.set_action_on_selected(RebaseAction::Reword(String::new()), cx) - } - "s" => self.set_action_on_selected(RebaseAction::Squash, cx), - "f" => self.set_action_on_selected(RebaseAction::Fixup, cx), - "d" => self.set_action_on_selected(RebaseAction::Drop, cx), - _ => {} - } - } - } + CommandId::RebaseMoveUp => self.move_entry_up(cx), + CommandId::RebaseMoveDown => self.move_entry_down(cx), + CommandId::RebasePick => self.set_action_on_selected(RebaseAction::Pick, None, cx), + CommandId::RebaseReword => { + self.set_action_on_selected(RebaseAction::Reword(String::new()), Some(window), cx) } + CommandId::RebaseSquash => self.set_action_on_selected(RebaseAction::Squash, None, cx), + CommandId::RebaseFixup => self.set_action_on_selected(RebaseAction::Fixup, None, cx), + CommandId::RebaseDrop => self.set_action_on_selected(RebaseAction::Drop, None, cx), + _ => cx.propagate(), } } } impl Render for InteractiveRebase { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let colors = cx.colors(); + let colors = cx.colors().clone(); if !self.visible { return div().id("interactive-rebase").into_any_element(); @@ -578,20 +529,9 @@ impl Render for InteractiveRebase { let is_dropped = matches!(entry.action, RebaseAction::Drop); // Determine what message to display - let is_editing = self - .editing_reword - .as_ref() - .is_some_and(|(edit_idx, _, _)| *edit_idx == idx); + let is_editing = self.editing_reword == Some(idx); - let display_msg: SharedString = if let Some((_, ref msg, _)) = - self.editing_reword.as_ref().filter(|_| is_editing) - { - if msg.is_empty() { - "Enter new commit message...".into() - } else { - SharedString::from(msg.clone()) - } - } else if let RebaseAction::Reword(ref msg) = entry.action { + let display_msg: SharedString = if let RebaseAction::Reword(ref msg) = entry.action { if msg.is_empty() { SharedString::from(entry.original_message.clone()) } else { @@ -603,16 +543,6 @@ impl Render for InteractiveRebase { let msg_color = if is_dropped { Color::Disabled - } else if is_editing { - if self - .editing_reword - .as_ref() - .is_some_and(|(_, msg, _)| msg.is_empty()) - { - Color::Placeholder - } else { - Color::Default - } } else { Color::Default }; @@ -746,80 +676,7 @@ impl Render for InteractiveRebase { // Message (or reword editor) if is_editing { - let Some((_, ref msg, cursor)) = self.editing_reword.as_ref() else { - continue; - }; - let cursor = *cursor; - let text_color = colors.text; - let editor_bg = colors.editor_background; - let border_focused = colors.border_focused; - - let mut input_row = div().h_flex().items_center().flex_1(); - - if msg.is_empty() { - input_row = input_row - .child(div().w(px(2.)).h(px(14.)).bg(text_color)) - .child( - Label::new("Enter new commit message...") - .size(LabelSize::Small) - .color(Color::Placeholder), - ); - } else { - let before = &msg[..cursor]; - let cursor_char = if cursor < msg.len() { - &msg[cursor..cursor + 1] - } else { - "" - }; - let after = if cursor + 1 < msg.len() { - &msg[cursor + 1..] - } else { - "" - }; - - if !before.is_empty() { - input_row = input_row.child( - Label::new(SharedString::from(before.to_string())) - .size(LabelSize::Small), - ); - } - if !cursor_char.is_empty() { - input_row = input_row.child( - div().bg(text_color).child( - Label::new(SharedString::from(cursor_char.to_string())) - .size(LabelSize::Small) - .color(Color::Custom(gpui::Hsla { - h: 0.0, - s: 0.0, - l: 0.0, - a: 1.0, - })), - ), - ); - } else { - input_row = input_row.child(div().w(px(2.)).h(px(14.)).bg(text_color)); - } - if !after.is_empty() { - input_row = input_row.child( - Label::new(SharedString::from(after.to_string())) - .size(LabelSize::Small), - ); - } - } - - row = row.child( - div() - .flex_1() - .h(px(24.)) - .px_1() - .bg(editor_bg) - .border_1() - .border_color(border_focused) - .rounded(px(4.)) - .h_flex() - .items_center() - .child(input_row), - ); + row = row.child(div().flex_1().child(self.reword_editor.clone())); } else { // Strikethrough for dropped commits if is_dropped { @@ -857,7 +714,15 @@ impl Render for InteractiveRebase { let modal = div() .id("interactive-rebase-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "InteractiveRebase List", + &["Menu", "InteractiveRebase"], + cx, + Self::dispatch_command, + ) + }) .on_mouse_down( gpui::MouseButton::Left, |_: &gpui::MouseDownEvent, _, cx| { From 4d24fc71f71e59186d69da4a267698d7f6836e26 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:44:09 +0100 Subject: [PATCH 08/16] feat(graph): add multi-select to the commit graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The graph could only ever hold one selected commit, which rules out every operation over a range. Give it an arbitrary set instead: a set of commit indices plus the anchor a range extension grows from. Members are commit indices rather than list indices, so a virtual worktree row can never join the selection and inserting or removing one leaves it alone — the row ↔ commit mapping is now two pure functions over the sorted worktree rows, unit-tested against real `compute_worktree_row_positions` output. Reloading the commit list remaps the selection by OID, as the single selection already did. Gestures: shift-click extends from the anchor, secondary-click toggles one row, and `shift-j`/`shift-k`/`shift-down`/`shift-up` extend by one commit, stepping over worktree rows. Plain clicks and plain `j`/`k` still collapse onto one commit and re-anchor there, so single selection behaves exactly as before. `CommitSelected` is emitted only while exactly one commit is selected, so growing a selection does not fire a diff computation per row; the new `SelectionChanged` event carries the rest. Co-Authored-By: Claude Fable 5 --- crates/rgitui_graph/src/lib.rs | 621 +++++++++++++++++- .../rgitui_workspace/src/keymap/registry.rs | 4 + .../src/workspace/commands.rs | 4 + .../rgitui_workspace/src/workspace/events.rs | 6 + docs/KEYBINDINGS.md | 2 + docs/keymap.schema.json | 8 + 6 files changed, 621 insertions(+), 24 deletions(-) diff --git a/crates/rgitui_graph/src/lib.rs b/crates/rgitui_graph/src/lib.rs index f306238..bd451a6 100644 --- a/crates/rgitui_graph/src/lib.rs +++ b/crates/rgitui_graph/src/lib.rs @@ -134,6 +134,10 @@ fn quarter_arc_offsets() -> &'static [(f32, f32)] { #[derive(Debug, Clone)] pub enum GraphViewEvent { CommitSelected(git2::Oid), + /// The set of selected commits changed. Emitted alongside `CommitSelected` + /// for a single selection, and on its own while a multi-selection is built, + /// so command availability can follow without a diff being recomputed. + SelectionChanged, CherryPick(git2::Oid), RevertCommit(git2::Oid), CreateBranchAtCommit(git2::Oid), @@ -373,6 +377,130 @@ fn worktree_targets_commit_immediately_above( }) } +/// The commit index displayed at `list_index`, or `None` when that row is a +/// virtual worktree row. +/// +/// `worktree_rows` is the ascending list of rows occupied by worktree +/// pseudo-nodes: each one shifts every commit below it down by one. +fn commit_index_for_row(worktree_rows: &[usize], list_index: usize) -> Option { + if worktree_rows.binary_search(&list_index).is_ok() { + return None; + } + let virtual_rows_above = worktree_rows.partition_point(|row| *row < list_index); + Some(list_index - virtual_rows_above) +} + +/// The row `commit_index` is displayed at, or `None` when it is outside the +/// window of commits that have a computed graph row. +/// +/// The inverse of [`commit_index_for_row`]. `worktree_rows` must be ascending; +/// a worktree row sitting at or above the commit's own row pushes it down, and +/// because the rows are distinct and sorted, `row - ordinal` is non-decreasing — +/// so a single pass is enough. +fn row_for_commit_index( + worktree_rows: &[usize], + commit_count: usize, + commit_index: usize, +) -> Option { + if commit_index >= commit_count { + return None; + } + let mut row = commit_index; + for worktree_row in worktree_rows { + if *worktree_row <= row { + row += 1; + } + } + Some(row) +} + +/// The set of commits selected in the graph, plus the anchor a range extension +/// grows from. +/// +/// Members are *commit* indices, never list indices, so a virtual worktree row +/// can never join the selection and inserting or removing one leaves it alone. +/// The set is arbitrary rather than a single range: ctrl-click punches holes in +/// it, and operations that need a contiguous run (squash, for one) validate that +/// for themselves and explain the failure. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct CommitSelection { + indices: std::collections::BTreeSet, + anchor: Option, + /// The selection as it stood when the anchor was last set. A range extension + /// unions the anchor range onto this, so extending again *replaces* the + /// previous range instead of accumulating every row the cursor passed over. + anchor_base: std::collections::BTreeSet, +} + +impl CommitSelection { + /// A selection restored from commit indices, e.g. after the commit list was + /// reloaded and the previous members were looked up again by OID. + fn from_parts(indices: impl IntoIterator, anchor: Option) -> Self { + let indices: std::collections::BTreeSet = indices.into_iter().collect(); + Self { + anchor: anchor.filter(|_| !indices.is_empty()), + indices, + anchor_base: std::collections::BTreeSet::new(), + } + } + + fn clear(&mut self) { + self.indices.clear(); + self.anchor = None; + self.anchor_base.clear(); + } + + /// Collapses the selection onto one commit — a plain click or a plain + /// `j`/`k`, which must behave exactly as they did before multi-select. + fn replace(&mut self, commit_index: usize) { + self.indices.clear(); + self.indices.insert(commit_index); + self.anchor = Some(commit_index); + // Extending from here selects exactly the anchor range. + self.anchor_base.clear(); + } + + /// Adds or removes one commit, leaving the rest of the selection alone, and + /// re-anchors so a following range extension grows from this commit. + fn toggle(&mut self, commit_index: usize) { + if !self.indices.remove(&commit_index) { + self.indices.insert(commit_index); + } + self.anchor = Some(commit_index); + self.anchor_base = self.indices.clone(); + } + + /// Selects the inclusive range between the anchor and `commit_index`, on top + /// of whatever was selected when the anchor was set. + fn extend_to(&mut self, commit_index: usize) { + let Some(anchor) = self.anchor else { + self.replace(commit_index); + return; + }; + let (first, last) = (anchor.min(commit_index), anchor.max(commit_index)); + self.indices = self.anchor_base.clone(); + self.indices.extend(first..=last); + } + + fn contains(&self, commit_index: usize) -> bool { + self.indices.contains(&commit_index) + } + + fn len(&self) -> usize { + self.indices.len() + } + + fn anchor(&self) -> Option { + self.anchor + } + + /// Members in ascending commit order — newest commit first, since the commit + /// list itself is ordered newest first. + fn iter(&self) -> impl Iterator + '_ { + self.indices.iter().copied() + } +} + /// The commit graph panel. pub struct GraphView { commits: Arc>, @@ -380,6 +508,12 @@ pub struct GraphView { global_max_lane: usize, selected_index: Option, selected_oid: Option, + /// Every selected commit. The cursor (`selected_index`) is the member that + /// drives the diff and detail panels; the rest are highlighted only. + selection: CommitSelection, + /// [`Self::selection`] projected onto list indices, so a row can test its own + /// membership without walking the set on every frame. + selected_rows: Arc>, row_height: f32, scroll_handle: UniformListScrollHandle, context_menu: Option, @@ -404,7 +538,9 @@ pub struct GraphView { search_debounce_task: Option>, worktree_infos: Vec, worktree_row_positions: Vec, - worktree_row_set: HashSet, + /// Ascending list indices occupied by worktree pseudo-nodes. Drives the + /// list-index ↔ commit-index mapping. + worktree_rows: Vec, virtual_rows_prefix: Arc>, show_settings_popover: bool, /// SHA display length: 0 = default short (7), or specific length (7/8/10/12/40). @@ -460,6 +596,8 @@ impl GraphView { global_max_lane: 0, selected_index: None, selected_oid: None, + selection: CommitSelection::default(), + selected_rows: Arc::new(HashSet::new()), row_height: 32.0, scroll_handle: UniformListScrollHandle::new(), context_menu: None, @@ -477,7 +615,7 @@ impl GraphView { search_debounce_task: None, worktree_infos: Vec::new(), worktree_row_positions: Vec::new(), - worktree_row_set: HashSet::new(), + worktree_rows: Vec::new(), virtual_rows_prefix: Arc::new(Vec::new()), show_settings_popover: false, sha_display_length: 0, @@ -557,7 +695,20 @@ impl GraphView { let prev_selected_oid = this.selected_oid; let prev_selected_index = this.selected_index; let prev_selected_worktree = - prev_selected_index.is_some_and(|index| this.worktree_row_set.contains(&index)); + prev_selected_index.is_some_and(|index| this.is_worktree_row(index)); + // The multi-selection is remembered by OID too: commit indices + // shift whenever commits are loaded, filtered or reordered. + let prev_selected_oids: Vec = this + .selection + .iter() + .filter_map(|commit_index| this.commits.get(commit_index)) + .map(|commit| commit.oid) + .collect(); + let prev_anchor_oid = this + .selection + .anchor() + .and_then(|commit_index| this.commits.get(commit_index)) + .map(|commit| commit.oid); this.global_max_lane = graph_rows .iter() @@ -592,6 +743,17 @@ impl GraphView { this.selected_index = None; } + // Members that survived the reload keep their place in the + // selection; the rest are dropped along with their commits. + let restored: Vec = prev_selected_oids + .iter() + .filter_map(|oid| this.commits.iter().position(|commit| commit.oid == *oid)) + .collect(); + let restored_anchor = prev_anchor_oid + .and_then(|oid| this.commits.iter().position(|commit| commit.oid == oid)); + this.selection = CommitSelection::from_parts(restored, restored_anchor); + this.sync_selected_rows(); + if this.show_search && !this.search_editor.read(cx).is_empty() { this.update_search_filter(cx); } @@ -664,22 +826,29 @@ impl GraphView { &self.worktree_infos, self.global_max_lane, ); - self.worktree_row_set.clear(); - for position in &self.worktree_row_positions { - self.worktree_row_set.insert(position.list_index); - } + // `compute_worktree_row_positions` returns positions sorted by list index, + // which is what the mapping helpers rely on. + self.worktree_rows = self + .worktree_row_positions + .iter() + .map(|position| position.list_index) + .collect(); + debug_assert!(self.worktree_rows.windows(2).all(|pair| pair[0] < pair[1])); let visible_commit_count = self.commits.len().min(self.graph_rows.len()); let total_items = visible_commit_count + self.worktree_row_positions.len(); let mut virtual_rows_prefix = vec![0; total_items]; let mut virtual_count = 0; for (list_index, prefix) in virtual_rows_prefix.iter_mut().enumerate() { - if self.worktree_row_set.contains(&list_index) { + if self.worktree_rows.binary_search(&list_index).is_ok() { virtual_count += 1; } *prefix = virtual_count; } self.virtual_rows_prefix = Arc::new(virtual_rows_prefix); + // Rows shift when a worktree row appears or disappears, so the projection + // of the selection onto list indices has to follow. + self.sync_selected_rows(); } fn worktree_row_at_list_index(&self, list_index: usize) -> Option<&WorktreeRowPosition> { @@ -688,28 +857,34 @@ impl GraphView { .find(|position| position.list_index == list_index) } + /// Whether `list_index` is a virtual worktree row rather than a commit. + fn is_worktree_row(&self, list_index: usize) -> bool { + self.worktree_rows.binary_search(&list_index).is_ok() + } + fn commit_index_for_list_index(&self, list_index: usize) -> Option { - if self.worktree_row_set.contains(&list_index) { + if list_index >= self.total_list_items() { return None; } - self.virtual_rows_prefix - .get(list_index) - .map(|virtual_count| list_index - *virtual_count) + commit_index_for_row(&self.worktree_rows, list_index) } fn list_index_for_commit_index(&self, commit_index: usize) -> Option { - if commit_index >= self.commits.len().min(self.graph_rows.len()) { - return None; - } - let virtual_rows_before = self - .worktree_row_positions + row_for_commit_index( + &self.worktree_rows, + self.commits.len().min(self.graph_rows.len()), + commit_index, + ) + } + + /// Re-projects [`Self::selection`] onto the list indices the rows are drawn at. + fn sync_selected_rows(&mut self) { + let rows: HashSet = self + .selection .iter() - .enumerate() - .filter(|(ordinal, position)| { - position.list_index.saturating_sub(*ordinal) <= commit_index - }) - .count(); - Some(commit_index + virtual_rows_before) + .filter_map(|commit_index| self.list_index_for_commit_index(commit_index)) + .collect(); + self.selected_rows = Arc::new(rows); } /// Total number of list items (virtual worktree rows + commits). @@ -730,6 +905,30 @@ impl GraphView { self.selected_index } + /// Every selected commit, newest first. + /// + /// One plain click or one `j` leaves exactly one entry here, so callers that + /// only care about the cursor can keep using [`Self::selected_commit`]. + pub fn selected_commits(&self) -> Vec<&CommitInfo> { + self.selection + .iter() + .filter_map(|commit_index| self.commits.get(commit_index)) + .collect() + } + + /// The OIDs of [`Self::selected_commits`], newest first. + pub fn selected_commit_oids(&self) -> Vec { + self.selected_commits() + .into_iter() + .map(|commit| commit.oid) + .collect() + } + + /// How many commits are selected. Zero while a worktree row is the cursor. + pub fn selected_commit_count(&self) -> usize { + self.selection.len() + } + pub fn commit_count(&self) -> usize { self.commits.len() } @@ -816,12 +1015,22 @@ impl GraphView { } /// Select an item by its index in the uniform list (accounts for working tree row). + /// + /// This is the plain-click / plain-`j` path, so it collapses any multi-selection + /// back onto the one row and re-anchors range extension there. fn select_list_index(&mut self, list_index: usize, cx: &mut Context) { let total = self.total_list_items(); if list_index >= total { return; } self.selected_index = Some(list_index); + match self.commit_index_for_list_index(list_index) { + Some(commit_index) => self.selection.replace(commit_index), + // A worktree row is not a commit, so nothing stays selected. + None => self.selection.clear(), + } + self.sync_selected_rows(); + cx.emit(GraphViewEvent::SelectionChanged); if let Some(worktree_idx) = self .worktree_row_at_list_index(list_index) .map(|position| position.worktree_idx) @@ -842,6 +1051,137 @@ impl GraphView { cx.notify(); } + /// The commit the cursor sits on, or `None` when it sits on a worktree row. + fn cursor_commit_index(&self) -> Option { + self.selected_index + .and_then(|list_index| self.commit_index_for_list_index(list_index)) + } + + /// Adds or removes one row without disturbing the rest of the selection — + /// the secondary-click gesture. + /// + /// Worktree rows carry no commit, so a toggle on one is ignored rather than + /// letting a virtual row into the selection. + pub fn toggle_selection_at_list_index(&mut self, list_index: usize, cx: &mut Context) { + let Some(commit_index) = self.commit_index_for_list_index(list_index) else { + return; + }; + if commit_index >= self.commits.len() { + return; + } + self.selection.toggle(commit_index); + if self.selection.contains(commit_index) { + // Move the cursor onto the row that was just added, so a following + // extension grows from where the user last clicked. + self.selected_index = Some(list_index); + self.selected_oid = self.commits.get(commit_index).map(|commit| commit.oid); + } + self.sync_selected_rows(); + self.emit_single_selection(cx); + cx.emit(GraphViewEvent::SelectionChanged); + cx.notify(); + } + + /// Extends the selection from the anchor to `list_index` — the shift-click + /// gesture. A worktree row has no commit to extend to, so it is ignored. + pub fn extend_selection_to_list_index(&mut self, list_index: usize, cx: &mut Context) { + let Some(commit_index) = self.commit_index_for_list_index(list_index) else { + return; + }; + self.extend_selection_to_commit(commit_index, None, cx); + } + + /// Extends the selection down one commit, keeping the anchor put. + pub fn extend_selection_next(&mut self, cx: &mut Context) { + self.extend_selection_by_one(true, cx); + } + + /// Extends the selection up one commit, keeping the anchor put. + pub fn extend_selection_prev(&mut self, cx: &mut Context) { + self.extend_selection_by_one(false, cx); + } + + /// Moves the cursor one commit and extends the selection to it. + /// + /// The step is taken in *commit* space, so a virtual worktree row between two + /// commits is stepped straight over: it can never be part of the selection. + fn extend_selection_by_one(&mut self, forward: bool, cx: &mut Context) { + let commit_count = self.commits.len().min(self.graph_rows.len()); + if commit_count == 0 { + return; + } + let Some(cursor) = self.cursor_commit_index() else { + // The cursor is on a worktree row (or nowhere): there is no commit to + // extend from, so fall back to plain movement. + if forward { + self.select_next_row(cx); + } else { + self.select_prev_row(cx); + } + return; + }; + let target = if forward { + cursor + 1 + } else { + match cursor.checked_sub(1) { + Some(target) => target, + None => return, + } + }; + if target >= commit_count { + return; + } + self.extend_selection_to_commit(target, Some(ScrollStrategy::Center), cx); + } + + fn extend_selection_to_commit( + &mut self, + commit_index: usize, + scroll: Option, + cx: &mut Context, + ) { + let Some(list_index) = self.list_index_for_commit_index(commit_index) else { + return; + }; + if self.selection.anchor().is_none() { + // Nothing anchored yet: anchor where the cursor is, so shift-j from a + // fresh selection grows from the current row rather than jumping. + let anchor = self.cursor_commit_index().unwrap_or(commit_index); + self.selection.replace(anchor); + } + self.selection.extend_to(commit_index); + self.selected_index = Some(list_index); + self.selected_oid = self.commits.get(commit_index).map(|commit| commit.oid); + self.sync_selected_rows(); + self.emit_single_selection(cx); + cx.emit(GraphViewEvent::SelectionChanged); + if let Some(strategy) = scroll { + self.scroll_handle.scroll_to_item(list_index, strategy); + } + cx.notify(); + } + + /// Emits `CommitSelected` only while exactly one commit is selected. + /// + /// Growing a multi-selection must not fire a diff computation per row, so the + /// diff and detail panels keep showing whatever single commit was last chosen. + fn emit_single_selection(&mut self, cx: &mut Context) { + if self.selection.len() != 1 { + return; + } + let Some(commit_index) = self.selection.iter().next() else { + return; + }; + let Some(oid) = self.commits.get(commit_index).map(|commit| commit.oid) else { + return; + }; + if let Some(list_index) = self.list_index_for_commit_index(commit_index) { + self.selected_index = Some(list_index); + } + self.selected_oid = Some(oid); + cx.emit(GraphViewEvent::CommitSelected(oid)); + } + /// Toggle the search bar visibility. Clears query when hiding. pub fn toggle_search(&mut self, cx: &mut Context) { self.show_search = !self.show_search; @@ -1187,6 +1527,7 @@ impl Render for GraphView { let commits = self.commits.clone(); let graph_rows = self.graph_rows.clone(); let selected_index = self.selected_index; + let selected_rows = Arc::clone(&self.selected_rows); let worktree_infos = self.worktree_infos.clone(); let worktree_row_positions = self.worktree_row_positions.clone(); let virtual_rows_prefix = self.virtual_rows_prefix.clone(); @@ -1583,7 +1924,9 @@ impl Render for GraphView { let commit = &commits[commit_idx]; let oid = commit.oid; let graph_row = &graph_rows[commit_idx]; - let selected = selected_index == Some(i); + // Every member of a multi-selection is highlighted the same + // way as a lone selection; the cursor is `selected_index`. + let selected = selected_index == Some(i) || selected_rows.contains(&i); let is_current_match = current_match_index == Some(commit_idx); let is_any_match = has_search_query && filter_match_set.contains(&commit_idx); let is_head_row = graph_row.is_head; @@ -1708,9 +2051,14 @@ impl Render for GraphView { return; } this.dismiss_context_menu(cx); + let modifiers = event.modifiers(); if event.click_count() >= 2 { // Double-click: checkout this commit cx.emit(GraphViewEvent::CheckoutCommit(oid)); + } else if modifiers.shift { + this.extend_selection_to_list_index(i, cx); + } else if modifiers.secondary() { + this.toggle_selection_at_list_index(i, cx); } else { this.select_list_index(i, cx); } @@ -3903,4 +4251,229 @@ mod tests { let result = format_relative_time(&t, now); assert_eq!(result, "1y ago"); } + + // ── Row ↔ commit index mapping ──────────────────────────────────── + + /// The list indices worktree rows occupy for a real layout, so the mapping + /// tests below are pinned to what `compute_worktree_row_positions` produces + /// rather than to a hand-guessed arrangement. + fn worktree_rows_for(commits: &[CommitInfo], worktrees: &[WorktreeGraphInfo]) -> Vec { + let graph_rows = compute_graph(commits); + compute_worktree_row_positions( + commits, + &graph_rows, + worktrees, + graph_lane_count(&graph_rows), + ) + .iter() + .map(|position| position.list_index) + .collect() + } + + #[test] + fn rows_map_to_commits_one_to_one_without_worktree_rows() { + for commit_index in 0..3 { + assert_eq!( + row_for_commit_index(&[], 3, commit_index), + Some(commit_index) + ); + assert_eq!(commit_index_for_row(&[], commit_index), Some(commit_index)); + } + assert_eq!(row_for_commit_index(&[], 3, 3), None); + } + + #[test] + fn an_interleaved_worktree_row_shifts_every_commit_below_it() { + // 3 -> 2 -> 1 with a dirty worktree on commit 2 (commit index 1), whose + // pseudo-node therefore takes the row commit 2 used to sit on. + let commits = vec![ + make_commit(3, &[2], vec![RefLabel::Head]), + make_commit(2, &[1], Vec::new()), + make_commit(1, &[], Vec::new()), + ]; + let worktrees = vec![dirty_worktree("wip", Some(make_oid(2)), false)]; + let worktree_rows = worktree_rows_for(&commits, &worktrees); + assert_eq!(worktree_rows, vec![1]); + + // The commit above the worktree row keeps its row; the ones below shift. + assert_eq!(row_for_commit_index(&worktree_rows, 3, 0), Some(0)); + assert_eq!(row_for_commit_index(&worktree_rows, 3, 1), Some(2)); + assert_eq!(row_for_commit_index(&worktree_rows, 3, 2), Some(3)); + + assert_eq!(commit_index_for_row(&worktree_rows, 0), Some(0)); + // The worktree row itself is not a commit. + assert_eq!(commit_index_for_row(&worktree_rows, 1), None); + assert_eq!(commit_index_for_row(&worktree_rows, 2), Some(1)); + assert_eq!(commit_index_for_row(&worktree_rows, 3), Some(2)); + } + + #[test] + fn several_worktree_rows_round_trip_through_the_mapping() { + let commits = vec![ + make_commit(4, &[3], vec![RefLabel::Head]), + make_commit(3, &[2], Vec::new()), + make_commit(2, &[1], Vec::new()), + make_commit(1, &[], Vec::new()), + ]; + let worktrees = vec![ + dirty_worktree("current", Some(make_oid(4)), true), + dirty_worktree("other", Some(make_oid(2)), false), + ]; + let worktree_rows = worktree_rows_for(&commits, &worktrees); + assert_eq!(worktree_rows.len(), 2); + + for commit_index in 0..commits.len() { + let row = row_for_commit_index(&worktree_rows, commits.len(), commit_index) + .expect("every commit has a row"); + assert!( + !worktree_rows.contains(&row), + "commit {commit_index} was mapped onto worktree row {row}" + ); + assert_eq!( + commit_index_for_row(&worktree_rows, row), + Some(commit_index) + ); + } + for row in &worktree_rows { + assert_eq!(commit_index_for_row(&worktree_rows, *row), None); + } + } + + // ── Selection set mechanics ─────────────────────────────────────── + + fn members(selection: &CommitSelection) -> Vec { + selection.iter().collect() + } + + #[test] + fn a_plain_selection_holds_one_commit_and_anchors_there() { + let mut selection = CommitSelection::default(); + selection.replace(4); + assert_eq!(members(&selection), vec![4]); + assert_eq!(selection.anchor(), Some(4)); + assert_eq!(selection.len(), 1); + + // A second plain selection replaces the first — this is the click and + // `j`/`k` path, which must behave exactly as it did before multi-select. + selection.replace(7); + assert_eq!(members(&selection), vec![7]); + assert_eq!(selection.anchor(), Some(7)); + } + + #[test] + fn toggling_adds_and_removes_single_commits() { + let mut selection = CommitSelection::default(); + selection.replace(2); + selection.toggle(5); + selection.toggle(8); + assert_eq!(members(&selection), vec![2, 5, 8]); + assert!(selection.contains(5)); + + selection.toggle(5); + assert_eq!(members(&selection), vec![2, 8]); + assert!(!selection.contains(5)); + // The anchor follows the toggled row even when it was removed. + assert_eq!(selection.anchor(), Some(5)); + } + + #[test] + fn extending_grows_a_range_from_the_anchor_in_both_directions() { + let mut selection = CommitSelection::default(); + selection.replace(3); + selection.extend_to(5); + assert_eq!(members(&selection), vec![3, 4, 5]); + // Re-extending replaces the previous range instead of accumulating. + selection.extend_to(4); + assert_eq!(members(&selection), vec![3, 4]); + // Crossing the anchor selects the other side of it, anchor unmoved. + selection.extend_to(1); + assert_eq!(members(&selection), vec![1, 2, 3]); + assert_eq!(selection.anchor(), Some(3)); + } + + #[test] + fn extending_keeps_what_was_selected_when_the_anchor_was_set() { + let mut selection = CommitSelection::default(); + selection.replace(0); + selection.toggle(4); + selection.extend_to(6); + assert_eq!(members(&selection), vec![0, 4, 5, 6]); + } + + #[test] + fn extending_without_an_anchor_selects_just_the_target() { + let mut selection = CommitSelection::default(); + selection.extend_to(9); + assert_eq!(members(&selection), vec![9]); + assert_eq!(selection.anchor(), Some(9)); + } + + #[test] + fn a_plain_selection_resets_the_anchor_and_drops_the_rest() { + let mut selection = CommitSelection::default(); + selection.replace(2); + selection.extend_to(6); + assert_eq!(selection.len(), 5); + + selection.replace(6); + assert_eq!(members(&selection), vec![6]); + assert_eq!(selection.anchor(), Some(6)); + // Extending after the reset grows from the new anchor only. + selection.extend_to(8); + assert_eq!(members(&selection), vec![6, 7, 8]); + } + + #[test] + fn clearing_drops_the_selection_and_the_anchor() { + let mut selection = CommitSelection::default(); + selection.replace(1); + selection.extend_to(3); + selection.clear(); + assert_eq!(members(&selection), Vec::::new()); + assert_eq!(selection.anchor(), None); + assert_eq!(selection.len(), 0); + } + + #[test] + fn a_restored_selection_keeps_its_members_and_anchor() { + let selection = CommitSelection::from_parts([5, 2, 3], Some(2)); + assert_eq!(members(&selection), vec![2, 3, 5]); + assert_eq!(selection.anchor(), Some(2)); + + // Everything the reload dropped leaves nothing to anchor to. + let emptied = CommitSelection::from_parts([], Some(2)); + assert_eq!(emptied.anchor(), None); + } + + /// A keyboard extension steps in commit space, so a worktree row between two + /// commits is stepped straight over and the selection stays contiguous — which + /// is what the squash validator needs. + #[test] + fn a_selection_spanning_a_worktree_row_is_contiguous_in_commit_space() { + let commits = vec![ + make_commit(3, &[2], vec![RefLabel::Head]), + make_commit(2, &[1], Vec::new()), + make_commit(1, &[], Vec::new()), + ]; + let worktrees = vec![dirty_worktree("wip", Some(make_oid(2)), false)]; + let worktree_rows = worktree_rows_for(&commits, &worktrees); + assert_eq!(worktree_rows, vec![1]); + + let mut selection = CommitSelection::default(); + selection.replace(0); + selection.extend_to(1); + assert_eq!(members(&selection), vec![0, 1]); + + // The two selected commits are two rows apart because the worktree row + // sits between them, and neither maps onto that row. + let rows: Vec = selection + .iter() + .map(|commit_index| { + row_for_commit_index(&worktree_rows, commits.len(), commit_index) + .expect("selected commits have rows") + }) + .collect(); + assert_eq!(rows, vec![0, 2]); + assert!(rows.iter().all(|row| !worktree_rows.contains(row))); + } } diff --git a/crates/rgitui_workspace/src/keymap/registry.rs b/crates/rgitui_workspace/src/keymap/registry.rs index aae5a34..8280a35 100644 --- a/crates/rgitui_workspace/src/keymap/registry.rs +++ b/crates/rgitui_workspace/src/keymap/registry.rs @@ -239,6 +239,10 @@ commands! { GraphSelectFirst ["home" in "GraphView", "g"] [hidden]; /// Select the oldest loaded commit in the graph. GraphSelectLast ["end" in "GraphView", "shift-g"] [hidden]; + /// Add the next commit in the graph to the selection. + GraphExtendSelectionNext ["shift-down", "shift-j"] [hidden]; + /// Add the previous commit in the graph to the selection. + GraphExtendSelectionPrev ["shift-up", "shift-k"] [hidden]; /// Close the graph search, or dismiss the graph context menu. GraphCancel "escape" in "GraphView" [hidden]; /// Copy the selected commit's SHA to the clipboard. diff --git a/crates/rgitui_workspace/src/workspace/commands.rs b/crates/rgitui_workspace/src/workspace/commands.rs index 527453a..a6373dc 100644 --- a/crates/rgitui_workspace/src/workspace/commands.rs +++ b/crates/rgitui_workspace/src/workspace/commands.rs @@ -89,6 +89,8 @@ impl Workspace { CommandId::GraphSelectPrev => graph.select_prev_row(cx), CommandId::GraphSelectFirst => graph.select_first_row(cx), CommandId::GraphSelectLast => graph.select_last_row(cx), + CommandId::GraphExtendSelectionNext => graph.extend_selection_next(cx), + CommandId::GraphExtendSelectionPrev => graph.extend_selection_prev(cx), CommandId::GraphCancel => graph.cancel(window, cx), CommandId::CopyCommitSha => graph.copy_selected_sha(cx), CommandId::CopyCommitMessage => graph.copy_selected_message(cx), @@ -735,6 +737,8 @@ impl Workspace { | CommandId::GraphSelectPrev | CommandId::GraphSelectFirst | CommandId::GraphSelectLast + | CommandId::GraphExtendSelectionNext + | CommandId::GraphExtendSelectionPrev | CommandId::GraphCancel | CommandId::CopyCommitSha | CommandId::CopyCommitMessage diff --git a/crates/rgitui_workspace/src/workspace/events.rs b/crates/rgitui_workspace/src/workspace/events.rs index 4296ad4..94067cc 100644 --- a/crates/rgitui_workspace/src/workspace/events.rs +++ b/crates/rgitui_workspace/src/workspace/events.rs @@ -1699,6 +1699,12 @@ pub(super) fn subscribe_graph( cx.subscribe(graph, { move |this, _graph, event: &GraphViewEvent, cx| { match event { + // Availability of the multi-commit operations follows the graph + // selection, so the palette context is refreshed here rather than + // only after a repository refresh. + GraphViewEvent::SelectionChanged => { + this.update_command_context(cx); + } GraphViewEvent::CommitSelected(oid) => { let commit_oid = *oid; log::info!("CommitSelected: oid={:.7}", commit_oid); diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index f83a07b..add247f 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -120,6 +120,8 @@ The file is reloaded when you save it. Bindings you add win over the defaults. T | `up` or `k` | `GraphView` or `GraphView && !TextInput` | `graph::GraphSelectPrev` | Select the previous commit in the graph. | | `home` or `g` | `GraphView` or `GraphView && !TextInput` | `graph::GraphSelectFirst` | Select the newest commit in the graph. | | `end` or `shift-g` | `GraphView` or `GraphView && !TextInput` | `graph::GraphSelectLast` | Select the oldest loaded commit in the graph. | +| `shift-down` or `shift-j` | `GraphView && !TextInput` | `graph::GraphExtendSelectionNext` | Add the next commit in the graph to the selection. | +| `shift-up` or `shift-k` | `GraphView && !TextInput` | `graph::GraphExtendSelectionPrev` | Add the previous commit in the graph to the selection. | | `escape` | `GraphView` | `graph::GraphCancel` | Close the graph search, or dismiss the graph context menu. | | `y` | `GraphView && !TextInput` | `graph::CopyCommitSha` | Copy the selected commit's SHA to the clipboard. | | `shift-c` | `GraphView && !TextInput` | `graph::CopyCommitMessage` | Copy the selected commit's message to the clipboard. | diff --git a/docs/keymap.schema.json b/docs/keymap.schema.json index 11fd0e9..dafe54b 100644 --- a/docs/keymap.schema.json +++ b/docs/keymap.schema.json @@ -380,6 +380,14 @@ "const": "graph::GraphSelectLast", "description": "Select the oldest loaded commit in the graph. Command id: `graph_select_last`." }, + { + "const": "graph::GraphExtendSelectionNext", + "description": "Add the next commit in the graph to the selection. Command id: `graph_extend_selection_next`." + }, + { + "const": "graph::GraphExtendSelectionPrev", + "description": "Add the previous commit in the graph to the selection. Command id: `graph_extend_selection_prev`." + }, { "const": "graph::GraphCancel", "description": "Close the graph search, or dismiss the graph context menu. Command id: `graph_cancel`." From 6af339067ead6a738c1e11c9364b7b286e6fb385 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:49:23 +0100 Subject: [PATCH 09/16] feat(graph): squash the selected commits via the rebase dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `secondary-shift-s` on a multi-commit graph selection now pre-fills the interactive rebase dialog with a plan that melds the selection together. Nothing is executed here: the user reviews the plan, can adjust it, and confirms through the dialog's existing `Execute` path. `rebase_interactive` accepts a plan only when its commit set is exactly HEAD's contiguous first-parent range `base..HEAD` — it recomputes that range from HEAD rather than trusting the plan, so a cross-branch plan cannot replay commits from an unrelated branch. The graph, meanwhile, lists every ref in date order, so a run of adjacent rows can easily straddle two branches. `squash::plan_squash` therefore validates first and each rejection carries an actionable message: too few commits, off HEAD's first-parent chain, a gap in the run, a merge inside the range that would be rewritten, or a range that reaches the root commit. The plan covers HEAD down to the oldest selected commit. That commit stays a `pick` and the rest of the selection becomes `squash`, because git melds a `squash` into the todo line above it; anything newer is replayed unchanged. It is all pure, so the rules are tested without a display or a fixture repo. Co-Authored-By: Claude Fable 5 --- .../rgitui_workspace/src/command_palette.rs | 19 + .../src/interactive_rebase.rs | 2 +- .../rgitui_workspace/src/keymap/registry.rs | 6 +- crates/rgitui_workspace/src/lib.rs | 1 + crates/rgitui_workspace/src/squash.rs | 412 ++++++++++++++++++ .../src/workspace/commands.rs | 53 +++ crates/rgitui_workspace/src/workspace/tabs.rs | 1 + docs/KEYBINDINGS.md | 1 + docs/keymap.schema.json | 4 + 9 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 crates/rgitui_workspace/src/squash.rs diff --git a/crates/rgitui_workspace/src/command_palette.rs b/crates/rgitui_workspace/src/command_palette.rs index 292e530..40fc172 100644 --- a/crates/rgitui_workspace/src/command_palette.rs +++ b/crates/rgitui_workspace/src/command_palette.rs @@ -33,6 +33,9 @@ pub struct CommandContext { pub in_progress_operation: bool, /// True when the user has a GitHub token configured. pub has_github_token: bool, + /// True when the commit graph has more than one commit selected, which is + /// what the multi-commit operations (squash, for one) need. + pub has_multi_commit_selection: bool, } impl CommandContext { @@ -48,9 +51,19 @@ impl CommandContext { has_staged: false, in_progress_operation: false, has_github_token: false, + has_multi_commit_selection: false, } } + /// Records whether the commit graph has more than one commit selected. + /// + /// Kept off [`Self::from_parts`], which maps *repository* state: this flag + /// comes from the graph view's selection and changes far more often. + pub fn with_multi_commit_selection(mut self, selected: bool) -> Self { + self.has_multi_commit_selection = selected; + self + } + /// Build a context from primitive inputs. Keeps the `RepoState` → /// `worktree_clean` / `is_bisecting` / `in_progress_operation` mapping in /// one place so `open_repo`, refresh, and future callers cannot drift. @@ -81,6 +94,7 @@ impl CommandContext { | rgitui_git::RepoState::RevertSequence ), has_github_token, + has_multi_commit_selection: false, } } } @@ -130,6 +144,11 @@ pub(crate) const fn in_progress_operation(ctx: CommandContext) -> bool { ctx.in_progress_operation } +/// Show only when the commit graph has two or more commits selected. +pub(crate) const fn has_multi_commit_selection(ctx: CommandContext) -> bool { + ctx.has_multi_commit_selection +} + /// The command identifier, its gpui action and its default keybinding are /// generated by the `commands!` macro in `keymap::registry`. impl CommandId { diff --git a/crates/rgitui_workspace/src/interactive_rebase.rs b/crates/rgitui_workspace/src/interactive_rebase.rs index 9f61486..4c48137 100644 --- a/crates/rgitui_workspace/src/interactive_rebase.rs +++ b/crates/rgitui_workspace/src/interactive_rebase.rs @@ -56,7 +56,7 @@ impl RebaseAction { } /// A single commit entry in the interactive rebase list. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct RebaseEntry { pub oid: String, pub original_message: String, diff --git a/crates/rgitui_workspace/src/keymap/registry.rs b/crates/rgitui_workspace/src/keymap/registry.rs index 8280a35..f2fd9c4 100644 --- a/crates/rgitui_workspace/src/keymap/registry.rs +++ b/crates/rgitui_workspace/src/keymap/registry.rs @@ -32,8 +32,8 @@ // invocation site; `always_show` is the default and is referenced through // `$crate`, so it is not imported here. use crate::command_palette::{ - has_changes, has_github_token, has_remotes, has_staged, has_stashes, in_progress_operation, - is_bisecting, worktree_clean, CommandContext, + has_changes, has_github_token, has_multi_commit_selection, has_remotes, has_staged, + has_stashes, in_progress_operation, is_bisecting, worktree_clean, CommandContext, }; /// Static description of a single command. @@ -243,6 +243,8 @@ commands! { GraphExtendSelectionNext ["shift-down", "shift-j"] [hidden]; /// Add the previous commit in the graph to the selection. GraphExtendSelectionPrev ["shift-up", "shift-k"] [hidden]; + /// Squash the selected commits into the oldest of them. + SquashSelected "secondary-shift-s" if has_multi_commit_selection [hidden]; /// Close the graph search, or dismiss the graph context menu. GraphCancel "escape" in "GraphView" [hidden]; /// Copy the selected commit's SHA to the clipboard. diff --git a/crates/rgitui_workspace/src/lib.rs b/crates/rgitui_workspace/src/lib.rs index a754cec..570a6ea 100644 --- a/crates/rgitui_workspace/src/lib.rs +++ b/crates/rgitui_workspace/src/lib.rs @@ -29,6 +29,7 @@ mod settings_window; mod shortcuts_help; mod sidebar; mod splash_screen; +mod squash; mod stash_branch_dialog; mod stash_save_dialog; mod status_bar; diff --git a/crates/rgitui_workspace/src/squash.rs b/crates/rgitui_workspace/src/squash.rs new file mode 100644 index 0000000..ad9da6e --- /dev/null +++ b/crates/rgitui_workspace/src/squash.rs @@ -0,0 +1,412 @@ +//! Turning a graph multi-selection into an interactive-rebase plan that squashes +//! the selected commits together. +//! +//! [`GitProject::rebase_interactive`](rgitui_git::GitProject::rebase_interactive) +//! accepts a plan only when its commit set is *exactly* HEAD's contiguous +//! first-parent range `base..HEAD`, and hard-fails otherwise — it derives the +//! range from HEAD itself rather than from the plan, so a cross-branch or stale +//! plan cannot replay commits from an unrelated branch. The commit graph, on the +//! other hand, lists every ref in date order, so a plausible-looking selection +//! can easily straddle two branches. +//! +//! Everything here is therefore validated *before* anything is executed, and the +//! rejection carries an actionable message. It is all pure — no gpui, no git2 +//! repository — so the rules are unit-tested without a display or a fixture repo. + +use git2::Oid; +use rgitui_git::CommitInfo; + +use crate::interactive_rebase::{RebaseAction, RebaseEntry}; + +/// A validated squash, ready to pre-fill the interactive rebase dialog. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SquashPlan { + /// Every commit that will be replayed, newest first — the order the dialog + /// lists them in. Covers HEAD down to the oldest selected commit inclusive. + pub entries: Vec, + /// Short id of the commit the range is replayed onto, for the dialog's title. + pub base_label: String, +} + +/// Why a selection cannot be squashed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SquashRejection { + /// Fewer than two distinct commits are selected. + TooFewSelected(usize), + /// At least one selected commit is not on HEAD's first-parent chain. + NotOnHeadChain, + /// The selected commits are all on HEAD's chain but skip one in between. + NotContiguous, + /// A merge commit sits inside the range that would be rewritten. + CrossesMerge(String), + /// The range reaches the first commit in the repository, which has no parent + /// to rebase onto. + ReachesRootCommit, +} + +impl SquashRejection { + /// An actionable, user-facing explanation for a toast. + pub(crate) fn message(&self) -> String { + match self { + SquashRejection::TooFewSelected(_) => "Select two or more commits to squash them: \ + hold shift to extend the selection by row, or the primary modifier to add \ + single rows." + .to_owned(), + SquashRejection::NotOnHeadChain => "Squash only rewrites the checked-out branch. \ + The graph lists every branch in date order, so at least one selected commit is \ + not on this branch's history — select commits from the current branch instead." + .to_owned(), + SquashRejection::NotContiguous => "Squash needs an unbroken run of commits. The \ + selection skips at least one commit in between: either include it, or narrow \ + the selection." + .to_owned(), + SquashRejection::CrossesMerge(short_id) => format!( + "Squash would have to replay merge commit {short_id}, which sits between the \ + selection and HEAD. Select commits newer than that merge instead." + ), + SquashRejection::ReachesRootCommit => "Squash cannot include the repository's first \ + commit, which has no parent to rebase onto." + .to_owned(), + } + } +} + +/// HEAD's first-parent chain, newest first, restricted to the loaded commits. +/// +/// Stops at the first commit the graph has not loaded: the plan may only name +/// commits that are actually on screen, and the rebase layer would reject a plan +/// referring to anything else anyway. +fn first_parent_chain(commits: &[CommitInfo], head_oid: Oid) -> Vec<&CommitInfo> { + let mut chain: Vec<&CommitInfo> = Vec::new(); + let mut next = Some(head_oid); + while let Some(oid) = next { + let Some(commit) = commits.iter().find(|commit| commit.oid == oid) else { + break; + }; + chain.push(commit); + // A history longer than the list it is drawn from means the parent links + // loop; bail rather than spinning on the UI thread. + if chain.len() > commits.len() { + break; + } + next = commit.parent_oids.first().copied(); + } + chain +} + +/// Builds the plan that squashes `selected` together, or explains why it cannot. +/// +/// `commits` is the graph's display list (date-ordered, every ref), `head_oid` +/// the commit the branch is actually on. On success the plan replays HEAD down to +/// the oldest selected commit: the oldest selected commit stays a `pick` and the +/// rest of the selection becomes `squash`, because git melds a `squash` into the +/// todo line above it. Commits newer than the selection are replayed unchanged. +pub(crate) fn plan_squash( + commits: &[CommitInfo], + head_oid: Oid, + selected: &[Oid], +) -> Result { + if selected.len() < 2 { + return Err(SquashRejection::TooFewSelected(selected.len())); + } + + let chain = first_parent_chain(commits, head_oid); + let mut positions = Vec::with_capacity(selected.len()); + for oid in selected { + let Some(position) = chain.iter().position(|commit| commit.oid == *oid) else { + return Err(SquashRejection::NotOnHeadChain); + }; + positions.push(position); + } + positions.sort_unstable(); + positions.dedup(); + if positions.len() < 2 { + return Err(SquashRejection::TooFewSelected(positions.len())); + } + + // The chain runs newest first, so the smallest position is the newest commit. + let newest = positions[0]; + let oldest = positions[positions.len() - 1]; + if oldest - newest + 1 != positions.len() { + return Err(SquashRejection::NotContiguous); + } + + let covered = &chain[..=oldest]; + if let Some(merge) = covered + .iter() + .find(|commit| commit.parent_oids.len() > 1) + .map(|commit| commit.short_id.clone()) + { + return Err(SquashRejection::CrossesMerge(merge)); + } + let base_oid = covered + .last() + .and_then(|commit| commit.parent_oids.first()) + .copied(); + let Some(base_oid) = base_oid else { + return Err(SquashRejection::ReachesRootCommit); + }; + + let entries = covered + .iter() + .enumerate() + .map(|(position, commit)| RebaseEntry { + oid: commit.oid.to_string(), + original_message: commit.summary.clone(), + author: commit.author.name.clone(), + // The oldest selected commit carries the squash, so it stays a pick. + action: if position >= newest && position < oldest { + RebaseAction::Squash + } else { + RebaseAction::Pick + }, + }) + .collect(); + + // The base itself is usually loaded, which gives a nicer label than an abbrev + // of the raw OID; fall back to that when it is out of the loaded window. + let base_label = commits + .iter() + .find(|commit| commit.oid == base_oid) + .map(|commit| commit.short_id.clone()) + .unwrap_or_else(|| base_oid.to_string()[..7].to_owned()); + + Ok(SquashPlan { + entries, + base_label, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use rgitui_git::{RefLabel, Signature}; + + fn oid(byte: u8) -> Oid { + let mut bytes = [0_u8; 20]; + bytes[0] = byte; + Oid::from_bytes(&bytes).unwrap() + } + + fn commit(id: u8, parents: &[u8]) -> CommitInfo { + CommitInfo { + oid: oid(id), + short_id: format!("{id:07x}"), + summary: format!("Commit {id}"), + message: format!("Commit {id}"), + author: Signature { + name: "Test".to_owned(), + email: "test@example.com".to_owned(), + }, + committer: Signature { + name: "Test".to_owned(), + email: "test@example.com".to_owned(), + }, + co_authors: Vec::new(), + time: Utc::now(), + parent_oids: parents.iter().map(|parent| oid(*parent)).collect(), + refs: Vec::new(), + is_signed: false, + } + } + + /// A linear branch: 5 (HEAD) -> 4 -> 3 -> 2 -> 1, plus a root commit 0 so the + /// range never runs off the end of history. + fn linear_history() -> Vec { + vec![ + commit(5, &[4]), + commit(4, &[3]), + commit(3, &[2]), + commit(2, &[1]), + commit(1, &[0]), + commit(0, &[]), + ] + } + + fn actions(plan: &SquashPlan) -> Vec<(&str, &RebaseAction)> { + plan.entries + .iter() + .map(|entry| (entry.oid.as_str(), &entry.action)) + .collect() + } + + #[test] + fn three_of_five_commits_squash_into_the_oldest_selected() { + let commits = linear_history(); + // Select 4, 3 and 2 — the middle of the branch, with 5 newer than them. + let plan = plan_squash(&commits, oid(5), &[oid(4), oid(3), oid(2)]) + .expect("a contiguous run on HEAD's chain is squashable"); + + // The plan covers HEAD down to the oldest selected commit, newest first. + assert_eq!( + actions(&plan), + vec![ + (oid(5).to_string().as_str(), &RebaseAction::Pick), + (oid(4).to_string().as_str(), &RebaseAction::Squash), + (oid(3).to_string().as_str(), &RebaseAction::Squash), + // `squash` melds into the line above, so the oldest selected + // commit stays the pick that carries the others. + (oid(2).to_string().as_str(), &RebaseAction::Pick), + ] + ); + // Nothing older than the selection is replayed. + assert_eq!(plan.entries.len(), 4); + // Replayed onto the parent of the oldest covered commit. + assert_eq!(plan.base_label, commits[4].short_id); + } + + #[test] + fn selecting_the_two_newest_commits_squashes_them_alone() { + let commits = linear_history(); + let plan = plan_squash(&commits, oid(5), &[oid(5), oid(4)]).expect("squashable"); + assert_eq!( + actions(&plan), + vec![ + (oid(5).to_string().as_str(), &RebaseAction::Squash), + (oid(4).to_string().as_str(), &RebaseAction::Pick), + ] + ); + } + + #[test] + fn the_selection_order_does_not_matter() { + let commits = linear_history(); + let ascending = plan_squash(&commits, oid(5), &[oid(2), oid(3), oid(4)]).unwrap(); + let descending = plan_squash(&commits, oid(5), &[oid(4), oid(3), oid(2)]).unwrap(); + assert_eq!(ascending, descending); + } + + #[test] + fn a_gap_in_the_selection_is_rejected() { + let commits = linear_history(); + // 4 and 2 are on the chain but 3 sits between them. + assert_eq!( + plan_squash(&commits, oid(5), &[oid(4), oid(2)]), + Err(SquashRejection::NotContiguous) + ); + } + + #[test] + fn fewer_than_two_commits_cannot_be_squashed() { + let commits = linear_history(); + assert_eq!( + plan_squash(&commits, oid(5), &[oid(4)]), + Err(SquashRejection::TooFewSelected(1)) + ); + assert_eq!( + plan_squash(&commits, oid(5), &[]), + Err(SquashRejection::TooFewSelected(0)) + ); + // The same commit twice is still one commit. + assert_eq!( + plan_squash(&commits, oid(5), &[oid(4), oid(4)]), + Err(SquashRejection::TooFewSelected(1)) + ); + } + + /// The graph is date-ordered across every ref, so a run of adjacent *rows* + /// can easily include a commit from another branch. + #[test] + fn a_commit_from_another_branch_is_rejected() { + // 5 (HEAD) -> 4 -> 1, and 9 -> 1 on a side branch that the date order + // interleaves between 5 and 4. + let commits = vec![ + commit(5, &[4]), + commit(9, &[1]), + commit(4, &[1]), + commit(1, &[0]), + commit(0, &[]), + ]; + assert_eq!( + plan_squash(&commits, oid(5), &[oid(5), oid(9)]), + Err(SquashRejection::NotOnHeadChain) + ); + // Its own two commits are fine, because both are on HEAD's chain. + assert!(plan_squash(&commits, oid(5), &[oid(5), oid(4)]).is_ok()); + } + + #[test] + fn a_commit_that_is_not_loaded_is_rejected() { + let commits = linear_history(); + assert_eq!( + plan_squash(&commits, oid(5), &[oid(4), oid(42)]), + Err(SquashRejection::NotOnHeadChain) + ); + } + + #[test] + fn a_merge_inside_the_replayed_range_is_rejected() { + // 5 (HEAD) is a merge of 4 and 9; squashing 3 into 2 would have to replay + // it, which `git rebase -i` would flatten. + let commits = vec![ + commit(5, &[4, 9]), + commit(9, &[3]), + commit(4, &[3]), + commit(3, &[2]), + commit(2, &[1]), + commit(1, &[]), + ]; + assert_eq!( + plan_squash(&commits, oid(5), &[oid(3), oid(2)]), + Err(SquashRejection::CrossesMerge(commits[0].short_id.clone())) + ); + } + + #[test] + fn a_range_reaching_the_root_commit_is_rejected() { + let commits = vec![commit(3, &[2]), commit(2, &[1]), commit(1, &[])]; + assert_eq!( + plan_squash(&commits, oid(3), &[oid(2), oid(1)]), + Err(SquashRejection::ReachesRootCommit) + ); + } + + /// The base is only a label, so an unloaded parent must not fail the plan. + #[test] + fn an_unloaded_base_falls_back_to_the_abbreviated_oid() { + // The parent of commit 1 is not in the list. + let commits = vec![commit(3, &[2]), commit(2, &[1]), commit(1, &[7])]; + let plan = plan_squash(&commits, oid(3), &[oid(2), oid(1)]).unwrap(); + assert_eq!(plan.base_label, oid(7).to_string()[..7]); + } + + /// The plan must match what `rebase_interactive` recomputes from HEAD: exactly + /// the last N first-parent commits, one entry each, no duplicates. + #[test] + fn the_plan_matches_heads_first_parent_range_exactly() { + let commits = linear_history(); + let plan = plan_squash(&commits, oid(5), &[oid(3), oid(2)]).unwrap(); + + let expected: Vec = [5_u8, 4, 3, 2] + .iter() + .map(|id| oid(*id).to_string()) + .collect(); + let planned: Vec = plan.entries.iter().map(|e| e.oid.clone()).collect(); + assert_eq!(planned, expected); + } + + #[test] + fn a_refs_label_on_head_does_not_affect_the_plan() { + // The chain is walked from the OID, not from the HEAD ref label. + let mut commits = linear_history(); + commits[2].refs = vec![RefLabel::Head]; + let plan = plan_squash(&commits, oid(5), &[oid(5), oid(4)]).unwrap(); + assert_eq!(plan.entries.len(), 2); + } + + #[test] + fn every_rejection_explains_itself() { + for rejection in [ + SquashRejection::TooFewSelected(1), + SquashRejection::NotOnHeadChain, + SquashRejection::NotContiguous, + SquashRejection::CrossesMerge("abc1234".to_owned()), + SquashRejection::ReachesRootCommit, + ] { + let message = rejection.message(); + assert!(message.ends_with('.'), "{message}"); + assert!(!message.contains(" "), "double space in {message}"); + } + } +} diff --git a/crates/rgitui_workspace/src/workspace/commands.rs b/crates/rgitui_workspace/src/workspace/commands.rs index a6373dc..46a7589 100644 --- a/crates/rgitui_workspace/src/workspace/commands.rs +++ b/crates/rgitui_workspace/src/workspace/commands.rs @@ -84,6 +84,12 @@ impl Workspace { cx.propagate(); return; }; + // Squashing needs the project, the rebase dialog and the toast queue, so it + // is handled on the workspace rather than inside `graph.update`. + if cmd == CommandId::SquashSelected { + self.squash_selected_commits(cx); + return; + } graph.update(cx, |graph, cx| match cmd { CommandId::GraphSelectNext => graph.select_next_row(cx), CommandId::GraphSelectPrev => graph.select_prev_row(cx), @@ -98,6 +104,52 @@ impl Workspace { }); } + /// Pre-fills the interactive rebase dialog with a plan that squashes the + /// commits selected in the graph into the oldest of them. + /// + /// Nothing is executed here: the plan goes into the dialog so the user can + /// review it, adjust the actions and confirm through the dialog's own + /// `Execute` path. Validation happens first — see [`crate::squash`] for why — + /// and a rejection becomes a toast that names the condition that failed. + fn squash_selected_commits(&mut self, cx: &mut Context) { + let Some(tab) = self.tabs.get(self.active_tab) else { + return; + }; + let graph = tab.graph.clone(); + let project = tab.project.clone(); + let selected = graph.read(cx).selected_commit_oids(); + + let planned = { + let proj = project.read(cx); + proj.head_oid_at(proj.repo_path()).map(|head_oid| { + crate::squash::plan_squash(proj.recent_commits(), head_oid, &selected) + }) + }; + + match planned { + Some(Ok(plan)) => { + self.overlays.interactive_rebase.update(cx, |ir, cx| { + ir.show_visible( + plan.entries, + format!("{} (rebase onto)", plan.base_label), + cx, + ); + }); + } + Some(Err(rejection)) => { + self.show_toast(rejection.message(), ToastKind::Warning, cx); + } + None => { + self.show_toast( + "Could not resolve HEAD, so there is nothing to squash onto. Refresh and \ + try again.", + ToastKind::Warning, + cx, + ); + } + } + } + /// Runs a `diff::*` command against the active tab's diff viewer. /// /// Handled here for the same reason as [`Self::dispatch_graph_command`]: @@ -739,6 +791,7 @@ impl Workspace { | CommandId::GraphSelectLast | CommandId::GraphExtendSelectionNext | CommandId::GraphExtendSelectionPrev + | CommandId::SquashSelected | CommandId::GraphCancel | CommandId::CopyCommitSha | CommandId::CopyCommitMessage diff --git a/crates/rgitui_workspace/src/workspace/tabs.rs b/crates/rgitui_workspace/src/workspace/tabs.rs index 1626eab..1ff8520 100644 --- a/crates/rgitui_workspace/src/workspace/tabs.rs +++ b/crates/rgitui_workspace/src/workspace/tabs.rs @@ -257,6 +257,7 @@ impl Workspace { !proj.status().staged.is_empty(), has_token, ) + .with_multi_commit_selection(tab.graph.read(cx).selected_commit_count() > 1) } /// Update the command palette's context with fresh data from the active tab. diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index add247f..a8cba7a 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -122,6 +122,7 @@ The file is reloaded when you save it. Bindings you add win over the defaults. T | `end` or `shift-g` | `GraphView` or `GraphView && !TextInput` | `graph::GraphSelectLast` | Select the oldest loaded commit in the graph. | | `shift-down` or `shift-j` | `GraphView && !TextInput` | `graph::GraphExtendSelectionNext` | Add the next commit in the graph to the selection. | | `shift-up` or `shift-k` | `GraphView && !TextInput` | `graph::GraphExtendSelectionPrev` | Add the previous commit in the graph to the selection. | +| `secondary-shift-s` | `GraphView && !TextInput` | `graph::SquashSelected` | Squash the selected commits into the oldest of them. | | `escape` | `GraphView` | `graph::GraphCancel` | Close the graph search, or dismiss the graph context menu. | | `y` | `GraphView && !TextInput` | `graph::CopyCommitSha` | Copy the selected commit's SHA to the clipboard. | | `shift-c` | `GraphView && !TextInput` | `graph::CopyCommitMessage` | Copy the selected commit's message to the clipboard. | diff --git a/docs/keymap.schema.json b/docs/keymap.schema.json index dafe54b..f4f052c 100644 --- a/docs/keymap.schema.json +++ b/docs/keymap.schema.json @@ -388,6 +388,10 @@ "const": "graph::GraphExtendSelectionPrev", "description": "Add the previous commit in the graph to the selection. Command id: `graph_extend_selection_prev`." }, + { + "const": "graph::SquashSelected", + "description": "Squash the selected commits into the oldest of them. Command id: `squash_selected`." + }, { "const": "graph::GraphCancel", "description": "Close the graph search, or dismiss the graph context menu. Command id: `graph_cancel`." From 2672a1e32520a4257efb2186e97c11baea87f80d Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:15:00 +0100 Subject: [PATCH 10/16] feat(keymap): render every shortcut from the loaded keymap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three hand-written lists claimed to document the keybindings — the shortcut help, the command palette hints and the settings quick reference — and one had already drifted: it advertised Ctrl+Shift+F for Fetch, which the registry binds to Ctrl+Shift+R. Every shortcut the UI shows is now derived from the bindings that were actually loaded: * `keymap::display` humanises a keystroke — `secondary-shift-r` becomes `Ctrl+Shift+R`, or `⌘⇧R` on macOS. Parsing is `gpui::Keystroke::parse`, so `secondary` and the shift/uppercase normalisation cannot disagree with what gpui matches; only the spelling is ours, since gpui's own `Display` renders the keymap syntax rather than a label. The style is a parameter rather than a `cfg!`, so both spellings are unit-testable anywhere. * `keymap::summary` records what the load produced, per command: the bindings that will fire, where each came from, and anything that went wrong. It is built from the very specs handed to `bind_keys`, so it agrees with the live keymap by construction, and it is pure. It also resolves shadowing, which `Keymap::bindings_for_action` cannot: after a user binds Ctrl+S to Commit, StageAll reads as unbound instead of advertising a keystroke that can no longer reach it. * the summary is published on `KeymapState` behind an `Arc`, so any render can read it, and it is rebuilt on every `keymap.json` reload. The shortcut help now groups by `commands!` view block — which is also the key context each group is scoped to — labels every row with the command's doc comment, and lists unbound commands last rather than hiding them, so the palette-only commands stay discoverable. The palette hints, the settings quick reference, the toolbar tooltips, the title bar and the home screen all read the same accessor, as does the branch-switch toast; the sidebar's discard tooltip stops quoting Ctrl+Z, which never discarded anything. `no_surface_hardcodes_a_chord` scans those files and fails the build if one spells a keystroke out again, so the drift becomes impossible rather than merely fixed. Co-Authored-By: Claude Fable 5 --- .../rgitui_workspace/src/command_palette.rs | 189 ++---- crates/rgitui_workspace/src/commit_panel.rs | 18 +- .../rgitui_workspace/src/keymap/conflict.rs | 9 +- crates/rgitui_workspace/src/keymap/display.rs | 547 ++++++++++++++++ crates/rgitui_workspace/src/keymap/loader.rs | 15 +- crates/rgitui_workspace/src/keymap/mod.rs | 48 ++ crates/rgitui_workspace/src/keymap/summary.rs | 611 ++++++++++++++++++ .../src/settings_window/view.rs | 69 +- crates/rgitui_workspace/src/shortcuts_help.rs | 475 ++++++-------- crates/rgitui_workspace/src/sidebar.rs | 2 +- crates/rgitui_workspace/src/title_bar.rs | 33 +- crates/rgitui_workspace/src/toolbar.rs | 52 +- .../src/workspace/commands.rs | 5 +- .../rgitui_workspace/src/workspace/layout.rs | 80 ++- 14 files changed, 1630 insertions(+), 523 deletions(-) create mode 100644 crates/rgitui_workspace/src/keymap/display.rs create mode 100644 crates/rgitui_workspace/src/keymap/summary.rs diff --git a/crates/rgitui_workspace/src/command_palette.rs b/crates/rgitui_workspace/src/command_palette.rs index 40fc172..a611968 100644 --- a/crates/rgitui_workspace/src/command_palette.rs +++ b/crates/rgitui_workspace/src/command_palette.rs @@ -33,9 +33,6 @@ pub struct CommandContext { pub in_progress_operation: bool, /// True when the user has a GitHub token configured. pub has_github_token: bool, - /// True when the commit graph has more than one commit selected, which is - /// what the multi-commit operations (squash, for one) need. - pub has_multi_commit_selection: bool, } impl CommandContext { @@ -51,19 +48,9 @@ impl CommandContext { has_staged: false, in_progress_operation: false, has_github_token: false, - has_multi_commit_selection: false, } } - /// Records whether the commit graph has more than one commit selected. - /// - /// Kept off [`Self::from_parts`], which maps *repository* state: this flag - /// comes from the graph view's selection and changes far more often. - pub fn with_multi_commit_selection(mut self, selected: bool) -> Self { - self.has_multi_commit_selection = selected; - self - } - /// Build a context from primitive inputs. Keeps the `RepoState` → /// `worktree_clean` / `is_bisecting` / `in_progress_operation` mapping in /// one place so `open_repo`, refresh, and future callers cannot drift. @@ -94,7 +81,6 @@ impl CommandContext { | rgitui_git::RepoState::RevertSequence ), has_github_token, - has_multi_commit_selection: false, } } } @@ -144,11 +130,6 @@ pub(crate) const fn in_progress_operation(ctx: CommandContext) -> bool { ctx.in_progress_operation } -/// Show only when the commit graph has two or more commits selected. -pub(crate) const fn has_multi_commit_selection(ctx: CommandContext) -> bool { - ctx.has_multi_commit_selection -} - /// The command identifier, its gpui action and its default keybinding are /// generated by the `commands!` macro in `keymap::registry`. impl CommandId { @@ -161,12 +142,17 @@ impl CommandId { } } +/// A palette row. +/// +/// Deliberately carries no shortcut field: the keystroke hint is read from +/// [`crate::keymap::shortcut`] at render time, so it always shows the binding +/// the user's `keymap.json` produced rather than a literal that goes stale the +/// moment either the registry or the user's keymap changes. #[derive(Clone)] pub struct PaletteCommand { pub id: CommandId, pub label: &'static str, pub description: Option<&'static str>, - pub shortcut: Option<&'static str>, pub category: &'static str, /// Context predicate — evaluated at filter-time to determine visibility. predicate: fn(CommandContext) -> bool, @@ -184,14 +170,12 @@ impl PaletteCommand { id: CommandId, label: &'static str, description: Option<&'static str>, - shortcut: Option<&'static str>, category: &'static str, ) -> Self { Self { id, label, description, - shortcut, category, predicate: always_show, } @@ -246,7 +230,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::Fetch, "Git: Fetch", Some("Download objects and refs from another repository"), - Some("Ctrl+Shift+F"), "Git", ) .with_predicate(has_remotes), @@ -254,7 +237,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::Pull, "Git: Pull", Some("Fetch from and integrate with another repository or a local branch"), - None, "Git", ) .with_predicate(has_remotes), @@ -262,7 +244,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::Push, "Git: Push", Some("Update remote refs along with associated objects"), - None, "Git", ) .with_predicate(has_remotes), @@ -270,7 +251,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::PushAll, "Git: Push All", Some("Push all branches to their configured remotes"), - None, "Git", ) .with_predicate(has_remotes), @@ -278,7 +258,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::PullAll, "Git: Pull All", Some("Pull updates for all configured branches"), - None, "Git", ) .with_predicate(has_remotes), @@ -286,7 +265,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::ForcePush, "Git: Force Push", Some("Force update remote refs (can overwrite history)"), - None, "Git", ) .with_predicate(has_remotes), @@ -294,7 +272,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::Commit, "Git: Commit", Some("Record changes to the repository"), - Some("Ctrl+Enter"), "Git", ) .with_predicate(has_staged), @@ -302,7 +279,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::StageAll, "Git: Stage All", Some("Add all changes to the staging area"), - Some("Ctrl+S"), "Git", ) .with_predicate(has_changes), @@ -310,7 +286,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::UnstageAll, "Git: Unstage All", Some("Remove all changes from the staging area"), - Some("Ctrl+U"), "Git", ) .with_predicate(has_changes), @@ -318,7 +293,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::StashSave, "Git: Stash", Some("Save your local modifications to a new stash"), - Some("Ctrl+Z"), "Git", ) .with_predicate(has_changes), @@ -326,7 +300,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::StashPop, "Git: Pop Stash", Some("Apply the latest stash and remove it"), - Some("Ctrl+Shift+Z"), "Git", ) .with_predicate(has_stashes), @@ -334,7 +307,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::StashApply, "Git: Apply Stash (keep)", Some("Apply the latest stash but keep it in the list"), - None, "Git", ) .with_predicate(has_stashes), @@ -342,7 +314,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::StashDrop, "Git: Drop Stash", Some("Delete the latest stash entry"), - None, "Git", ) .with_predicate(has_stashes), @@ -350,35 +321,30 @@ pub(crate) fn palette_commands() -> Vec { CommandId::CreateBranch, "Git: Create Branch", Some("Create a new branch"), - Some("Ctrl+B"), "Git", ), PaletteCommand::new( CommandId::SwitchBranch, "Git: Switch Branch", Some("Switch to an existing branch"), - Some("Ctrl+Shift+B"), "Git", ), PaletteCommand::new( CommandId::DeleteBranch, "Git: Delete Branch", Some("Delete a local branch"), - None, "Git", ), PaletteCommand::new( CommandId::RenameBranch, "Git: Rename Branch", Some("Rename the current or selected branch"), - None, "Git", ), PaletteCommand::new( CommandId::MergeBranch, "Git: Merge Branch", Some("Join two or more development histories together"), - None, "Git", ) .with_predicate(worktree_clean), @@ -386,21 +352,18 @@ pub(crate) fn palette_commands() -> Vec { CommandId::CreateTag, "Git: Create Tag", Some("Create a tag at the selected commit"), - None, "Git", ), PaletteCommand::new( CommandId::CreateWorktree, "Git: Create Worktree", Some("Check out a branch into a separate working tree"), - None, "Git", ), PaletteCommand::new( CommandId::CreatePr, "Git: Create Pull Request", Some("Open a pull request for the current branch"), - None, "Git", ) .with_predicate(has_github_token), @@ -408,7 +371,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::CherryPick, "Git: Cherry-pick Commit", Some("Apply the changes introduced by some existing commits"), - None, "Git", ) .with_predicate(worktree_clean), @@ -416,7 +378,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::RevertCommit, "Git: Revert Commit", Some("Revert an existing commit"), - None, "Git", ) .with_predicate(worktree_clean), @@ -424,7 +385,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::InteractiveRebase, "Git: Interactive Rebase", Some("Reapply commits on top of another base tip interactively"), - None, "Git", ) .with_predicate(worktree_clean), @@ -432,7 +392,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::DiscardAll, "Git: Discard All Changes", Some("Discard every unstaged and staged working tree change"), - None, "Git", ) .with_predicate(has_changes), @@ -440,14 +399,12 @@ pub(crate) fn palette_commands() -> Vec { CommandId::CleanUntracked, "Git: Clean Untracked Files", Some("Remove untracked files from the working tree"), - None, "Git", ), PaletteCommand::new( CommandId::ResetHard, "Git: Reset Hard (to HEAD)", Some("Discard all local changes, staged and unstaged"), - None, "Git", ) .with_predicate(has_changes), @@ -455,7 +412,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::AbortOperation, "Git: Abort Merge/Rebase", Some("Abort the in-progress merge, rebase, cherry-pick, or revert"), - None, "Git", ) .with_predicate(in_progress_operation), @@ -463,7 +419,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::ContinueMerge, "Git: Continue Merge", Some("Continue the in-progress merge, rebase, cherry-pick, or revert"), - None, "Git", ) .with_predicate(in_progress_operation), @@ -471,21 +426,18 @@ pub(crate) fn palette_commands() -> Vec { CommandId::ToggleDiffMode, "View: Toggle Diff Mode", Some("Switch between inline and side-by-side diff"), - Some("d"), "View", ), PaletteCommand::new( CommandId::Search, "View: Search Commits", Some("Filter the commit graph by message, author, or hash"), - Some("Ctrl+F"), "View", ), PaletteCommand::new( CommandId::AiMessage, "AI: Generate Commit Message", Some("Use AI to generate a commit message based on staged changes"), - Some("Ctrl+G"), "AI", ) .with_predicate(has_staged), @@ -493,70 +445,60 @@ pub(crate) fn palette_commands() -> Vec { CommandId::Refresh, "Git: Refresh", Some("Reload repository status, branches, and graph data"), - Some("F5"), "Git", ), PaletteCommand::new( CommandId::Settings, "Preferences: Open Settings", Some("Open the application settings window"), - Some("Ctrl+,"), "Preferences", ), PaletteCommand::new( CommandId::OpenRepo, "File: Open Repository", Some("Choose a repository to open"), - Some("Ctrl+O"), "File", ), PaletteCommand::new( CommandId::WorkspaceHome, "Workspace: Home", Some("Return to the workspace home screen"), - None, "Workspace", ), PaletteCommand::new( CommandId::RestoreLastWorkspace, "Workspace: Restore Last", Some("Reopen the last active workspace"), - None, "Workspace", ), PaletteCommand::new( CommandId::Shortcuts, "Help: Keyboard Shortcuts", Some("Show available keyboard shortcuts"), - Some("?"), "Help", ), PaletteCommand::new( CommandId::Blame, "View: Blame File", Some("Show what revision and author last modified each line"), - Some("b"), "View", ), PaletteCommand::new( CommandId::FileHistory, "View: File History", Some("Show commit history for the selected file"), - Some("h"), "View", ), PaletteCommand::new( CommandId::Undo, "Edit: Undo Last Operation", Some("Undo the most recent git operation when possible"), - None, "Edit", ), PaletteCommand::new( CommandId::BisectStart, "Git: Bisect Start", Some("Start a binary search for the commit that introduced a bug"), - None, "Git", ) .with_predicate(worktree_clean), @@ -564,7 +506,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::BisectGood, "Git: Bisect Good (mark current)", Some("Mark the current revision as good"), - None, "Git", ) .with_predicate(is_bisecting), @@ -572,7 +513,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::BisectBad, "Git: Bisect Bad (mark current)", Some("Mark the current revision as bad"), - None, "Git", ) .with_predicate(is_bisecting), @@ -580,7 +520,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::BisectReset, "Git: Bisect Reset", Some("Stop bisecting and return to the original branch"), - None, "Git", ) .with_predicate(is_bisecting), @@ -588,7 +527,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::BisectSkip, "Git: Bisect Skip (skip this commit)", Some("Skip the current revision during bisect"), - None, "Git", ) .with_predicate(is_bisecting), @@ -596,21 +534,18 @@ pub(crate) fn palette_commands() -> Vec { CommandId::Reflog, "View: Reflog", Some("Show recent branch and HEAD movements"), - None, "View", ), PaletteCommand::new( CommandId::Submodules, "View: Submodules", Some("Inspect configured git submodules"), - None, "View", ), PaletteCommand::new( CommandId::Bisect, "View: Bisect Log", Some("Show current bisect progress and selected revisions"), - None, "View", ) .with_predicate(is_bisecting), @@ -618,42 +553,36 @@ pub(crate) fn palette_commands() -> Vec { CommandId::GlobalSearch, "Search: Global Search", Some("Search for text across the entire repository"), - Some("Ctrl+Shift+F"), "Search", ), PaletteCommand::new( CommandId::ToggleIssues, "View: Issues Panel", Some("Open the GitHub issues panel"), - Some("Alt+5"), "View", ), PaletteCommand::new( CommandId::TogglePullRequests, "View: Pull Requests Panel", Some("Open the GitHub pull requests panel"), - Some("Alt+6"), "View", ), PaletteCommand::new( CommandId::ToggleBranchHealth, "View: Branch Health Panel", Some("Open branch recency and merge-status checks"), - Some("Alt+7"), "View", ), PaletteCommand::new( CommandId::ToggleStashes, "View: Stashes Panel", Some("Open the stash management panel"), - Some("Alt+8"), "View", ), PaletteCommand::new( CommandId::StashBranch, "Git: Create Branch from Stash", Some("Create a new branch starting from a stash entry"), - None, "Git", ) .with_predicate(has_stashes), @@ -661,7 +590,6 @@ pub(crate) fn palette_commands() -> Vec { CommandId::OpenThemeEditor, "View: Edit Theme", Some("Customize and save the active theme"), - Some("Ctrl+Shift+T"), "View", ), ] @@ -940,7 +868,10 @@ impl Render for CommandPalette { let list = uniform_list( "palette-results", filtered_count, - move |range: Range, _window: &mut Window, _cx: &mut App| { + move |range: Range, _window: &mut Window, cx: &mut App| { + // Read once per rendered range: the hint has to be the + // binding in force, not a literal baked into the row. + let summary = keymap::summary(cx); range .map(|display_idx| { let (cmd_idx, _score) = filtered[display_idx]; @@ -950,7 +881,7 @@ impl Render for CommandPalette { let label: SharedString = cmd.label.into(); let cmd_id = cmd.id; let icon = CommandPalette::category_icon(cmd.category); - let shortcut = cmd.shortcut; + let shortcut = summary.display(cmd_id); let description = cmd.description; let view_click = view.clone(); @@ -1372,22 +1303,54 @@ mod tests { CommandId::Fetch, "Git: Fetch", Some("Download objects and refs"), - Some("Ctrl+Shift+R"), "Git", ); assert_eq!(cmd.label, "Git: Fetch"); assert_eq!(cmd.description, Some("Download objects and refs")); - assert_eq!(cmd.shortcut, Some("Ctrl+Shift+R")); assert_eq!(cmd.category, "Git"); } #[test] fn palette_command_new_without_description() { use super::{CommandId, PaletteCommand}; - let cmd = PaletteCommand::new(CommandId::Refresh, "Git: Refresh", None, Some("F5"), "Git"); + let cmd = PaletteCommand::new(CommandId::Refresh, "Git: Refresh", None, "Git"); assert_eq!(cmd.label, "Git: Refresh"); assert_eq!(cmd.description, None); - assert_eq!(cmd.shortcut, Some("F5")); + } + + /// The row's keystroke hint is whatever the keymap holds, which is what makes + /// the old `Ctrl+Shift+F`-for-Fetch drift impossible rather than merely fixed. + #[test] + fn a_palette_hint_is_the_keymap_binding() { + use super::CommandId; + use crate::keymap::display::KeystrokeStyle; + use crate::keymap::KeymapSummary; + + let summary = KeymapSummary::defaults(KeystrokeStyle::Words); + for command in super::palette_commands() { + let hint = summary.display(command.id); + let expected = crate::keymap::display::join_bindings( + command + .id + .default_bindings() + .iter() + .filter_map(|(keystrokes, _)| { + crate::keymap::humanize_sequence(keystrokes, KeystrokeStyle::Words) + }) + .collect::>() + .iter() + .map(String::as_str), + ); + assert_eq!( + hint, expected, + "{}'s palette hint disagrees with its registry binding", + command.id + ); + } + assert_eq!( + summary.display(CommandId::Fetch).as_deref(), + Some("Ctrl+Shift+R") + ); } #[test] @@ -1397,68 +1360,10 @@ mod tests { CommandId::StashPop, "Git: Pop Stash", Some("Apply and remove stash"), - None, "Git", ) .with_predicate(super::has_stashes); // The command was constructed — verify no panic on creation assert_eq!(cmd.label, "Git: Pop Stash"); } - - /// View-level harness test: opens the palette in a real (headless) GPUI - /// test window and drives it entirely through simulated keystrokes, - /// proving that focus, key dispatch, and render-driven state updates work - /// end to end. This is the reference pattern for writing gpui view tests - /// in this codebase (see README_TESTING.md). - #[test] - fn palette_keyboard_navigation_in_test_window() { - use rgitui_test_support::ViewTest; - - use super::{CommandContext, CommandPalette}; - - let mut window = ViewTest::open(|_window, cx| CommandPalette::new(cx)); - - // Open the palette; `toggle` focuses the query editor. - window.update(|palette, window, cx| { - palette.set_context(CommandContext { - has_remotes: true, - ..CommandContext::none() - }); - palette.toggle(window, cx); - }); - let unfiltered_count = window.read(|palette, _| { - assert!(palette.is_visible(), "toggle should show the palette"); - palette.filtered_indices.len() - }); - assert!(unfiltered_count > 1); - - // Type into the focused query editor; the palette re-filters on change. - window.simulate_input("push"); - window.read(|palette, cx| { - assert_eq!(palette.query_editor.read(cx).text(), "push"); - let filtered_count = palette.filtered_indices.len(); - assert!(filtered_count > 0, "matching commands should remain"); - assert!( - filtered_count < unfiltered_count, - "typing should narrow the list ({filtered_count} vs {unfiltered_count})" - ); - let top = &palette.commands[palette.filtered_indices[0].0]; - assert!( - top.label.to_lowercase().contains("push"), - "expected a push command at the top, got {:?}", - top.label - ); - assert_eq!(palette.selected_index, 0); - }); - - // Arrow keys bubble past the text input to the palette's key handler. - window.simulate_keystroke("down"); - window.read(|palette, _| assert_eq!(palette.selected_index, 1)); - window.simulate_keystroke("up"); - window.read(|palette, _| assert_eq!(palette.selected_index, 0)); - - // Escape dismisses the palette. - window.simulate_keystroke("escape"); - window.read(|palette, _| assert!(!palette.is_visible())); - } } diff --git a/crates/rgitui_workspace/src/commit_panel.rs b/crates/rgitui_workspace/src/commit_panel.rs index 29275f5..9f0d31c 100644 --- a/crates/rgitui_workspace/src/commit_panel.rs +++ b/crates/rgitui_workspace/src/commit_panel.rs @@ -685,13 +685,17 @@ impl Render for CommitPanel { .color(Color::Warning), ) }) - .when(can_commit, |el| { - el.child( - Label::new("Ctrl+Enter") - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - }) + .when_some( + crate::keymap::shortcut(crate::CommandId::Commit, cx) + .filter(|_| can_commit), + |el, keystrokes| { + el.child( + Label::new(SharedString::from(keystrokes)) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + }, + ) .child( Button::new("commit-btn", commit_label) .icon(IconName::GitCommit) diff --git a/crates/rgitui_workspace/src/keymap/conflict.rs b/crates/rgitui_workspace/src/keymap/conflict.rs index d358615..95fade5 100644 --- a/crates/rgitui_workspace/src/keymap/conflict.rs +++ b/crates/rgitui_workspace/src/keymap/conflict.rs @@ -96,7 +96,8 @@ impl BindingSpec { } } - fn is_unbind(&self) -> bool { + /// Whether this entry removes a binding rather than adding one. + pub fn is_unbind(&self) -> bool { self.action == NO_ACTION } @@ -154,7 +155,7 @@ impl ConflictReport { /// A keystroke reduced to the parts gpui matches on, so that different spellings /// of the same keystroke (`ctrl-s` and `secondary-s` off macOS) compare equal. #[derive(Debug, Clone, PartialEq, Eq)] -struct NormalizedKeystroke { +pub struct NormalizedKeystroke { control: bool, alt: bool, shift: bool, @@ -180,7 +181,7 @@ impl From for NormalizedKeystroke { /// /// Returns `None` when any keystroke is unparseable — such entries are reported /// by the loader and excluded from conflict detection. -fn normalize_sequence(keystrokes: &str) -> Option> { +pub fn normalize_sequence(keystrokes: &str) -> Option> { let sequence: Vec = keystrokes .split_whitespace() .map(|source| Keystroke::parse(source).ok().map(NormalizedKeystroke::from)) @@ -195,7 +196,7 @@ fn normalize_sequence(keystrokes: &str) -> Option> { /// predicate that fails to parse is reported by the loader; here it is treated /// as overlapping nothing so a single bad predicate cannot suppress unrelated /// bindings. -fn contexts_overlap(left: Option<&str>, right: Option<&str>) -> bool { +pub fn contexts_overlap(left: Option<&str>, right: Option<&str>) -> bool { match (left, right) { (None, _) | (_, None) => true, (Some(left), Some(right)) => { diff --git a/crates/rgitui_workspace/src/keymap/display.rs b/crates/rgitui_workspace/src/keymap/display.rs new file mode 100644 index 0000000..6920f3b --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/display.rs @@ -0,0 +1,547 @@ +//! Humanised keystroke rendering. +//! +//! Turns the keystroke spelling used in the registry and in `keymap.json` +//! (`secondary-shift-r`, `ctrl-k ctrl-o`) into the text shown to the user: +//! `Ctrl+Shift+R` on Windows and Linux, `⌘⇧R` on macOS. +//! +//! Parsing is [`gpui::Keystroke::parse`], so `secondary` resolves to the +//! platform's primary modifier and `shift-g`/`G` normalise the same way the +//! keymap does — the display can therefore never disagree with what gpui +//! actually matches. Only the final spelling is ours: gpui's own [`Display`] +//! renders `ctrl-shift-R`, which is the keymap syntax rather than a label. +//! +//! [`KeystrokeStyle`] is passed in rather than read from `cfg!`, so both +//! spellings are unit-testable on any platform. +//! +//! [`Display`]: std::fmt::Display + +use gpui::{Keystroke, Modifiers}; + +/// How a keystroke is spelled for the user. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeystrokeStyle { + /// `Ctrl+Shift+R` — Windows and Linux. + Words, + /// `⌘⇧R` — macOS, where modifier glyphs are the platform convention. + Symbols, +} + +impl KeystrokeStyle { + /// The spelling this platform's users expect. + pub const fn platform() -> Self { + if cfg!(target_os = "macos") { + KeystrokeStyle::Symbols + } else { + KeystrokeStyle::Words + } + } +} + +/// Separates the keystrokes of a chord, e.g. `Ctrl+K Ctrl+O`. +const CHORD_SEPARATOR: &str = " "; + +/// Separates alternative bindings for one command, e.g. `Ctrl+Shift+S or Ctrl+U`. +pub const BINDING_SEPARATOR: &str = " or "; + +/// Shown in place of a keystroke for a command that has no binding. +pub const UNBOUND: &str = "unbound"; + +/// Renders one keystroke, e.g. `secondary-shift-r` → `Ctrl+Shift+R`. +/// +/// Returns `None` when the keystroke does not parse; the loader reports those +/// separately and they are never applied, so there is nothing to display. +pub fn humanize_keystroke(source: &str, style: KeystrokeStyle) -> Option { + let keystroke = Keystroke::parse(source).ok()?; + Some(render(&keystroke.modifiers, &keystroke.key, style)) +} + +/// Renders a whitespace-separated keystroke sequence, e.g. `ctrl-k ctrl-o` → +/// `Ctrl+K Ctrl+O`. +/// +/// Returns `None` when the sequence is empty or any keystroke in it is +/// unparseable — a chord is all-or-nothing, since half of one is meaningless. +pub fn humanize_sequence(keystrokes: &str, style: KeystrokeStyle) -> Option { + let rendered: Option> = keystrokes + .split_whitespace() + .map(|keystroke| humanize_keystroke(keystroke, style)) + .collect(); + let rendered = rendered?; + (!rendered.is_empty()).then(|| rendered.join(CHORD_SEPARATOR)) +} + +/// Joins the alternative bindings of one command, e.g. `UnstageAll` → +/// `Ctrl+Shift+S or Ctrl+U`. +/// +/// Duplicates are collapsed: one command often binds the same keystroke in two +/// contexts, which is one thing to press and so one thing to show. +pub fn join_bindings<'a>(displays: impl IntoIterator) -> Option { + let mut unique: Vec<&str> = Vec::new(); + for display in displays { + if !unique.contains(&display) { + unique.push(display); + } + } + (!unique.is_empty()).then(|| unique.join(BINDING_SEPARATOR)) +} + +/// Spells out modifiers followed by the key. +fn render(modifiers: &Modifiers, key: &str, style: KeystrokeStyle) -> String { + let mut out = String::new(); + match style { + // Word order follows `Keystroke::unparse`, so the label reads in the + // same order as the keymap entry it came from. + KeystrokeStyle::Words => { + if modifiers.function { + out.push_str("Fn+"); + } + if modifiers.control { + out.push_str("Ctrl+"); + } + if modifiers.alt { + out.push_str("Alt+"); + } + if modifiers.platform { + out.push_str(if cfg!(target_os = "windows") { + "Win+" + } else { + "Super+" + }); + } + if modifiers.shift { + out.push_str("Shift+"); + } + } + // The glyphs and their order are gpui's, so a label matches what the + // rest of the platform shows in its menus. + KeystrokeStyle::Symbols => { + if modifiers.function { + out.push_str("fn"); + } + if modifiers.control { + out.push('^'); + } + if modifiers.alt { + out.push('⌥'); + } + if modifiers.platform { + out.push('⌘'); + } + if modifiers.shift { + out.push('⇧'); + } + } + } + out.push_str(&render_key(key, style)); + out +} + +/// Spells out the key itself. +/// +/// A single character is upper-cased — the keymap stores `r` for what the user +/// sees printed on the key as `R`, with shift tracked as a modifier. +fn render_key(key: &str, style: KeystrokeStyle) -> String { + if let Some(named) = named_key(key, style) { + return named.to_owned(); + } + let mut chars = key.chars(); + match (chars.next(), chars.next()) { + (Some(single), None) => single.to_uppercase().collect(), + // `f5`, or a key name this build does not know: show it verbatim rather + // than inventing a spelling. + _ => title_case(key), + } +} + +/// The display name of a key gpui spells with a word. +/// +/// macOS shows glyphs for these; elsewhere they get conventional capitalisation. +fn named_key(key: &str, style: KeystrokeStyle) -> Option<&'static str> { + let symbols = matches!(style, KeystrokeStyle::Symbols); + Some(match key { + "enter" => { + if symbols { + "↩" + } else { + "Enter" + } + } + "escape" => { + if symbols { + "⎋" + } else { + "Esc" + } + } + "tab" => { + if symbols { + "⇥" + } else { + "Tab" + } + } + "space" => { + if symbols { + "␣" + } else { + "Space" + } + } + "backspace" => { + if symbols { + "⌫" + } else { + "Backspace" + } + } + "delete" => { + if symbols { + "⌦" + } else { + "Delete" + } + } + "up" => { + if symbols { + "↑" + } else { + "Up" + } + } + "down" => { + if symbols { + "↓" + } else { + "Down" + } + } + "left" => { + if symbols { + "←" + } else { + "Left" + } + } + "right" => { + if symbols { + "→" + } else { + "Right" + } + } + "home" => { + if symbols { + "↖" + } else { + "Home" + } + } + "end" => { + if symbols { + "↘" + } else { + "End" + } + } + "pageup" => { + if symbols { + "⇞" + } else { + "PageUp" + } + } + "pagedown" => { + if symbols { + "⇟" + } else { + "PageDown" + } + } + "insert" => "Insert", + // A modifier bound as the key in its own right. + "shift" => { + if symbols { + "⇧" + } else { + "Shift" + } + } + "control" => { + if symbols { + "^" + } else { + "Ctrl" + } + } + "alt" => { + if symbols { + "⌥" + } else { + "Alt" + } + } + "platform" => { + if symbols { + "⌘" + } else if cfg!(target_os = "windows") { + "Win" + } else { + "Super" + } + } + "function" => { + if symbols { + "fn" + } else { + "Fn" + } + } + _ => return None, + }) +} + +/// Upper-cases the first character, leaving the rest — `f5` → `F5`. +fn title_case(value: &str) -> String { + let mut chars = value.chars(); + match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_primary_modifier_follows_the_platform() { + assert_eq!( + humanize_keystroke("secondary-shift-r", KeystrokeStyle::Words).as_deref(), + Some("Ctrl+Shift+R") + ); + assert_eq!( + humanize_keystroke("secondary-shift-r", KeystrokeStyle::Symbols).as_deref(), + // `secondary` is `ctrl` off macOS, which is `^` in the glyph style. + Some(if cfg!(target_os = "macos") { + "⌘⇧R" + } else { + "^⇧R" + }) + ); + } + + #[test] + fn an_explicit_platform_modifier_renders_the_same_either_way() { + assert_eq!( + humanize_keystroke("cmd-shift-r", KeystrokeStyle::Symbols).as_deref(), + Some("⌘⇧R") + ); + } + + #[test] + fn named_keys_get_conventional_names() { + for (source, words) in [ + ("secondary-enter", "Ctrl+Enter"), + ("escape", "Esc"), + ("f5", "F5"), + ("shift-tab", "Shift+Tab"), + ("secondary-up", "Ctrl+Up"), + ("alt-5", "Alt+5"), + ("space", "Space"), + ("delete", "Delete"), + ("secondary-,", "Ctrl+,"), + ("secondary-[", "Ctrl+["), + ] { + assert_eq!( + humanize_keystroke(source, KeystrokeStyle::Words).as_deref(), + Some(words), + "{source}" + ); + } + } + + /// `?` and `G` reach gpui as shift plus a base key; the label has to show + /// the character the user actually types. + #[test] + fn shifted_characters_keep_the_shift_modifier_visible() { + assert_eq!( + humanize_keystroke("?", KeystrokeStyle::Words).as_deref(), + Some("?") + ); + assert_eq!( + humanize_keystroke("shift-g", KeystrokeStyle::Words).as_deref(), + Some("Shift+G") + ); + // `G` parses to shift-g, so both spellings render identically. + assert_eq!( + humanize_keystroke("G", KeystrokeStyle::Words), + humanize_keystroke("shift-g", KeystrokeStyle::Words) + ); + } + + #[test] + fn a_bare_letter_is_upper_cased() { + assert_eq!( + humanize_keystroke("j", KeystrokeStyle::Words).as_deref(), + Some("J") + ); + assert_eq!( + humanize_keystroke("j", KeystrokeStyle::Symbols).as_deref(), + Some("J") + ); + } + + #[test] + fn a_chord_renders_every_keystroke() { + assert_eq!( + humanize_sequence("ctrl-k ctrl-o", KeystrokeStyle::Words).as_deref(), + Some("Ctrl+K Ctrl+O") + ); + assert_eq!( + humanize_sequence("ctrl-k ctrl-o", KeystrokeStyle::Symbols).as_deref(), + Some("^K ^O") + ); + } + + #[test] + fn an_unparseable_keystroke_has_no_label() { + assert_eq!(humanize_keystroke("ctrl-a-b", KeystrokeStyle::Words), None); + assert_eq!(humanize_sequence("", KeystrokeStyle::Words), None); + // One bad keystroke discards the whole chord. + assert_eq!( + humanize_sequence("ctrl-k ctrl-a-b", KeystrokeStyle::Words), + None + ); + } + + #[test] + fn alternative_bindings_are_joined_and_deduplicated() { + assert_eq!( + join_bindings(["Ctrl+Shift+S", "Ctrl+U"]).as_deref(), + Some("Ctrl+Shift+S or Ctrl+U") + ); + // `down` and `j` in two contexts is still one thing to press. + assert_eq!(join_bindings(["Down", "Down"]).as_deref(), Some("Down")); + assert_eq!(join_bindings(std::iter::empty()), None); + } + + /// The source files that must show shortcuts the keymap decided, not + /// shortcuts a developer typed. Each is scanned by + /// [`no_surface_hardcodes_a_chord`]; add a file here when you route a new + /// shortcut display through [`super::shortcut`]. + const SURFACES: &[(&str, &str)] = &[ + ("shortcuts_help.rs", include_str!("../shortcuts_help.rs")), + ("command_palette.rs", include_str!("../command_palette.rs")), + ( + "settings_window/view.rs", + include_str!("../settings_window/view.rs"), + ), + ("toolbar.rs", include_str!("../toolbar.rs")), + ("title_bar.rs", include_str!("../title_bar.rs")), + ( + "workspace/layout.rs", + include_str!("../workspace/layout.rs"), + ), + ( + "workspace/commands.rs", + include_str!("../workspace/commands.rs"), + ), + ]; + + /// Spellings that only ever appear in a hand-written shortcut label. + /// + /// A modifier joined to something with `+` or `-` cannot occur in Rust code + /// outside a string, so this needs no quote handling. Built at runtime so + /// this test's own source does not trip it. + /// + /// It does not catch a bare `"?"` or `"j / k"` — a single character is + /// indistinguishable from ordinary text — so it is a floor, not a proof. + fn chord_needles() -> Vec { + ["Ctrl", "Cmd", "Alt", "Shift", "Win", "Super"] + .into_iter() + .flat_map(|modifier| ["+", "-"].map(move |joiner| format!("{modifier}{joiner}"))) + .chain(["⌘".to_owned(), "⌥".to_owned(), "⇧".to_owned()]) + .collect() + } + + /// The `Ctrl+Shift+F`-for-Fetch drift happened because a shortcut label was a + /// literal in a list nobody re-checked. Deleting the literals fixed it once; + /// this test is what stops the next one being written, by failing the build if + /// a user-facing surface spells a chord out again instead of asking the + /// keymap. + /// + /// Doc comments and the test modules are skipped — prose may name a chord, + /// and an assertion has to spell out what it expects. + #[test] + fn no_surface_hardcodes_a_chord() { + let needles = chord_needles(); + for (name, source) in SURFACES { + let code = source + .split_once("\n#[cfg(test)]") + .map_or(*source, |(before, _)| before); + for (number, line) in code.lines().enumerate() { + let trimmed = line.trim_start(); + if trimmed.starts_with("//") { + continue; + } + for needle in &needles { + assert!( + !line.contains(needle.as_str()), + "{name}:{} spells a keystroke out instead of reading it from the \ + keymap — use `keymap::shortcut` or `keymap::command_tooltip`:\n {}", + number + 1, + line.trim() + ); + } + } + } + } + + /// The scanner has to actually catch the string the old code contained, + /// otherwise it is a test that can never fail. + #[test] + fn the_chord_scanner_catches_the_label_that_drifted() { + let needles = chord_needles(); + for offender in [ + // The literal the shortcut help used to carry for Fetch. + r#"("Ctrl+Shift+F", "Fetch"),"#, + // The palette's hint field. + r#"Some("Ctrl+Shift+F"),"#, + // A chord tucked inside a longer sentence. + r#"tooltip_text: "Fetch from remote (Ctrl+Shift+R)","#, + // The macOS spelling. + r#"Label::new("⌘⇧R")"#, + ] { + assert!( + needles + .iter() + .any(|needle| offender.contains(needle.as_str())), + "the scanner would not have caught {offender}" + ); + } + } + + /// The glyph style exists to match what macOS shows elsewhere, so on macOS + /// it must agree with gpui's own rendering for the cases gpui spells with + /// glyphs. rgitui extends the set to keys gpui leaves as bare words + /// (`enter`, `space`, …), which is why the comparison is limited. + #[test] + #[cfg(target_os = "macos")] + fn the_glyph_style_matches_gpui_for_the_keys_gpui_spells_with_glyphs() { + for source in [ + "cmd-shift-r", + "cmd-s", + "ctrl-alt-cmd-shift-a", + "escape", + "cmd-up", + "shift-tab", + "j", + ] { + let keystroke = Keystroke::parse(source).expect("the test keystrokes parse"); + assert_eq!( + humanize_keystroke(source, KeystrokeStyle::Symbols).as_deref(), + Some(keystroke.to_string().as_str()), + "{source} drifted from gpui's rendering" + ); + } + } +} diff --git a/crates/rgitui_workspace/src/keymap/loader.rs b/crates/rgitui_workspace/src/keymap/loader.rs index daa8798..1a5bad3 100644 --- a/crates/rgitui_workspace/src/keymap/loader.rs +++ b/crates/rgitui_workspace/src/keymap/loader.rs @@ -44,7 +44,9 @@ use serde::de::{MapAccess, Visitor}; use serde::{Deserialize, Deserializer}; use super::conflict::{self, BindingSource, BindingSpec, ConflictReport, NO_ACTION}; +use super::display::KeystrokeStyle; use super::registry::ALL_COMMANDS; +use super::summary::KeymapSummary; /// A keystroke-to-action map that keeps the order the entries were written in. /// @@ -108,6 +110,9 @@ pub struct LoadedKeymap { pub bindings: Vec, /// Problems the user should fix: parse errors, unknown actions, conflicts. pub problems: Vec, + /// The same bindings, labelled for display. Everything the UI shows about a + /// shortcut is read from here, so no surface can drift from the keymap. + pub summary: KeymapSummary, } /// Path of the user keymap, alongside `settings.json` in rgitui's config dir. @@ -312,8 +317,16 @@ pub fn load_from_content(content: &str, cx: &App) -> LoadedKeymap { }); problems.extend(plan.problems); + // Derived from the specs that are about to be bound, so the labels the UI + // shows and the bindings gpui matches cannot disagree. + let summary = KeymapSummary::build(&specs, &plan.keep, &report, KeystrokeStyle::platform()); + let bindings = build_bindings(&pending, &plan.keep, &mut problems, cx); - LoadedKeymap { bindings, problems } + LoadedKeymap { + bindings, + problems, + summary, + } } /// Constructs the gpui bindings for the planned candidates. diff --git a/crates/rgitui_workspace/src/keymap/mod.rs b/crates/rgitui_workspace/src/keymap/mod.rs index df3b193..4933310 100644 --- a/crates/rgitui_workspace/src/keymap/mod.rs +++ b/crates/rgitui_workspace/src/keymap/mod.rs @@ -5,6 +5,10 @@ //! gpui action structs and [`ALL_COMMANDS`]. //! * [`conflict`] detects ambiguous bindings before gpui silently resolves them. //! * [`loader`] reads `keymap.json` and assembles the final binding list. +//! * [`display`] renders a keystroke the way the user reads it. +//! * [`summary`] records what the load produced — the effective binding of every +//! command, where it came from and what went wrong — and is what every +//! shortcut shown anywhere in the UI is read from. //! * [`generate`] renders the committed `docs/KEYBINDINGS.md` and //! `docs/keymap.schema.json`. //! @@ -16,17 +20,22 @@ pub(crate) mod macros; pub mod conflict; +pub mod display; pub mod generate; pub mod loader; pub mod registry; +pub mod summary; use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use gpui::{App, Global}; +pub use display::{humanize_sequence, KeystrokeStyle}; pub use loader::keymap_path; pub use registry::{actions, attach_actions, CommandId, CommandMeta, ALL_COMMANDS}; +pub use summary::{CommandBindings, CommandGroup, EffectiveBinding, KeymapSummary}; /// Declares a view's key context and attaches the commands it handles. /// @@ -73,6 +82,10 @@ pub struct KeymapState { pub binding_count: usize, /// Incremented on every load, so observers can tell reloads apart. pub generation: usize, + /// The bindings in force, labelled for display. Read at render time by every + /// surface that shows a shortcut; `Arc` because those reads happen while the + /// rest of the app state is already borrowed. + pub summary: Arc, } impl Global for KeymapState {} @@ -84,6 +97,40 @@ impl KeymapState { } } +/// The bindings in force, for rendering a shortcut anywhere in the UI. +/// +/// Falls back to the registry defaults when the keymap has not loaded yet, so a +/// caller never has to decide what to show in that case. +pub fn summary(cx: &App) -> Arc { + cx.try_global::() + .map(|state| state.summary.clone()) + .unwrap_or_else(summary::fallback) +} + +/// The humanised keystrokes for a command, or `None` when it is unbound. +/// +/// The one accessor every shortcut hint in the UI goes through — see +/// [`summary`] for why a literal would be a lie. +pub fn shortcut(id: CommandId, cx: &App) -> Option { + summary(cx).display(id) +} + +/// A tooltip captioned `text` and annotated with the command's current keystroke. +/// +/// The keystroke is looked up when the tooltip is built rather than when the +/// button is, so it follows a `keymap.json` reload without the button having to +/// re-render. An unbound command gets a plain tooltip instead of an empty chip. +pub fn command_tooltip( + text: impl Into, + id: CommandId, +) -> impl Fn(&mut gpui::Window, &mut App) -> gpui::AnyView { + let text = text.into(); + move |window, cx| match shortcut(id, cx) { + Some(keystrokes) => rgitui_ui::Tooltip::with_shortcut(text.clone(), keystrokes)(window, cx), + None => rgitui_ui::Tooltip::text(text.clone())(window, cx), + } +} + /// Keeps the `keymap.json` watcher alive for the lifetime of the app. struct KeymapWatcher { #[allow(dead_code, reason = "dropping the watcher stops the notifications")] @@ -115,6 +162,7 @@ pub fn reload(cx: &mut App) { problems: loaded.problems, binding_count, generation, + summary: Arc::new(loaded.summary), }); } diff --git a/crates/rgitui_workspace/src/keymap/summary.rs b/crates/rgitui_workspace/src/keymap/summary.rs new file mode 100644 index 0000000..1db1c25 --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/summary.rs @@ -0,0 +1,611 @@ +//! What the keymap load actually produced, in a form the UI can render. +//! +//! Every shortcut rgitui shows the user comes from here: the shortcut +//! reference, the command palette hints, the settings quick reference, the +//! toolbar tooltips and the home screen. One source means the surfaces cannot +//! disagree, and it means they follow the user's `keymap.json` rather than +//! advertising a default the user has rebound. +//! +//! # Why not ask gpui +//! +//! [`gpui::Keymap::bindings_for_action`] returns the bindings gpui holds for an +//! action, which is close but not enough: +//! +//! * it cannot say whether a binding came from the registry or from +//! `keymap.json`, which is exactly what the user wants flagged; +//! * it only filters bindings an explicit `null` removed, so after +//! `"ctrl-s": "rgitui::Commit"` it would still report `ctrl-s` for +//! `StageAll`, a keystroke that can no longer reach it; and +//! * conflicts are rgitui's own analysis ([`super::conflict`]) and gpui knows +//! nothing about them. +//! +//! So [`KeymapSummary::build`] is handed the very list of [`BindingSpec`]s that +//! is turned into `gpui::KeyBinding`s, plus the indices that survived, and +//! derives the display from that. It agrees with the live keymap by +//! construction, and it is pure — no `App`, no window, unit-testable. +//! +//! # Shadowing +//! +//! A binding is *shadowed* when a later applied binding claims the same +//! keystroke in an overlapping context. gpui resolves that silently in the +//! later binding's favour, so the earlier one is dropped from the display and +//! the affected command gets a warning saying where its keystroke went. That is +//! how a rebind shows up as "this command lost its shortcut" instead of as a +//! shortcut that does nothing. + +use std::sync::Arc; + +use super::conflict::{ + self, BindingSource, BindingSpec, ConflictReport, NormalizedKeystroke, NO_ACTION, +}; +use super::display::{self, KeystrokeStyle}; +use super::registry::{command_for_action, CommandId, ALL_COMMANDS}; + +/// One binding of one command, as it will actually fire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveBinding { + /// Keystrokes as written, e.g. `secondary-shift-r`. + pub keystrokes: String, + /// Humanised label, e.g. `Ctrl+Shift+R`. + pub display: String, + /// Key context the binding is scoped to, or `None` for "everywhere". + pub context: Option, + /// Whether the registry or the user's `keymap.json` supplied it. + pub source: BindingSource, +} + +impl EffectiveBinding { + /// Whether this binding came from the user's `keymap.json`. + pub fn is_user_defined(&self) -> bool { + self.source == BindingSource::User + } +} + +/// Everything the UI needs to show about one command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandBindings { + /// The command. + pub command: CommandId, + /// Bindings that will fire, in precedence order (last wins). + pub bindings: Vec, + /// Problems the user should know about: a binding of theirs that was + /// dropped, or a keystroke this command lost to another. + pub warnings: Vec, +} + +impl CommandBindings { + /// The humanised keystrokes, or `None` when the command is unbound. + pub fn display(&self) -> Option { + display::join_bindings(self.bindings.iter().map(|binding| binding.display.as_str())) + } + + /// Whether any of this command's bindings came from `keymap.json`. + pub fn is_user_defined(&self) -> bool { + self.bindings.iter().any(EffectiveBinding::is_user_defined) + } +} + +/// A group of commands sharing a `commands!` view block, which is also the key +/// context they are scoped to. Drives the layout of the shortcut reference. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandGroup { + /// The view name from the registry, e.g. `DiffViewer`. + pub view: &'static str, + /// The distinct key contexts the group's bindings use. + pub contexts: Vec<&'static str>, + /// The group's commands: bound ones first, each in registry order. + pub commands: Vec, +} + +impl CommandGroup { + /// A sentence describing when the group's shortcuts are live, derived from + /// the key contexts rather than written by hand. + pub fn description(&self) -> String { + match self.contexts.as_slice() { + [] => "Reachable from the command palette only.".to_owned(), + contexts => format!("Active while `{}` matches.", contexts.join("`, `")), + } + } +} + +/// The bindings in force, grouped and labelled for display. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct KeymapSummary { + /// Every command, in registry order. + commands: Vec, + /// Warnings that name no registry command — a `keymap.json` binding for an + /// action rgitui does not know, for instance. Surfaced so nothing is lost. + pub unattributed_warnings: Vec, +} + +impl KeymapSummary { + /// Derives the summary from the specs handed to gpui. + /// + /// `specs` must be in the order they are applied (defaults then user + /// bindings) and `applied` the indices that survived conflict detection and + /// validation, exactly as the loader computed them. + pub fn build( + specs: &[BindingSpec], + applied: &[usize], + report: &ConflictReport, + style: KeystrokeStyle, + ) -> Self { + let mut commands: Vec = ALL_COMMANDS + .iter() + .map(|meta| CommandBindings { + command: meta.id, + bindings: Vec::new(), + warnings: Vec::new(), + }) + .collect(); + let mut unattributed_warnings = Vec::new(); + + for conflict in &report.conflicts { + let ignored = &specs[conflict.ignored]; + warn( + &mut commands, + &mut unattributed_warnings, + &ignored.action, + conflict.message.clone(), + ); + } + + // Only applied specs can fire, and only in the order they were applied. + let applied: Vec<&BindingSpec> = applied.iter().map(|&index| &specs[index]).collect(); + let sequences: Vec>> = applied + .iter() + .map(|spec| conflict::normalize_sequence(&spec.keystrokes)) + .collect(); + + for (index, spec) in applied.iter().enumerate() { + if spec.is_unbind() { + continue; + } + let Some(display) = display::humanize_sequence(&spec.keystrokes, style) else { + continue; + }; + + if let Some(shadow) = shadow_of(index, &applied, &sequences) { + warn( + &mut commands, + &mut unattributed_warnings, + &spec.action, + shadow_message(&display, applied[shadow]), + ); + continue; + } + + let Some(id) = command_for_action(&spec.action) else { + continue; + }; + let Some(entry) = commands.iter_mut().find(|entry| entry.command == id) else { + continue; + }; + entry.bindings.push(EffectiveBinding { + keystrokes: spec.keystrokes.clone(), + display, + context: spec.context.clone(), + source: spec.source, + }); + } + + Self { + commands, + unattributed_warnings, + } + } + + /// The registry defaults on their own, with no user keymap. + /// + /// Used as the fallback before the keymap has loaded, and by the tests that + /// pin the default labels. + pub fn defaults(style: KeystrokeStyle) -> Self { + let specs = super::loader::default_specs(); + let report = conflict::detect_conflicts(&specs); + let applied: Vec = (0..specs.len()).filter(|i| report.is_kept(*i)).collect(); + Self::build(&specs, &applied, &report, style) + } + + /// Every command, in registry order. + pub fn commands(&self) -> &[CommandBindings] { + &self.commands + } + + /// What is known about one command. + pub fn command(&self, id: CommandId) -> Option<&CommandBindings> { + self.commands.iter().find(|entry| entry.command == id) + } + + /// The humanised keystrokes for one command, or `None` when it is unbound. + /// + /// This is the accessor every shortcut hint in the UI goes through. + pub fn display(&self, id: CommandId) -> Option { + self.command(id).and_then(CommandBindings::display) + } + + /// Whether the user rebound this command in `keymap.json`. + pub fn is_user_defined(&self, id: CommandId) -> bool { + self.command(id) + .is_some_and(CommandBindings::is_user_defined) + } + + /// Warnings for one command: dropped bindings and lost keystrokes. + pub fn warnings(&self, id: CommandId) -> &[String] { + self.command(id).map_or(&[], |entry| &entry.warnings) + } + + /// Number of bindings that came from `keymap.json`. + pub fn user_binding_count(&self) -> usize { + self.commands + .iter() + .flat_map(|entry| &entry.bindings) + .filter(|binding| binding.is_user_defined()) + .count() + } + + /// Number of commands carrying at least one warning. + pub fn warning_count(&self) -> usize { + self.commands + .iter() + .filter(|entry| !entry.warnings.is_empty()) + .count() + + usize::from(!self.unattributed_warnings.is_empty()) + } + + /// Number of commands that have at least one binding. + pub fn bound_command_count(&self) -> usize { + self.commands + .iter() + .filter(|entry| !entry.bindings.is_empty()) + .count() + } + + /// The commands grouped by `commands!` view block, bound ones first. + /// + /// Bound before unbound because the reference is read to look a keystroke + /// up; the unbound tail is kept so the palette-only commands are still + /// discoverable, and so a command whose binding the user removed does not + /// silently vanish. + pub fn groups(&self) -> Vec { + let mut groups: Vec = Vec::new(); + + for meta in ALL_COMMANDS { + let Some(entry) = self.command(meta.id) else { + continue; + }; + let group = match groups.iter_mut().find(|group| group.view == meta.view) { + Some(group) => group, + None => { + groups.push(CommandGroup { + view: meta.view, + contexts: Vec::new(), + commands: Vec::new(), + }); + groups.last_mut().expect("just pushed") + } + }; + for (_, context) in meta.default_bindings { + if !group.contexts.contains(context) { + group.contexts.push(context); + } + } + group.commands.push(entry.clone()); + } + + for group in &mut groups { + group + .commands + .sort_by_key(|entry| entry.bindings.is_empty()); + } + groups + } +} + +/// Files a warning against the command that owns `action`, or against the +/// summary as a whole when the action belongs to no registry command. +fn warn( + commands: &mut [CommandBindings], + unattributed: &mut Vec, + action: &str, + message: String, +) { + match command_for_action(action) + .and_then(|id| commands.iter_mut().find(|entry| entry.command == id)) + { + Some(entry) => entry.warnings.push(message), + None => unattributed.push(message), + } +} + +/// Index of the applied binding that takes `index`'s keystroke away, if any. +/// +/// A later binding shadows an earlier one when the keystroke sequences match and +/// the contexts overlap — including an unbind, which is a deliberate removal. +fn shadow_of( + index: usize, + applied: &[&BindingSpec], + sequences: &[Option>], +) -> Option { + let keys = sequences[index].as_ref()?; + ((index + 1)..applied.len()).rev().find(|&later| { + sequences[later].as_ref() == Some(keys) + && applied[later].action != applied[index].action + && conflict::contexts_overlap( + applied[index].context.as_deref(), + applied[later].context.as_deref(), + ) + }) +} + +/// Explains where a command's keystroke went. +fn shadow_message(display: &str, winner: &BindingSpec) -> String { + if winner.action == NO_ACTION { + return format!("`{display}` was removed by keymap.json."); + } + let winner_label = command_for_action(&winner.action) + .map(|id| format!("`{}`", id.description())) + .unwrap_or_else(|| format!("`{}`", winner.action)); + format!( + "`{display}` now runs {winner_label} ({}), so it no longer reaches this command.", + winner.source + ) +} + +/// The summary shown before the keymap has loaded, built once. +pub fn fallback() -> Arc { + use std::sync::OnceLock; + static FALLBACK: OnceLock> = OnceLock::new(); + FALLBACK + .get_or_init(|| Arc::new(KeymapSummary::defaults(KeystrokeStyle::platform()))) + .clone() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The defaults in the word style, which is what the assertions below spell. + fn defaults() -> KeymapSummary { + KeymapSummary::defaults(KeystrokeStyle::Words) + } + + /// Builds a summary from the defaults plus the given user bindings, applying + /// the same conflict resolution the loader does. + fn with_user(user: &[BindingSpec]) -> KeymapSummary { + let mut specs = super::super::loader::default_specs(); + specs.extend(user.iter().cloned()); + let report = conflict::detect_conflicts(&specs); + let applied: Vec = (0..specs.len()).filter(|i| report.is_kept(*i)).collect(); + KeymapSummary::build(&specs, &applied, &report, KeystrokeStyle::Words) + } + + fn user(keystrokes: &str, context: &str, action: &str) -> BindingSpec { + BindingSpec::user_binding(keystrokes, Some(context), action) + } + + /// The drift that motivated this work: the help used to advertise + /// `Ctrl+Shift+F` for Fetch while the registry bound `Ctrl+Shift+R`. + #[test] + fn a_default_label_comes_from_the_registry_keystroke() { + let summary = defaults(); + assert_eq!( + summary.display(CommandId::Fetch).as_deref(), + Some("Ctrl+Shift+R") + ); + assert_eq!( + summary.display(CommandId::Fetch), + display::humanize_sequence( + CommandId::Fetch.default_bindings()[0].0, + KeystrokeStyle::Words + ) + ); + assert!(!summary.is_user_defined(CommandId::Fetch)); + } + + #[test] + fn every_default_binding_is_labelled() { + let summary = defaults(); + for meta in ALL_COMMANDS { + let entry = summary + .command(meta.id) + .expect("every command is summarised"); + assert_eq!( + entry.bindings.len(), + meta.default_bindings.len(), + "{} lost a binding: {entry:?}", + meta.action_name + ); + for binding in &entry.bindings { + assert!( + !binding.display.is_empty(), + "{} has an empty label", + meta.action_name + ); + assert_eq!(binding.source, BindingSource::Default); + } + assert!( + entry.warnings.is_empty(), + "{} warns about the defaults: {:?}", + meta.action_name, + entry.warnings + ); + } + assert!(summary.unattributed_warnings.is_empty()); + assert_eq!(summary.user_binding_count(), 0); + assert_eq!(summary.warning_count(), 0); + } + + #[test] + fn a_command_with_two_keystrokes_lists_both() { + assert_eq!( + defaults().display(CommandId::UnstageAll).as_deref(), + Some("Ctrl+Shift+S or Ctrl+U") + ); + } + + /// One command binding one keystroke in two contexts is one label. + #[test] + fn keystrokes_bound_twice_in_different_contexts_show_once() { + assert_eq!( + defaults().display(CommandId::SelectNext).as_deref(), + Some("Down or J") + ); + } + + #[test] + fn an_unbound_command_has_no_label() { + let summary = defaults(); + assert_eq!(summary.display(CommandId::Pull), None); + assert!(summary + .command(CommandId::Pull) + .expect("Pull is summarised") + .bindings + .is_empty()); + } + + #[test] + fn a_user_binding_is_labelled_and_marked() { + let summary = with_user(&[user("ctrl-alt-p", "Workspace", "rgitui::Pull")]); + assert_eq!( + summary.display(CommandId::Pull).as_deref(), + Some("Ctrl+Alt+P") + ); + assert!(summary.is_user_defined(CommandId::Pull)); + assert_eq!(summary.user_binding_count(), 1); + // The defaults it did not touch are untouched. + assert!(!summary.is_user_defined(CommandId::Fetch)); + } + + /// Rebinding a default keystroke moves the label to the new command and + /// tells the old one where its keystroke went. + #[test] + fn rebinding_a_keystroke_moves_the_label() { + let summary = with_user(&[user("secondary-s", "Workspace && !modal", "rgitui::Commit")]); + + let commit = summary + .command(CommandId::Commit) + .expect("Commit is summarised"); + assert!(commit + .bindings + .iter() + .any(|binding| binding.display == "Ctrl+S" && binding.is_user_defined())); + + assert_eq!(summary.display(CommandId::StageAll), None); + let warnings = summary.warnings(CommandId::StageAll); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("Ctrl+S"), "{warnings:?}"); + assert!(warnings[0].contains("keymap.json"), "{warnings:?}"); + } + + #[test] + fn an_unbind_removes_the_label_and_says_so() { + let summary = with_user(&[user("secondary-s", "Workspace && !modal", NO_ACTION)]); + assert_eq!(summary.display(CommandId::StageAll), None); + let warnings = summary.warnings(CommandId::StageAll); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!( + warnings[0].contains("was removed by keymap.json"), + "{warnings:?}" + ); + } + + /// The headline: the user must be able to see which of their bindings was + /// ignored and why, without reading a log. + #[test] + fn a_conflict_is_reported_against_the_command_that_lost() { + let summary = with_user(&[ + user("ctrl-alt-9", "Workspace", "rgitui::Fetch"), + user("ctrl-alt-9", "Workspace", "rgitui::Pull"), + ]); + + let warnings = summary.warnings(CommandId::Fetch); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("ctrl-alt-9"), "{warnings:?}"); + assert!(warnings[0].contains("rgitui::Fetch"), "{warnings:?}"); + assert!(warnings[0].contains("is ignored"), "{warnings:?}"); + // Fetch keeps its own default; only the extra binding was dropped. + assert_eq!( + summary.display(CommandId::Fetch).as_deref(), + Some("Ctrl+Shift+R") + ); + assert_eq!( + summary.display(CommandId::Pull).as_deref(), + Some("Ctrl+Alt+9") + ); + assert_eq!(summary.warning_count(), 1); + } + + #[test] + fn a_chord_shadowed_by_its_prefix_is_reported() { + let summary = with_user(&[ + user("ctrl-alt-k ctrl-alt-o", "Workspace", "rgitui::OpenRepo"), + user("ctrl-alt-k", "Workspace", "rgitui::Pull"), + ]); + let warnings = summary.warnings(CommandId::Pull); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("shadows the chord"), "{warnings:?}"); + assert_eq!(summary.display(CommandId::Pull), None); + // The default comes first: defaults are applied before user bindings. + assert_eq!( + summary.display(CommandId::OpenRepo).as_deref(), + Some("Ctrl+O or Ctrl+Alt+K Ctrl+Alt+O") + ); + } + + #[test] + fn a_binding_for_an_unknown_action_is_not_silently_dropped() { + let summary = with_user(&[ + user("ctrl-alt-9", "Workspace", "rgitui::Nonexistent"), + user("ctrl-alt-9", "Workspace", "rgitui::Pull"), + ]); + assert_eq!(summary.unattributed_warnings.len(), 1, "{summary:?}"); + assert!(summary.unattributed_warnings[0].contains("rgitui::Nonexistent")); + assert!(summary.warning_count() > 0); + } + + #[test] + fn groups_follow_the_registry_views_and_put_bound_commands_first() { + let groups = defaults().groups(); + let views: Vec<&str> = groups.iter().map(|group| group.view).collect(); + assert_eq!(views.first(), Some(&"Workspace")); + assert!(views.contains(&"DiffViewer"), "{views:?}"); + + let total: usize = groups.iter().map(|group| group.commands.len()).sum(); + assert_eq!( + total, + ALL_COMMANDS.len(), + "every command is in exactly one group" + ); + + for group in &groups { + assert!(!group.description().is_empty()); + let first_unbound = group + .commands + .iter() + .position(|entry| entry.bindings.is_empty()); + if let Some(first_unbound) = first_unbound { + assert!( + group.commands[first_unbound..] + .iter() + .all(|entry| entry.bindings.is_empty()), + "{}: a bound command follows an unbound one", + group.view + ); + } + } + } + + #[test] + fn a_group_description_names_its_key_contexts() { + let groups = defaults().groups(); + let diff = groups + .iter() + .find(|group| group.view == "DiffViewer") + .expect("the diff viewer is a group"); + assert!( + diff.description().contains("DiffViewer && !TextInput"), + "{}", + diff.description() + ); + } +} diff --git a/crates/rgitui_workspace/src/settings_window/view.rs b/crates/rgitui_workspace/src/settings_window/view.rs index b7ec1a9..f5cf0b0 100644 --- a/crates/rgitui_workspace/src/settings_window/view.rs +++ b/crates/rgitui_workspace/src/settings_window/view.rs @@ -31,6 +31,24 @@ use rgitui_ui::{ use super::events::SettingsViewEvent; use super::{SettingsWindowAction, SettingsWindowActionGlobal}; use crate::github_device_flow::{self, DeviceFlowStatus}; +use crate::CommandId; + +/// The commands the General section shows a keystroke for. +/// +/// Only the command *ids* are listed — the label is the command's doc comment and +/// the keystroke comes from the keymap, so this cannot drift from what the keys +/// actually do. It is a short list on purpose; the full reference lives in the +/// workspace's shortcut panel. +const QUICK_REFERENCE_COMMANDS: &[CommandId] = &[ + CommandId::CommandPalette, + CommandId::Search, + CommandId::StageAll, + CommandId::UnstageAll, + CommandId::Commit, + CommandId::OpenRepo, + CommandId::Refresh, + CommandId::Settings, +]; /// Which section of the settings is currently active. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -3846,25 +3864,18 @@ impl SettingsView { section = section.child(tools_card); section = section.child(Self::section_divider(cx)); - // Keyboard shortcuts info card + // Keyboard shortcuts quick reference, read from the keymap in force so + // it follows the user's keymap.json instead of quoting a default. let mut shortcuts_card = Self::setting_card(cx); - shortcuts_card = shortcuts_card.child( - div() - .v_flex() - .gap(px(8.)) - .child(Self::setting_label( - "Keyboard Shortcuts", - "Quick reference for common actions.", - )) - .child(self.render_shortcut_row("Command Palette", "Ctrl+Shift+P", cx)) - .child(self.render_shortcut_row("Search Commits", "Ctrl+F", cx)) - .child(self.render_shortcut_row("Stage All", "Ctrl+S", cx)) - .child(self.render_shortcut_row("Unstage All", "Ctrl+Shift+S", cx)) - .child(self.render_shortcut_row("Commit", "Ctrl+Enter (in message)", cx)) - .child(self.render_shortcut_row("Open Repository", "Ctrl+O", cx)) - .child(self.render_shortcut_row("Refresh", "F5", cx)) - .child(self.render_shortcut_row("Settings", "Ctrl+,", cx)), - ); + let mut shortcuts_list = div().v_flex().gap(px(8.)).child(Self::setting_label( + "Keyboard Shortcuts", + "The bindings in force for a few common actions. Open the full \ + reference from the workspace for all of them.", + )); + for id in QUICK_REFERENCE_COMMANDS { + shortcuts_list = shortcuts_list.child(self.render_shortcut_row(*id, cx)); + } + shortcuts_card = shortcuts_card.child(shortcuts_list); section = section.child(shortcuts_card); section = section.child(Self::section_divider(cx)); @@ -3926,20 +3937,26 @@ impl SettingsView { section } - fn render_shortcut_row( - &self, - action: &str, - shortcut: &str, - cx: &Context, - ) -> impl IntoElement { + /// One quick-reference row: the command's description and the keystroke the + /// keymap currently binds it to. + /// + /// A binding the user defined is tinted and labelled, matching the badge in + /// the full shortcut reference. + fn render_shortcut_row(&self, id: CommandId, cx: &Context) -> impl IntoElement { let colors = cx.colors(); + let summary = crate::keymap::summary(cx); + let shortcut = summary + .display(id) + .unwrap_or_else(|| crate::keymap::display::UNBOUND.to_owned()); + div() .h_flex() .w_full() .items_center() + .gap(px(6.)) .py(px(4.)) .child( - Label::new(SharedString::from(action.to_string())) + Label::new(SharedString::from(id.description())) .size(LabelSize::XSmall) .color(Color::Muted), ) @@ -3953,7 +3970,7 @@ impl SettingsView { .bg(colors.hint_background) .items_center() .child( - Label::new(SharedString::from(shortcut.to_string())) + Label::new(SharedString::from(shortcut)) .size(LabelSize::XSmall) .color(Color::Muted) .weight(FontWeight::SEMIBOLD), diff --git a/crates/rgitui_workspace/src/shortcuts_help.rs b/crates/rgitui_workspace/src/shortcuts_help.rs index b581e69..32f9d49 100644 --- a/crates/rgitui_workspace/src/shortcuts_help.rs +++ b/crates/rgitui_workspace/src/shortcuts_help.rs @@ -1,11 +1,19 @@ -use std::borrow::Cow; +//! The keyboard shortcut reference, rendered from the keymap in force. +//! +//! Nothing here is a literal list of shortcuts. Every row comes from +//! [`crate::keymap::summary`]: the group is a `commands!` view block, the +//! description is the command's doc comment, and the keystroke is the one the +//! user's `keymap.json` actually produced rather than the registry default. use gpui::prelude::*; -use gpui::{div, px, ClickEvent, Context, EventEmitter, FocusHandle, FontWeight, Render, Window}; +use gpui::{ + div, px, ClickEvent, Context, EventEmitter, FocusHandle, FontWeight, Render, SharedString, + Window, +}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize}; -use crate::keymap; +use crate::keymap::{self, CommandBindings, CommandGroup, KeymapSummary}; use crate::CommandId; #[derive(Debug, Clone)] @@ -13,42 +21,6 @@ pub enum ShortcutsHelpEvent { Dismissed, } -struct ShortcutCategory { - title: &'static str, - description: &'static str, - shortcuts: &'static [(&'static str, &'static str)], -} - -/// Name of the platform's primary shortcut modifier, matching -/// `gpui::Modifiers::secondary`: the Command key on macOS, Control elsewhere. -const PRIMARY_MODIFIER: &str = if cfg!(target_os = "macos") { - "Cmd" -} else { - "Ctrl" -}; - -/// Labels that stay on Control everywhere because macOS reserves the Command -/// equivalent: Cmd+Tab is the application switcher and Cmd+H is Hide -/// Application, both swallowed by the WindowServer before the app sees them. -/// `key_handler` binds these to `modifiers.control` rather than the primary -/// modifier, so the help must not promise Cmd. -const PLATFORM_FIXED_LABELS: &[&str] = &["Ctrl+H", "Ctrl+Tab / Ctrl+Shift+Tab"]; - -/// Free-form help copy that names a chord, and so has to track the platform's -/// primary modifier just like the shortcut table does. -const PALETTE_TIP: &str = "Tip: plain-letter shortcuts like d, b, h, j, and k depend on which panel is focused. Use Ctrl+Shift+P for less common actions like reflog, submodules, bisect, and stash management."; -const MORE_ACTIONS_HINT: &str = "More actions: Ctrl+Shift+P"; - -/// Rewrites the `Ctrl` chords in a shortcut label to the platform's primary -/// modifier, so macOS shows `Cmd+Shift+P` rather than `Ctrl+Shift+P`. -fn with_primary_modifier<'a>(label: &'a str, primary: &str) -> Cow<'a, str> { - if primary == "Ctrl" || !label.contains("Ctrl+") || PLATFORM_FIXED_LABELS.contains(&label) { - Cow::Borrowed(label) - } else { - Cow::Owned(label.replace("Ctrl+", &format!("{primary}+"))) - } -} - pub struct ShortcutsHelp { visible: bool, focus_handle: FocusHandle, @@ -98,108 +70,72 @@ impl ShortcutsHelp { } } - fn shortcut_categories() -> Vec { - vec![ - ShortcutCategory { - title: "Workspace", - description: "App-level actions available from anywhere outside active overlays.", - shortcuts: &[ - ("Ctrl+Shift+P", "Open command palette"), - ("Ctrl+O", "Open repository"), - ("Ctrl+H", "Go to workspace home"), - ("Ctrl+W", "Close current tab"), - ("Ctrl+,", "Open settings"), - ("F5", "Refresh repository state"), - ("?", "Open this help"), - ], - }, - ShortcutCategory { - title: "Navigation", - description: "Focus a panel first — plain letters only act on the focused panel.", - shortcuts: &[ - ( - "j / k or Up / Down", - "Move the selection in the focused panel", - ), - ("g / G or Home / End", "Jump to first / last item"), - ("Tab / Shift+Tab", "Cycle focused panel"), - ("Alt+1 / 2 / 3 / 4", "Focus sidebar / graph / detail / diff"), - ( - "Alt+5 / 6 / 7", - "Toggle issues / PRs / branch health panels", - ), - ("Alt+8", "Toggle stashes panel"), - ("Ctrl+Shift+T / Alt+9", "Open theme editor"), - ("Ctrl+Tab / Ctrl+Shift+Tab", "Next / previous tab"), - ("v", "Toggle changed-files view (flat / tree)"), - ("Enter / Space", "Activate selected sidebar item"), - ], - }, - ShortcutCategory { - title: "Views & Search", - description: "Panels and graph tools. Letters act on the panel that has focus.", - shortcuts: &[ - ("Ctrl+F or /", "Toggle commit graph search"), - ("Ctrl+Shift+F", "Search the working tree"), - ("Shift+D", "Toggle diff mode (unified / split)"), - ("d", "In the diff viewer: cycle display mode"), - ("[ / ]", "In the diff viewer: previous / next hunk"), - ( - "y / Shift+C", - "In the graph: copy the commit's SHA / message", - ), - ("d", "In blame or file history: back to the diff"), - ("h / b", "In blame / file history: switch to the other view"), - ("/ or Ctrl+F", "In the detail panel: filter changed files"), - ( - "Mouse drag / Shift+click", - "Select lines in the diff viewer", - ), - ("Ctrl+C", "Copy selected diff lines"), - ("Esc", "Close the innermost overlay, modal or search"), - ], - }, - ShortcutCategory { - title: "Git & AI", - description: - "Common write actions. More advanced operations live in the command palette.", - shortcuts: &[ - ("Ctrl+Shift+R", "Fetch"), - ("Ctrl+S", "Stage all changes"), - ("Ctrl+Shift+S / Ctrl+U", "Unstage all changes"), - ("Ctrl+Enter", "Commit"), - ("Ctrl+B", "Create branch"), - ("Ctrl+Shift+B", "Switch branch (focus sidebar)"), - ("Ctrl+Z / Ctrl+Shift+Z", "Stash changes / pop stash"), - ("Ctrl+G", "Generate AI commit message"), - ("s", "Stage / unstage selected file in sidebar"), - ( - "Alt+S / Alt+U", - "Stage / unstage current hunk in diff viewer", - ), - ("p", "Toggle partial (line-selection) staging mode"), - ( - "s / u", - "Stage / unstage hunks under selection (or cursor hunk)", + /// Renders one command's row: what it does and what it is bound to. + fn render_command(&self, entry: &CommandBindings, cx: &mut Context) -> impl IntoElement { + let colors = cx.colors().clone(); + let hover_bg = colors.ghost_element_hover; + let keystrokes = entry.display(); + + let row = div() + .v_flex() + .w_full() + .py(px(5.)) + .px(px(4.)) + .rounded(px(4.)) + .gap(px(3.)) + .hover(move |s| s.bg(hover_bg)); + + let mut headline = div() + .flex() + .flex_row() + .w_full() + .items_center() + .gap(px(10.)) + .child( + div().flex_1().min_w_0().child( + Label::new(SharedString::from(entry.command.description())) + .size(LabelSize::Small) + .color(if keystrokes.is_some() { + Color::Default + } else { + Color::Muted + }), + ), + ); + + headline = match keystrokes { + Some(keystrokes) => headline.child( + div() + .h_flex() + .flex_shrink_0() + .h(px(24.)) + .px(px(10.)) + .gap_1() + .rounded(px(5.)) + .border_1() + .border_color(colors.border) + .bg(colors.hint_background) + .items_center() + .child( + Label::new(SharedString::from(keystrokes)) + .size(LabelSize::Small) + .weight(FontWeight::BOLD) + .color(Color::Default), ), - ("x / Delete", "Discard selected sidebar item"), - ], - }, - ] - } + ), + None => headline.child( + div().flex_shrink_0().child( + Label::new(keymap::display::UNBOUND) + .size(LabelSize::XSmall) + .color(Color::Placeholder), + ), + ), + }; - fn shortcut_count(categories: &[ShortcutCategory]) -> usize { - categories - .iter() - .map(|category| category.shortcuts.len()) - .sum() + row.child(headline) } - fn render_category( - &self, - category: &ShortcutCategory, - cx: &mut Context, - ) -> impl IntoElement { + fn render_group(&self, group: &CommandGroup, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); let border_variant = colors.border_variant; @@ -214,64 +150,34 @@ impl ShortcutsHelp { .v_flex() .gap(px(4.)) .child( - Label::new(category.title) + Label::new(group.view) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD) .color(Color::Accent), ) .child( - Label::new(category.description) - .size(LabelSize::Small) + Label::new(SharedString::from(group.description())) + .size(LabelSize::XSmall) .color(Color::Muted), ), ), ); - for (key, desc) in category.shortcuts { - let hover_bg = colors.ghost_element_hover; - col = col.child( - div() - .h_flex() - .w_full() - .py(px(5.)) - .px(px(4.)) - .rounded(px(4.)) - .items_center() - .gap(px(16.)) - .hover(move |s| s.bg(hover_bg)) - .child( - div().flex_1().min_w_0().child( - Label::new(*desc) - .size(LabelSize::Small) - .color(Color::Default), - ), - ) - .child( - div() - .h_flex() - .flex_shrink_0() - .h(px(24.)) - .px(px(10.)) - .gap_1() - .rounded(px(5.)) - .border_1() - .border_color(colors.border) - .bg(colors.hint_background) - .items_center() - .child( - Label::new( - with_primary_modifier(key, PRIMARY_MODIFIER).into_owned(), - ) - .size(LabelSize::Small) - .weight(FontWeight::BOLD) - .color(Color::Default), - ), - ), - ); + for entry in &group.commands { + col = col.child(self.render_command(entry, cx)); } col } + + /// The header summary line, counted from the keymap rather than written out. + fn subtitle(summary: &KeymapSummary) -> String { + format!( + "{} of {} commands are bound", + summary.bound_command_count(), + summary.commands().len() + ) + } } impl Render for ShortcutsHelp { @@ -280,8 +186,10 @@ impl Render for ShortcutsHelp { return div().id("shortcuts-help").into_any_element(); } - let categories = Self::shortcut_categories(); - let total_shortcuts = Self::shortcut_count(&categories); + let summary = keymap::summary(cx); + let groups = summary.groups(); + let subtitle = Self::subtitle(&summary); + let palette_hint = keymap::shortcut(CommandId::CommandPalette, cx); let colors = cx.colors().clone(); let viewport = window.viewport_size(); @@ -291,15 +199,28 @@ impl Render for ShortcutsHelp { let modal_height = px((viewport_height - 32.0).clamp(280.0, 720.0)); let use_two_columns = viewport_width >= 960.0; + // Split the groups into two balanced columns by row count, so a long + // block like `Workspace` does not leave the second column empty. let body = if use_two_columns { + let total: usize = groups.iter().map(|group| group.commands.len()).sum(); + let mut running = 0usize; + let split = groups + .iter() + .position(|group| { + running += group.commands.len(); + running * 2 >= total + }) + .map_or(groups.len(), |index| index + 1) + .min(groups.len()); + let mut left_col = div().v_flex().flex_1().min_w_0().gap(px(16.)); - for category in &categories[..2] { - left_col = left_col.child(self.render_category(category, cx)); + for group in &groups[..split] { + left_col = left_col.child(self.render_group(group, cx)); } let mut right_col = div().v_flex().flex_1().min_w_0().gap(px(16.)); - for category in &categories[2..] { - right_col = right_col.child(self.render_category(category, cx)); + for group in &groups[split..] { + right_col = right_col.child(self.render_group(group, cx)); } div() @@ -318,8 +239,8 @@ impl Render for ShortcutsHelp { .into_any_element() } else { let mut column = div().v_flex().w_full().gap(px(16.)); - for category in &categories { - column = column.child(self.render_category(category, cx)); + for group in &groups { + column = column.child(self.render_group(group, cx)); } div() @@ -357,7 +278,9 @@ impl Render for ShortcutsHelp { let modal = div() .id("shortcuts-help-container") .track_focus(&self.focus_handle) - .map(|el| keymap::bind_actions(el, "ShortcutsHelp", &["Menu"], cx, Self::dispatch_command)) + .map(|el| { + keymap::bind_actions(el, "ShortcutsHelp", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .w(modal_width) .h(modal_height) @@ -380,6 +303,7 @@ impl Render for ShortcutsHelp { .border_b_1() .border_color(colors.border_variant) .justify_between() + .gap(px(10.)) .child( div() .h_flex() @@ -403,13 +327,10 @@ impl Render for ShortcutsHelp { .weight(FontWeight::SEMIBOLD), ) .child( - Label::new(format!( - "{} shortcuts across navigation, views, workspace, and git actions", - total_shortcuts - )) - .size(LabelSize::XSmall) - .truncate() - .color(Color::Muted), + Label::new(SharedString::from(subtitle)) + .size(LabelSize::XSmall) + .truncate() + .color(Color::Muted), ), ), ) @@ -435,28 +356,28 @@ impl Render for ShortcutsHelp { ), ) .child( - div() - .w_full() - .px(px(16.)) - .pt(px(12.)) - .child( - div() - .w_full() - .rounded(px(8.)) - .bg(colors.surface_background) - .border_1() - .border_color(colors.border_variant) - .px(px(12.)) - .py(px(10.)) - .child( - Label::new( - with_primary_modifier(PALETTE_TIP, PRIMARY_MODIFIER) - .into_owned(), - ) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ), + div().w_full().px(px(16.)).pt(px(12.)).child( + div() + .w_full() + .rounded(px(8.)) + .bg(colors.surface_background) + .border_1() + .border_color(colors.border_variant) + .px(px(12.)) + .py(px(10.)) + .child( + Label::new(SharedString::from(format!( + "Every shortcut below is shown as your keymap.json leaves \n it. Plain-letter shortcuts only act on the panel that has \n focus — that is what each group's key context means. \n Commands marked {} have no keystroke and are reached from \n the command palette{}.", + keymap::display::UNBOUND, + palette_hint + .as_deref() + .map(|hint| format!(" ({hint})")) + .unwrap_or_default(), + ))) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ), ) .child(body) .child( @@ -475,16 +396,16 @@ impl Render for ShortcutsHelp { .size(LabelSize::XSmall) .color(Color::Placeholder), ) - .when(viewport_width >= 520.0, |footer| { - footer.child( - Label::new( - with_primary_modifier(MORE_ACTIONS_HINT, PRIMARY_MODIFIER) - .into_owned(), + .when_some( + palette_hint.filter(|_| viewport_width >= 520.0), + |footer, hint| { + footer.child( + Label::new(SharedString::from(format!("More actions: {hint}"))) + .size(LabelSize::XSmall) + .color(Color::Muted), ) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - }), + }, + ), ); backdrop.child(modal).into_any_element() @@ -494,6 +415,11 @@ impl Render for ShortcutsHelp { #[cfg(test)] mod tests { use super::*; + use crate::keymap::display::KeystrokeStyle; + + fn defaults() -> KeymapSummary { + KeymapSummary::defaults(KeystrokeStyle::Words) + } #[test] fn test_shortcuts_help_event_debug() { @@ -503,79 +429,54 @@ mod tests { #[test] fn test_shortcuts_help_event_match() { - let event = ShortcutsHelpEvent::Dismissed; - match event { + match ShortcutsHelpEvent::Dismissed { ShortcutsHelpEvent::Dismissed => {} } } + /// Every group the view renders has a heading, a derived key-context blurb + /// and at least one row, so no group can render as an empty box. #[test] - fn primary_modifier_rewrites_every_ctrl_chord_on_macos() { - assert_eq!(with_primary_modifier("Ctrl+Shift+P", "Cmd"), "Cmd+Shift+P"); - assert_eq!( - with_primary_modifier("Ctrl+Z / Ctrl+Shift+Z", "Cmd"), - "Cmd+Z / Cmd+Shift+Z" - ); - } - - #[test] - fn primary_modifier_leaves_labels_untouched_elsewhere() { - assert_eq!( - with_primary_modifier("Ctrl+Shift+P", "Ctrl"), - "Ctrl+Shift+P" - ); - assert_eq!( - with_primary_modifier("Alt+1 / 2 / 3 / 4", "Cmd"), - "Alt+1 / 2 / 3 / 4" - ); - assert_eq!(with_primary_modifier("j / k", "Cmd"), "j / k"); - } - - #[test] - fn every_documented_shortcut_label_is_platform_correct() { - let categories = ShortcutsHelp::shortcut_categories(); - let table = categories - .iter() - .flat_map(|category| category.shortcuts.iter().map(|(label, _)| *label)); - - for label in table.chain([PALETTE_TIP, MORE_ACTIONS_HINT]) { - let rendered = with_primary_modifier(label, PRIMARY_MODIFIER); - if PRIMARY_MODIFIER == "Cmd" && !PLATFORM_FIXED_LABELS.contains(&label) { + fn every_group_has_a_heading_and_rows() { + let summary = defaults(); + let groups = summary.groups(); + assert!(!groups.is_empty()); + for group in &groups { + assert!(!group.view.is_empty()); + assert!(!group.description().is_empty()); + assert!(!group.commands.is_empty(), "{} is empty", group.view); + for entry in &group.commands { assert!( - !rendered.contains("Ctrl+"), - "label {label:?} still advertises Ctrl on a Cmd platform" + !entry.command.description().is_empty(), + "{} has no description to render", + entry.command ); - } else { - assert_eq!(rendered, label); } } } + /// The old hand-written table advertised `Ctrl+Shift+F` for Fetch while the + /// keymap bound `Ctrl+Shift+R`. The row now comes from the keymap, so the + /// two cannot differ. #[test] - fn os_reserved_chords_keep_control_on_every_platform() { - for label in PLATFORM_FIXED_LABELS { - assert_eq!( - with_primary_modifier(label, "Cmd"), - *label, - "{label:?} is bound to Control in key_handler, so the help must not promise Cmd" - ); - } + fn a_row_shows_the_keystroke_the_keymap_binds() { + let summary = defaults(); + assert_eq!( + summary.display(CommandId::Fetch), + crate::keymap::humanize_sequence( + CommandId::Fetch.default_bindings()[0].0, + KeystrokeStyle::Words + ) + ); } #[test] - fn platform_fixed_labels_all_appear_in_the_shortcut_table() { - let categories = ShortcutsHelp::shortcut_categories(); - let documented: Vec<&str> = categories - .iter() - .flat_map(|category| category.shortcuts.iter().map(|(label, _)| *label)) - .collect(); - - for label in PLATFORM_FIXED_LABELS { - assert!( - documented.contains(label), - "{label:?} is exempted from the Cmd rewrite but no longer appears in the table; \ - the exemption is stale" - ); - } + fn the_subtitle_counts_what_the_keymap_holds() { + let summary = defaults(); + let subtitle = ShortcutsHelp::subtitle(&summary); + assert!( + subtitle.contains(&summary.bound_command_count().to_string()), + "{subtitle}" + ); } } diff --git a/crates/rgitui_workspace/src/sidebar.rs b/crates/rgitui_workspace/src/sidebar.rs index 3914916..0762c07 100644 --- a/crates/rgitui_workspace/src/sidebar.rs +++ b/crates/rgitui_workspace/src/sidebar.rs @@ -3679,7 +3679,7 @@ impl Render for Sidebar { .text_color(Color::Deleted.color(cx)) .hover(|s| s.bg(colors.ghost_element_hover)) .cursor_pointer() - .tooltip(Tooltip::text("Discard changes (Ctrl+Z)")) + .tooltip(crate::keymap::command_tooltip("Discard changes", CommandId::DiscardRow)) .on_click({ let w_dis = w.clone(); move |_: &ClickEvent, _: &mut Window, cx: &mut App| { diff --git a/crates/rgitui_workspace/src/title_bar.rs b/crates/rgitui_workspace/src/title_bar.rs index 43ac95a..ea08e84 100644 --- a/crates/rgitui_workspace/src/title_bar.rs +++ b/crates/rgitui_workspace/src/title_bar.rs @@ -5,6 +5,9 @@ use rgitui_ui::{ Icon, IconName, IconSize, Label, LabelSize, Tooltip, WindowControl, WindowControlType, }; +use crate::keymap; +use crate::CommandId; + type ClickHandler = Box; /// The application title bar. @@ -61,9 +64,23 @@ impl TitleBar { .bg(colors.border_variant) } + /// A `key — what it does` hint, with the keystroke read from the keymap. + /// + /// Returns nothing when the command is unbound, so removing a binding in + /// `keymap.json` removes the hint rather than leaving a lie in the title bar. fn render_keyboard_hint( colors: &rgitui_theme::ThemeColors, - key: &'static str, + command: CommandId, + label_text: &'static str, + cx: &App, + ) -> Option { + let key = keymap::shortcut(command, cx)?; + Some(Self::keyboard_hint(colors, key, label_text)) + } + + fn keyboard_hint( + colors: &rgitui_theme::ThemeColors, + key: String, label_text: &'static str, ) -> gpui::Div { div() @@ -79,7 +96,7 @@ impl TitleBar { .flex() .items_center() .child( - Label::new(key) + Label::new(SharedString::from(key)) .size(LabelSize::XSmall) .color(Color::Muted) .weight(gpui::FontWeight::MEDIUM), @@ -260,12 +277,18 @@ impl RenderOnce for TitleBar { .h_flex() .gap(px(12.)) .items_center() - .child(Self::render_keyboard_hint( + .children(Self::render_keyboard_hint( &colors, - "Ctrl+Shift+P", + CommandId::CommandPalette, "Commands", + cx, )) - .child(Self::render_keyboard_hint(&colors, "?", "Help")), + .children(Self::render_keyboard_hint( + &colors, + CommandId::Shortcuts, + "Help", + cx, + )), ) } } diff --git a/crates/rgitui_workspace/src/toolbar.rs b/crates/rgitui_workspace/src/toolbar.rs index 2f5d45a..7a971b4 100644 --- a/crates/rgitui_workspace/src/toolbar.rs +++ b/crates/rgitui_workspace/src/toolbar.rs @@ -8,13 +8,18 @@ use rgitui_ui::{ VerticalDivider, }; +use crate::keymap; +use crate::CommandId; + type TooltipFactory = Box gpui::AnyView>; struct ToolbarButtonState { disabled: bool, loading: bool, tooltip_text: &'static str, - shortcut: Option<&'static str>, + /// The command the button runs, if it is one a keystroke can reach. The + /// tooltip shows that command's current binding rather than a literal. + command: Option, } /// Events emitted by the toolbar. @@ -131,11 +136,10 @@ impl Toolbar { cx.notify(); } - fn build_tooltip(tooltip_text: &'static str, shortcut: Option<&'static str>) -> TooltipFactory { - if let Some(sc) = shortcut { - Box::new(Tooltip::with_shortcut(tooltip_text, sc)) - } else { - Box::new(Tooltip::text(tooltip_text)) + fn build_tooltip(tooltip_text: &'static str, command: Option) -> TooltipFactory { + match command { + Some(command) => Box::new(keymap::command_tooltip(tooltip_text, command)), + None => Box::new(Tooltip::text(tooltip_text)), } } @@ -176,7 +180,7 @@ impl Toolbar { Color::Default }; - let tooltip_fn = Self::build_tooltip(state.tooltip_text, state.shortcut); + let tooltip_fn = Self::build_tooltip(state.tooltip_text, state.command); div() .id(id) @@ -234,7 +238,7 @@ impl Toolbar { disabled: self.is_fetching, loading: self.is_fetching, tooltip_text: "Fetch from remote", - shortcut: Some("Ctrl+Shift+R"), + command: Some(CommandId::Fetch), }, cx, ) @@ -252,7 +256,7 @@ impl Toolbar { disabled: !self.can_pull, loading: self.is_pulling, tooltip_text: "Pull from remote", - shortcut: None, + command: Some(CommandId::Pull), }, cx, ) @@ -277,7 +281,7 @@ impl Toolbar { disabled: !self.can_push, loading: self.is_pushing, tooltip_text: "Push to remote", - shortcut: None, + command: Some(CommandId::Push), }, cx, ) @@ -307,7 +311,10 @@ impl Toolbar { .child( Button::new("tb-branch", "Branch") .icon(IconName::GitBranch) - .tooltip_fn(Tooltip::with_shortcut("Create new branch", "Ctrl+B")) + .tooltip_fn(keymap::command_tooltip( + "Create new branch", + CommandId::CreateBranch, + )) .on_click( cx.listener(|_, _: &ClickEvent, _, cx| cx.emit(ToolbarEvent::Branch)), ), @@ -323,7 +330,10 @@ impl Toolbar { Button::new("tb-stash", "Stash") .icon(IconName::Stash) .disabled(!self.has_changes) - .tooltip_fn(Tooltip::with_shortcut("Stash working changes", "Ctrl+Z")) + .tooltip_fn(keymap::command_tooltip( + "Stash working changes", + CommandId::StashSave, + )) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::StashSave) })), @@ -332,9 +342,9 @@ impl Toolbar { Button::new("tb-pop", "Pop") .icon(IconName::Undo) .disabled(!self.has_stashes) - .tooltip_fn(Tooltip::with_shortcut( + .tooltip_fn(keymap::command_tooltip( "Pop top stash entry", - "Ctrl+Shift+Z", + CommandId::StashPop, )) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::StashPop) @@ -347,7 +357,10 @@ impl Toolbar { Button::new("tb-pr", "Create PR") .icon(IconName::GitPullRequest) .disabled(!self.has_github_token) - .tooltip_fn(Tooltip::text("Create GitHub pull request")) + .tooltip_fn(keymap::command_tooltip( + "Create GitHub pull request", + CommandId::CreatePr, + )) .on_click( cx.listener(|_, _: &ClickEvent, _, cx| cx.emit(ToolbarEvent::CreatePr)), ), @@ -400,7 +413,10 @@ impl Toolbar { .child( IconButton::new("tb-search", IconName::Search) .color(Color::Muted) - .tooltip_fn(Tooltip::with_shortcut("Search commits", "Ctrl+F")) + .tooltip_fn(keymap::command_tooltip( + "Search commits", + CommandId::Search, + )) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::Search) })), @@ -408,7 +424,7 @@ impl Toolbar { .child( IconButton::new("tb-refresh", IconName::Refresh) .color(Color::Muted) - .tooltip_fn(Tooltip::with_shortcut("Refresh", "F5")) + .tooltip_fn(keymap::command_tooltip("Refresh", CommandId::Refresh)) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::Refresh) })), @@ -416,7 +432,7 @@ impl Toolbar { .child( IconButton::new("tb-settings", IconName::Settings) .color(Color::Muted) - .tooltip_fn(Tooltip::with_shortcut("Settings", "Ctrl+,")) + .tooltip_fn(keymap::command_tooltip("Settings", CommandId::Settings)) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::Settings) })), diff --git a/crates/rgitui_workspace/src/workspace/commands.rs b/crates/rgitui_workspace/src/workspace/commands.rs index 46a7589..4b33bd7 100644 --- a/crates/rgitui_workspace/src/workspace/commands.rs +++ b/crates/rgitui_workspace/src/workspace/commands.rs @@ -586,8 +586,11 @@ impl Workspace { self.show_toast(msg, ToastKind::Info, cx); } CommandId::SwitchBranch => { + let hint = crate::keymap::shortcut(CommandId::FocusSidebar, cx) + .map(|keystrokes| format!("Press {keystrokes} to ")) + .unwrap_or_else(|| "Use the sidebar to ".to_owned()); self.show_toast( - "Press Ctrl+Shift+B or use Alt+1 to focus the sidebar for branch switching", + format!("{hint}focus the sidebar for branch switching"), ToastKind::Info, cx, ); diff --git a/crates/rgitui_workspace/src/workspace/layout.rs b/crates/rgitui_workspace/src/workspace/layout.rs index f5ff870..c7669aa 100644 --- a/crates/rgitui_workspace/src/workspace/layout.rs +++ b/crates/rgitui_workspace/src/workspace/layout.rs @@ -55,6 +55,15 @@ const BASELINE_REM_SIZE: f32 = 16.0; const MIN_UI_FONT_SIZE: u32 = 8; const MAX_UI_FONT_SIZE: u32 = 24; +/// The commands the home screen offers a keystroke for. Only the ids are listed: +/// the label and the keystroke both come from the keymap. +const WELCOME_SHORTCUTS: &[CommandId] = &[ + CommandId::OpenRepo, + CommandId::WorkspaceHome, + CommandId::CommandPalette, + CommandId::Settings, +]; + impl Workspace { /// Translate the configured base font size into a window rem size. /// @@ -1718,10 +1727,11 @@ impl Workspace { .mt(px(8.)) .w_full() .items_center() - .child(self.shortcut_hint("Open Repository", "Ctrl+O", colors)) - .child(self.shortcut_hint("Go Home", "Ctrl+H", colors)) - .child(self.shortcut_hint("Command Palette", "Ctrl+Shift+P", colors)) - .child(self.shortcut_hint("Settings", "Ctrl+,", colors)), + .children( + WELCOME_SHORTCUTS + .iter() + .filter_map(|command| self.shortcut_hint(*command, colors, cx)), + ), ); // Scrollable outer container with an inner wrapper that fills at least @@ -1747,36 +1757,44 @@ impl Workspace { ) } + /// One home-screen hint, or nothing when the command has no keystroke. + /// + /// The label is the command's description and the keystroke comes from the + /// keymap, so a rebind in `keymap.json` shows up here too. fn shortcut_hint( &self, - action: &str, - shortcut: &str, + command: CommandId, colors: &rgitui_theme::ThemeColors, - ) -> impl IntoElement { - div() - .h_flex() - .w(px(260.)) - .justify_between() - .items_center() - .child( - Label::new(SharedString::from(action.to_string())) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .child( - div() - .h_flex() - .h(px(22.)) - .px(px(8.)) - .rounded(px(4.)) - .bg(colors.element_background) - .items_center() - .child( - Label::new(SharedString::from(shortcut.to_string())) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) + cx: &gpui::App, + ) -> Option { + let shortcut = crate::keymap::shortcut(command, cx)?; + let action = command.description(); + Some( + div() + .h_flex() + .w(px(260.)) + .justify_between() + .items_center() + .child( + Label::new(SharedString::from(action.to_string())) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child( + div() + .h_flex() + .h(px(22.)) + .px(px(8.)) + .rounded(px(4.)) + .bg(colors.element_background) + .items_center() + .child( + Label::new(SharedString::from(shortcut)) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ), + ) } /// Schedule a debounced layout save (avoids writing to disk on every resize pixel). From adbb99750dd3ae69a8913e7289cb9e705e9393c0 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:17:47 +0100 Subject: [PATCH 11/16] feat(keymap): surface user overrides and conflicts in the shortcut help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A binding the user wrote and a binding rgitui shipped looked identical in the reference, and a binding that was dropped as ambiguous looked like it worked. The conflict report only reached a startup toast, which is gone by the time anyone wonders why their key does nothing. The summary already carries provenance and per-command warnings, so the help panel now shows both: * a `keymap.json` badge and a tinted keystroke border on any row whose binding came from the user's file, mirrored as a tinted hint in the command palette and a marker in the settings quick reference; * a warning row per problem, naming which binding was ignored and why — the ambiguous-keystroke and chord-prefix messages from the load, plus the keystroke a command lost to another command or to an unbind; * a header line counting the user's bindings and the problems, so a keymap with a mistake in it says so before anything is scrolled. Opening the reference is now how a user finds out which of their bindings was ignored, rather than reading the log. Co-Authored-By: Claude Fable 5 --- .../rgitui_workspace/src/command_palette.rs | 9 +- .../src/settings_window/view.rs | 15 ++- crates/rgitui_workspace/src/shortcuts_help.rs | 108 ++++++++++++++++-- 3 files changed, 120 insertions(+), 12 deletions(-) diff --git a/crates/rgitui_workspace/src/command_palette.rs b/crates/rgitui_workspace/src/command_palette.rs index a611968..47fe7cc 100644 --- a/crates/rgitui_workspace/src/command_palette.rs +++ b/crates/rgitui_workspace/src/command_palette.rs @@ -948,6 +948,13 @@ impl Render for CommandPalette { } if let Some(shortcut_text) = shortcut { + // A binding the user defined is tinted, matching + // the badge in the shortcut reference. + let hint_color = if summary.is_user_defined(cmd_id) { + Color::Info + } else { + Color::Muted + }; row = row.child( div() .h_flex() @@ -960,7 +967,7 @@ impl Render for CommandPalette { .child( Label::new(SharedString::from(shortcut_text)) .size(LabelSize::XSmall) - .color(Color::Muted) + .color(hint_color) .weight(FontWeight::MEDIUM), ), ); diff --git a/crates/rgitui_workspace/src/settings_window/view.rs b/crates/rgitui_workspace/src/settings_window/view.rs index f5cf0b0..85f6769 100644 --- a/crates/rgitui_workspace/src/settings_window/view.rs +++ b/crates/rgitui_workspace/src/settings_window/view.rs @@ -3945,9 +3945,15 @@ impl SettingsView { fn render_shortcut_row(&self, id: CommandId, cx: &Context) -> impl IntoElement { let colors = cx.colors(); let summary = crate::keymap::summary(cx); + let is_user_defined = summary.is_user_defined(id); let shortcut = summary .display(id) .unwrap_or_else(|| crate::keymap::display::UNBOUND.to_owned()); + let color = if is_user_defined { + Color::Info + } else { + Color::Muted + }; div() .h_flex() @@ -3961,6 +3967,13 @@ impl SettingsView { .color(Color::Muted), ) .child(div().flex_1()) + .when(is_user_defined, |row| { + row.child( + Label::new("keymap.json") + .size(LabelSize::XSmall) + .color(Color::Info), + ) + }) .child( div() .h_flex() @@ -3972,7 +3985,7 @@ impl SettingsView { .child( Label::new(SharedString::from(shortcut)) .size(LabelSize::XSmall) - .color(Color::Muted) + .color(color) .weight(FontWeight::SEMIBOLD), ), ) diff --git a/crates/rgitui_workspace/src/shortcuts_help.rs b/crates/rgitui_workspace/src/shortcuts_help.rs index 32f9d49..f3a056c 100644 --- a/crates/rgitui_workspace/src/shortcuts_help.rs +++ b/crates/rgitui_workspace/src/shortcuts_help.rs @@ -1,9 +1,12 @@ //! The keyboard shortcut reference, rendered from the keymap in force. //! //! Nothing here is a literal list of shortcuts. Every row comes from -//! [`crate::keymap::summary`]: the group is a `commands!` view block, the -//! description is the command's doc comment, and the keystroke is the one the -//! user's `keymap.json` actually produced rather than the registry default. +//! [`crate::keymap::summary`]: the description is the command's doc comment, the +//! keystroke is what the user's `keymap.json` actually produced, a binding the +//! user supplied is badged as such, and a binding that was dropped — because two +//! of theirs collided, or because a keystroke now belongs to another command — +//! is shown as a warning on the row it affects. Opening this panel is therefore +//! how a user finds out *which* of their bindings was ignored and why. use gpui::prelude::*; use gpui::{ @@ -11,7 +14,7 @@ use gpui::{ Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; -use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize}; +use rgitui_ui::{Badge, Icon, IconName, IconSize, Label, LabelSize}; use crate::keymap::{self, CommandBindings, CommandGroup, KeymapSummary}; use crate::CommandId; @@ -21,6 +24,9 @@ pub enum ShortcutsHelpEvent { Dismissed, } +/// Badge text marking a binding that came from the user's `keymap.json`. +const USER_BINDING_BADGE: &str = "keymap.json"; + pub struct ShortcutsHelp { visible: bool, focus_handle: FocusHandle, @@ -70,13 +76,15 @@ impl ShortcutsHelp { } } - /// Renders one command's row: what it does and what it is bound to. + /// Renders one command's row: what it does, what it is bound to, where that + /// binding came from and anything wrong with it. fn render_command(&self, entry: &CommandBindings, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); let hover_bg = colors.ghost_element_hover; + let is_user_defined = entry.is_user_defined(); let keystrokes = entry.display(); - let row = div() + let mut row = div() .v_flex() .w_full() .py(px(5.)) @@ -103,6 +111,14 @@ impl ShortcutsHelp { ), ); + if is_user_defined { + headline = headline.child( + div() + .flex_shrink_0() + .child(Badge::new(USER_BINDING_BADGE).color(Color::Info)), + ); + } + headline = match keystrokes { Some(keystrokes) => headline.child( div() @@ -113,7 +129,11 @@ impl ShortcutsHelp { .gap_1() .rounded(px(5.)) .border_1() - .border_color(colors.border) + .border_color(if is_user_defined { + Color::Info.color(cx) + } else { + colors.border + }) .bg(colors.hint_background) .items_center() .child( @@ -132,7 +152,31 @@ impl ShortcutsHelp { ), }; - row.child(headline) + row = row.child(headline); + + for warning in &entry.warnings { + row = row.child( + div() + .h_flex() + .w_full() + .gap(px(6.)) + .items_start() + .child( + Icon::new(IconName::AlertTriangle) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + .child( + div().flex_1().min_w_0().child( + Label::new(SharedString::from(warning.clone())) + .size(LabelSize::XSmall) + .color(Color::Warning), + ), + ), + ); + } + + row } fn render_group(&self, group: &CommandGroup, cx: &mut Context) -> impl IntoElement { @@ -172,11 +216,22 @@ impl ShortcutsHelp { /// The header summary line, counted from the keymap rather than written out. fn subtitle(summary: &KeymapSummary) -> String { - format!( + let mut parts = vec![format!( "{} of {} commands are bound", summary.bound_command_count(), summary.commands().len() - ) + )]; + let user_bindings = summary.user_binding_count(); + if user_bindings > 0 { + parts.push(format!("{user_bindings} from your keymap.json",)); + } + let warnings = summary.warning_count(); + if warnings > 0 { + parts.push(format!( + "{warnings} with a problem — see the warnings below", + )); + } + parts.join(", ") } } @@ -415,6 +470,7 @@ impl Render for ShortcutsHelp { #[cfg(test)] mod tests { use super::*; + use crate::keymap::conflict::BindingSpec; use crate::keymap::display::KeystrokeStyle; fn defaults() -> KeymapSummary { @@ -478,5 +534,37 @@ mod tests { subtitle.contains(&summary.bound_command_count().to_string()), "{subtitle}" ); + // Nothing to warn about and nothing user-defined in the defaults. + assert!(!subtitle.contains("keymap.json"), "{subtitle}"); + assert!(!subtitle.contains("problem"), "{subtitle}"); + } + + /// A user binding and a conflict both have to be visible in the panel: the + /// badge comes from `is_user_defined`, the warning row from `warnings`. + #[test] + fn the_subtitle_and_rows_surface_user_bindings_and_conflicts() { + let mut specs = crate::keymap::loader::default_specs(); + specs.push(BindingSpec::user_binding( + "ctrl-alt-9", + Some("Workspace"), + "rgitui::Pull", + )); + specs.push(BindingSpec::user_binding( + "ctrl-alt-9", + Some("Workspace"), + "rgitui::Push", + )); + let report = crate::keymap::conflict::detect_conflicts(&specs); + let applied: Vec = (0..specs.len()).filter(|i| report.is_kept(*i)).collect(); + let summary = KeymapSummary::build(&specs, &applied, &report, KeystrokeStyle::Words); + + let subtitle = ShortcutsHelp::subtitle(&summary); + assert!(subtitle.contains("keymap.json"), "{subtitle}"); + assert!(subtitle.contains("problem"), "{subtitle}"); + + // Push won, so it carries the badge; Pull lost, so it carries the warning. + assert!(summary.is_user_defined(CommandId::Push)); + assert_eq!(summary.warnings(CommandId::Pull).len(), 1); + assert!(summary.warnings(CommandId::Pull)[0].contains("ignored")); } } From 8bce6a1792710716ef90178e41bf7e235899ea8c Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:23:27 +0100 Subject: [PATCH 12/16] feat(keymap): add an open-keymap-file affordance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shortcut reference told the user every binding was rebindable but not where to do it, and `keymap.json` does not exist until someone creates one — so the first step was to find an undocumented path in the config directory and guess the format. `rgitui::OpenKeymap` opens it: a header button in the shortcut reference, a button in the settings General section and a command palette entry all call `Workspace::open_keymap_file`, which creates the file when absent and hands it to the editor configured in settings — the same `open_editor` path the repository context menu uses, so no new launch mechanism. A failure to create the file is toasted with the path, which is also the button's tooltip, so the location is recoverable either way. The file is created from `keymap_stub()` rather than left empty: a commented starter naming the schema URL and, for its examples, a real action at its real default keystroke pulled from the registry, so it cannot advertise something that does not exist. `the_starter_keymap_parses_and_binds_nothing` holds it to loading cleanly and changing no binding. Co-Authored-By: Claude Fable 5 --- .../rgitui_workspace/src/command_palette.rs | 6 ++ crates/rgitui_workspace/src/keymap/loader.rs | 72 +++++++++++++++++++ crates/rgitui_workspace/src/keymap/mod.rs | 2 +- .../rgitui_workspace/src/keymap/registry.rs | 2 + .../src/settings_window/view.rs | 43 +++++++++++ crates/rgitui_workspace/src/shortcuts_help.rs | 25 ++++++- .../src/workspace/commands.rs | 4 ++ .../rgitui_workspace/src/workspace/events.rs | 7 +- crates/rgitui_workspace/src/workspace/mod.rs | 35 +++++++++ docs/KEYBINDINGS.md | 1 + docs/keymap.schema.json | 4 ++ 11 files changed, 197 insertions(+), 4 deletions(-) diff --git a/crates/rgitui_workspace/src/command_palette.rs b/crates/rgitui_workspace/src/command_palette.rs index 47fe7cc..e5fe1aa 100644 --- a/crates/rgitui_workspace/src/command_palette.rs +++ b/crates/rgitui_workspace/src/command_palette.rs @@ -477,6 +477,12 @@ pub(crate) fn palette_commands() -> Vec { Some("Show available keyboard shortcuts"), "Help", ), + PaletteCommand::new( + CommandId::OpenKeymap, + "Preferences: Open Keymap File", + Some("Edit keymap.json to rebind keyboard shortcuts"), + "Preferences", + ), PaletteCommand::new( CommandId::Blame, "View: Blame File", diff --git a/crates/rgitui_workspace/src/keymap/loader.rs b/crates/rgitui_workspace/src/keymap/loader.rs index 1a5bad3..0d1bda8 100644 --- a/crates/rgitui_workspace/src/keymap/loader.rs +++ b/crates/rgitui_workspace/src/keymap/loader.rs @@ -120,6 +120,58 @@ pub fn keymap_path() -> PathBuf { rgitui_settings::keymap_path() } +/// The starter `keymap.json` written when the user first opens the file. +/// +/// The example bindings name real actions at their real default keystrokes, +/// taken from the registry, so the stub cannot advertise something that does not +/// exist. It is commented out, so writing it changes no binding. +pub fn keymap_stub() -> String { + let example = ALL_COMMANDS + .iter() + .find(|meta| !meta.default_bindings.is_empty()) + .expect("the registry binds at least one command"); + let (keystrokes, context) = example.default_bindings[0]; + + format!( + "// rgitui keybindings. Saving this file reloads them immediately.\n\ + //\n\ + // Every action name is listed in docs/KEYBINDINGS.md. For completion\n\ + // while editing, point your editor at docs/keymap.schema.json for this\n\ + // filename — the file itself is a JSON array, so it has nowhere to put\n\ + // a \"$schema\" key.\n\ + //\n\ + // `secondary` is the platform's primary modifier: cmd on macOS, ctrl\n\ + // elsewhere. A binding you add wins over the default it replaces.\n\ + [\n \ + {{\n \ + \"context\": \"{context}\",\n \ + \"bindings\": {{\n \ + // Bind an action to a keystroke.\n \ + // \"ctrl-alt-s\": \"{action}\",\n \ + // Remove a default binding.\n \ + // \"{keystrokes}\": null\n \ + }}\n \ + }}\n\ + ]\n", + action = example.action_name, + ) +} + +/// Makes sure `keymap.json` exists, creating it with [`keymap_stub`] if not. +/// +/// Returns the path either way, so the caller can hand it to an editor. +pub fn ensure_keymap_file() -> std::io::Result { + let path = keymap_path(); + if path.exists() { + return Ok(path); + } + if let Some(directory) = path.parent() { + std::fs::create_dir_all(directory)?; + } + std::fs::write(&path, keymap_stub())?; + Ok(path) +} + /// The default bindings declared by `commands!`, in registry order. /// /// Pure, so the default set can be validated without an `App`. @@ -424,6 +476,26 @@ mod tests { } } + /// The stub is written into `keymap.json`, so it must load cleanly — an + /// example that does not parse would greet the user with an error toast. + #[test] + fn the_starter_keymap_parses_and_binds_nothing() { + let stub = keymap_stub(); + let (pending, problems) = parse_user_specs(&stub); + assert!(problems.is_empty(), "{problems:?}"); + assert!( + pending.is_empty(), + "the starter keymap must leave every binding at its default: {pending:?}" + ); + // The commented examples name a real action and a real default keystroke. + let example = ALL_COMMANDS + .iter() + .find(|meta| !meta.default_bindings.is_empty()) + .expect("the registry binds at least one command"); + assert!(stub.contains(example.action_name), "{stub}"); + assert!(stub.contains(example.default_bindings[0].0), "{stub}"); + } + #[test] fn the_default_keymap_has_no_conflicts() { let report = conflict::detect_conflicts(&default_specs()); diff --git a/crates/rgitui_workspace/src/keymap/mod.rs b/crates/rgitui_workspace/src/keymap/mod.rs index 4933310..c6a4b8d 100644 --- a/crates/rgitui_workspace/src/keymap/mod.rs +++ b/crates/rgitui_workspace/src/keymap/mod.rs @@ -33,7 +33,7 @@ use std::time::Duration; use gpui::{App, Global}; pub use display::{humanize_sequence, KeystrokeStyle}; -pub use loader::keymap_path; +pub use loader::{ensure_keymap_file, keymap_path, keymap_stub}; pub use registry::{actions, attach_actions, CommandId, CommandMeta, ALL_COMMANDS}; pub use summary::{CommandBindings, CommandGroup, EffectiveBinding, KeymapSummary}; diff --git a/crates/rgitui_workspace/src/keymap/registry.rs b/crates/rgitui_workspace/src/keymap/registry.rs index f2fd9c4..d2a8c1e 100644 --- a/crates/rgitui_workspace/src/keymap/registry.rs +++ b/crates/rgitui_workspace/src/keymap/registry.rs @@ -210,6 +210,8 @@ commands! { ShrinkDiffViewer "secondary-up" [hidden]; /// Heighten the diff viewer. GrowDiffViewer "secondary-down" [hidden]; + /// Open keymap.json in your editor to rebind shortcuts. + OpenKeymap unbound; } // Bound once and handled by whichever element owns the selection or the diff --git a/crates/rgitui_workspace/src/settings_window/view.rs b/crates/rgitui_workspace/src/settings_window/view.rs index 85f6769..23a6be9 100644 --- a/crates/rgitui_workspace/src/settings_window/view.rs +++ b/crates/rgitui_workspace/src/settings_window/view.rs @@ -3875,6 +3875,7 @@ impl SettingsView { for id in QUICK_REFERENCE_COMMANDS { shortcuts_list = shortcuts_list.child(self.render_shortcut_row(*id, cx)); } + shortcuts_list = shortcuts_list.child(Self::keymap_file_row(cx)); shortcuts_card = shortcuts_card.child(shortcuts_list); section = section.child(shortcuts_card); section = section.child(Self::section_divider(cx)); @@ -3990,6 +3991,48 @@ impl SettingsView { ), ) } + + /// The `keymap.json` path plus a button that opens it, creating the file with + /// a commented starter when it does not exist yet. + fn keymap_file_row(cx: &Context) -> impl IntoElement { + let path = crate::keymap::keymap_path(); + let path_display = path.display().to_string(); + let editor_command = cx + .try_global::() + .map(|state| state.settings().editor_command.clone()) + .unwrap_or_default(); + + div() + .h_flex() + .w_full() + .items_center() + .gap(px(8.)) + .pt(px(8.)) + .child( + div().flex_1().min_w_0().child( + Label::new(SharedString::from(path_display)) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + .child( + Button::new("open-keymap-file", "Edit keymap.json") + .style(ButtonStyle::Subtle) + .size(ButtonSize::Compact) + .icon(IconName::Settings) + .on_click( + move |_: &ClickEvent, _, _cx| match crate::keymap::ensure_keymap_file() { + Ok(path) => { + crate::workspace::open_editor(&path, &editor_command); + } + Err(error) => log::error!( + "keymap: could not create {}: {error}", + crate::keymap::keymap_path().display() + ), + }, + ), + ) + } } impl SettingsView { diff --git a/crates/rgitui_workspace/src/shortcuts_help.rs b/crates/rgitui_workspace/src/shortcuts_help.rs index f3a056c..043fcf0 100644 --- a/crates/rgitui_workspace/src/shortcuts_help.rs +++ b/crates/rgitui_workspace/src/shortcuts_help.rs @@ -14,7 +14,9 @@ use gpui::{ Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; -use rgitui_ui::{Badge, Icon, IconName, IconSize, Label, LabelSize}; +use rgitui_ui::{ + Badge, Button, ButtonSize, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize, +}; use crate::keymap::{self, CommandBindings, CommandGroup, KeymapSummary}; use crate::CommandId; @@ -22,6 +24,8 @@ use crate::CommandId; #[derive(Debug, Clone)] pub enum ShortcutsHelpEvent { Dismissed, + /// The user asked to edit `keymap.json`; the workspace opens it. + OpenKeymapFile, } /// Badge text marking a binding that came from the user's `keymap.json`. @@ -245,6 +249,7 @@ impl Render for ShortcutsHelp { let groups = summary.groups(); let subtitle = Self::subtitle(&summary); let palette_hint = keymap::shortcut(CommandId::CommandPalette, cx); + let keymap_file = keymap::keymap_path().display().to_string(); let colors = cx.colors().clone(); let viewport = window.viewport_size(); @@ -389,6 +394,17 @@ impl Render for ShortcutsHelp { ), ), ) + .child( + Button::new("shortcuts-open-keymap", "Edit keymap.json") + .style(ButtonStyle::Subtle) + .size(ButtonSize::Compact) + .icon(IconName::Settings) + .tooltip(SharedString::from(keymap_file)) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + cx.emit(ShortcutsHelpEvent::OpenKeymapFile); + this.dismiss(cx); + })), + ) .child( div() .id("shortcuts-close-btn") @@ -422,7 +438,11 @@ impl Render for ShortcutsHelp { .py(px(10.)) .child( Label::new(SharedString::from(format!( - "Every shortcut below is shown as your keymap.json leaves \n it. Plain-letter shortcuts only act on the panel that has \n focus — that is what each group's key context means. \n Commands marked {} have no keystroke and are reached from \n the command palette{}.", + "Every shortcut below is rebindable, and each is shown as your \ + keymap.json leaves it. Plain-letter shortcuts only act on the \ + panel that has focus — that is what each group's key context \ + means. Commands marked {} have no keystroke and are reached \ + from the command palette{}.", keymap::display::UNBOUND, palette_hint .as_deref() @@ -487,6 +507,7 @@ mod tests { fn test_shortcuts_help_event_match() { match ShortcutsHelpEvent::Dismissed { ShortcutsHelpEvent::Dismissed => {} + ShortcutsHelpEvent::OpenKeymapFile => unreachable!(), } } diff --git a/crates/rgitui_workspace/src/workspace/commands.rs b/crates/rgitui_workspace/src/workspace/commands.rs index 4b33bd7..75a87b5 100644 --- a/crates/rgitui_workspace/src/workspace/commands.rs +++ b/crates/rgitui_workspace/src/workspace/commands.rs @@ -252,6 +252,9 @@ impl Workspace { sh.toggle_visible(cx); }); } + CommandId::OpenKeymap => { + self.open_keymap_file(cx); + } CommandId::WorkspaceHome => { self.go_home(cx); } @@ -757,6 +760,7 @@ impl Workspace { | CommandId::CreateBranch | CommandId::OpenRepo | CommandId::Shortcuts + | CommandId::OpenKeymap | CommandId::WorkspaceHome | CommandId::RestoreLastWorkspace | CommandId::Undo diff --git a/crates/rgitui_workspace/src/workspace/events.rs b/crates/rgitui_workspace/src/workspace/events.rs index 94067cc..2111a62 100644 --- a/crates/rgitui_workspace/src/workspace/events.rs +++ b/crates/rgitui_workspace/src/workspace/events.rs @@ -703,7 +703,12 @@ pub(super) fn subscribe_shortcuts_help( ) { cx.subscribe( shortcuts_help, - |_this, _sh, _event: &ShortcutsHelpEvent, _cx| {}, + |this, _sh, event: &ShortcutsHelpEvent, cx| match event { + // Handled here rather than in the panel so a failure to create the + // file surfaces as a toast like every other error. + ShortcutsHelpEvent::OpenKeymapFile => this.open_keymap_file(cx), + ShortcutsHelpEvent::Dismissed => {} + }, ) .detach(); } diff --git a/crates/rgitui_workspace/src/workspace/mod.rs b/crates/rgitui_workspace/src/workspace/mod.rs index a1fc7ce..864395b 100644 --- a/crates/rgitui_workspace/src/workspace/mod.rs +++ b/crates/rgitui_workspace/src/workspace/mod.rs @@ -8,6 +8,7 @@ mod tabs; mod undo; mod update_checker; +pub(crate) use layout::open_editor; pub(crate) use state::*; pub(crate) use undo::{UndoAction, UndoStack}; @@ -660,6 +661,40 @@ impl Workspace { } } + /// Open `keymap.json` in the user's editor, creating it first if it does not + /// exist yet. + /// + /// The file is created with a commented starter (see + /// [`crate::keymap::keymap_stub`]) rather than left absent, so the editor + /// opens on something that explains the format instead of an empty buffer. + /// The editor is the one configured in settings, falling back to the same + /// detection the rest of the app uses; if none can be launched the path is + /// toasted so it can still be found by hand. + pub(crate) fn open_keymap_file(&mut self, cx: &mut Context) { + let path = match crate::keymap::ensure_keymap_file() { + Ok(path) => path, + Err(error) => { + self.show_toast( + format!( + "Could not create {}: {error}. Create it by hand to customise \ + keybindings.", + crate::keymap::keymap_path().display() + ), + ToastKind::Error, + cx, + ); + return; + } + }; + + let editor_command = cx + .try_global::() + .map(|settings| settings.settings().editor_command.clone()) + .unwrap_or_default(); + layout::open_editor(&path, &editor_command); + self.show_toast(format!("Opening {}", path.display()), ToastKind::Info, cx); + } + /// Mark a clean exit when the user explicitly closes or goes home. pub fn mark_clean_exit(&self, cx: &mut Context) { cx.update_global::(|settings, _| { diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index a8cba7a..7a4c90c 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -100,6 +100,7 @@ The file is reloaded when you save it. Bindings you add win over the defaults. T | `secondary-]` | `Workspace && !modal` | `rgitui::GrowDetailPanel` | Widen the detail panel. | | `secondary-up` | `Workspace && !modal` | `rgitui::ShrinkDiffViewer` | Shorten the diff viewer. | | `secondary-down` | `Workspace && !modal` | `rgitui::GrowDiffViewer` | Heighten the diff viewer. | +| _unbound_ | — | `rgitui::OpenKeymap` | Open keymap.json in your editor to rebind shortcuts. | ## Menu diff --git a/docs/keymap.schema.json b/docs/keymap.schema.json index f4f052c..5d1255d 100644 --- a/docs/keymap.schema.json +++ b/docs/keymap.schema.json @@ -340,6 +340,10 @@ "const": "rgitui::GrowDiffViewer", "description": "Heighten the diff viewer. Command id: `grow_diff_viewer`." }, + { + "const": "rgitui::OpenKeymap", + "description": "Open keymap.json in your editor to rebind shortcuts. Command id: `open_keymap`." + }, { "const": "menu::Cancel", "description": "Dismiss the focused overlay, dialog, search or selection. Command id: `cancel`." From b5ac8c2cb8cd723e90e963c7dcb52160bfa48d34 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:57:06 +0100 Subject: [PATCH 13/16] fix(keymap): bind squash-selected to bare `s` in the commit graph `graph::SquashSelected` defaulted to `secondary-shift-s`, which the graph dispatches at a deeper context than the workspace root: while the graph held focus that keystroke reached squash and never reached `rgitui::UnstageAll`, silently costing it one of its two bindings. Bare `s` matches the interactive rebase editor, where `s` already means squash, and the convention that an unmodified letter acts on the focused panel. It stays scoped `GraphView && !TextInput`, so it stands down for the graph's own search field, and it collides with nothing else the graph can reach: the other `s` bindings belong to the diff viewer, the sidebar and the rebase editor, none of which is on the graph's dispatch path. Co-Authored-By: Claude Fable 5 --- crates/rgitui_workspace/src/keymap/registry.rs | 7 ++++++- docs/KEYBINDINGS.md | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/rgitui_workspace/src/keymap/registry.rs b/crates/rgitui_workspace/src/keymap/registry.rs index d2a8c1e..983be4e 100644 --- a/crates/rgitui_workspace/src/keymap/registry.rs +++ b/crates/rgitui_workspace/src/keymap/registry.rs @@ -245,8 +245,12 @@ commands! { GraphExtendSelectionNext ["shift-down", "shift-j"] [hidden]; /// Add the previous commit in the graph to the selection. GraphExtendSelectionPrev ["shift-up", "shift-k"] [hidden]; + // Bare `s`, matching the rebase editor below where `s` already means + // squash. `secondary-shift-s` would be dispatched ahead of the + // workspace's `rgitui::UnstageAll`, taking that keystroke away from it + // whenever the graph held focus. /// Squash the selected commits into the oldest of them. - SquashSelected "secondary-shift-s" if has_multi_commit_selection [hidden]; + SquashSelected "s" if has_multi_commit_selection [hidden]; /// Close the graph search, or dismiss the graph context menu. GraphCancel "escape" in "GraphView" [hidden]; /// Copy the selected commit's SHA to the clipboard. @@ -673,6 +677,7 @@ mod tests { ( "s", &[ + "graph::SquashSelected", "diff::StageSelection", "sidebar::ToggleStageRow", "rebase::RebaseSquash", diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index 7a4c90c..d69eae8 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -123,7 +123,7 @@ The file is reloaded when you save it. Bindings you add win over the defaults. T | `end` or `shift-g` | `GraphView` or `GraphView && !TextInput` | `graph::GraphSelectLast` | Select the oldest loaded commit in the graph. | | `shift-down` or `shift-j` | `GraphView && !TextInput` | `graph::GraphExtendSelectionNext` | Add the next commit in the graph to the selection. | | `shift-up` or `shift-k` | `GraphView && !TextInput` | `graph::GraphExtendSelectionPrev` | Add the previous commit in the graph to the selection. | -| `secondary-shift-s` | `GraphView && !TextInput` | `graph::SquashSelected` | Squash the selected commits into the oldest of them. | +| `s` | `GraphView && !TextInput` | `graph::SquashSelected` | Squash the selected commits into the oldest of them. | | `escape` | `GraphView` | `graph::GraphCancel` | Close the graph search, or dismiss the graph context menu. | | `y` | `GraphView && !TextInput` | `graph::CopyCommitSha` | Copy the selected commit's SHA to the clipboard. | | `shift-c` | `GraphView && !TextInput` | `graph::CopyCommitMessage` | Copy the selected commit's message to the clipboard. | From b2781aa51ee74d5cf0bb24dddefe022c7a8e331e Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:58:58 +0100 Subject: [PATCH 14/16] feat(graph): offer squash in the commit context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashing a multi-selection was only reachable by knowing `s`. Add a "Squash selected commits" row to the graph's right-click menu, emitting `GraphViewEvent::SquashSelected`, which the workspace routes to the same `squash_selected_commits` the keystroke uses — so both paths validate through `plan_squash` and refuse a bad selection in identical words. The row appears only once two commits are selected, since below that it could never succeed. The remaining rules — a contiguous run on HEAD's own first-parent chain, no merge in the way — stay in the workspace, which is where `plan_squash` lives; `rgitui_graph` sits below it and cannot call it. Adding the row meant the menu's `CONTEXT_MENU_ITEM_COUNT` needed bumping, a constant that has to be kept in step by hand with a list expressed as a `match` on row indices. Both are now derived: the rows are a table of `GraphMenuItem`, the height counts what that table yields for the current selection, and each row carries its own separator and destructive flags instead of the render loop testing indices. That also puts the last separator where its comment always said it went — above the clipboard group rather than one row into it — and drops ~200 lines of per-index click handlers in favour of one `menu_event` mapping. Co-Authored-By: Claude Fable 5 --- crates/rgitui_graph/src/lib.rs | 582 ++++++++++-------- .../src/workspace/commands.rs | 6 +- .../rgitui_workspace/src/workspace/events.rs | 4 + 3 files changed, 323 insertions(+), 269 deletions(-) diff --git a/crates/rgitui_graph/src/lib.rs b/crates/rgitui_graph/src/lib.rs index bd451a6..777f50f 100644 --- a/crates/rgitui_graph/src/lib.rs +++ b/crates/rgitui_graph/src/lib.rs @@ -36,26 +36,179 @@ impl Render for DateColumnResize { /// Width of the commit graph context menu. const CONTEXT_MENU_WIDTH: f32 = 200.0; -/// Number of action rows in the context menu. -const CONTEXT_MENU_ITEM_COUNT: f32 = 14.0; /// Height of a single context-menu action row (`.h(px(26.))`). const CONTEXT_MENU_ITEM_HEIGHT: f32 = 26.0; -/// Number of separators drawn between menu groups. -const CONTEXT_MENU_SEPARATOR_COUNT: f32 = 4.0; /// Effective height of one separator: `.h(px(1.))` plus `.my(px(2.))` top and bottom. const CONTEXT_MENU_SEPARATOR_HEIGHT: f32 = 1.0 + 2.0 + 2.0; /// Combined top and bottom padding on the menu container (`.py(px(3.))`). const CONTEXT_MENU_VERTICAL_PADDING: f32 = 3.0 + 3.0; -/// Natural rendered height of the context menu, derived from its real item and -/// separator metrics. Shared by clamping and the dismiss hit-test so both agree -/// on where the menu actually sits. -const fn context_menu_height() -> f32 { - CONTEXT_MENU_ITEM_COUNT * CONTEXT_MENU_ITEM_HEIGHT - + CONTEXT_MENU_SEPARATOR_COUNT * CONTEXT_MENU_SEPARATOR_HEIGHT +/// Natural rendered height of a context menu holding `items`. +/// +/// Counted from the rows themselves rather than from a hand-maintained total, so +/// adding or hiding an item cannot leave the menu mis-sized. Shared by the +/// clamping and the dismiss hit-test, which have to agree on where the menu sits. +fn context_menu_height(items: &[&GraphMenuItem]) -> f32 { + let separators = items.iter().filter(|item| item.separator_before).count(); + items.len() as f32 * CONTEXT_MENU_ITEM_HEIGHT + + separators as f32 * CONTEXT_MENU_SEPARATOR_HEIGHT + CONTEXT_MENU_VERTICAL_PADDING } +/// What a commit graph context-menu row does when clicked. +/// +/// Carries no commit data: the row is built for one commit and +/// [`GraphView::menu_event`] fills that in, which keeps the table below a plain +/// description of the menu. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GraphMenuAction { + CherryPick, + Revert, + Checkout, + CreateBranch, + CreateTag, + BisectGood, + BisectBad, + Reset, + InteractiveRebase, + SquashSelected, + CopySha, + CopyMessage, + CopyAuthor, + CopyDate, + ViewOnGithub, +} + +/// One row of the commit graph context menu. +struct GraphMenuItem { + /// Row label. + label: &'static str, + /// Leading icon. + icon: IconName, + /// What clicking the row does. + action: GraphMenuAction, + /// Whether a group separator is drawn above this row. + separator_before: bool, + /// Whether the row renders in the error colour, because it rewrites or + /// discards committed work. + destructive: bool, +} + +impl GraphMenuItem { + const fn new(label: &'static str, icon: IconName, action: GraphMenuAction) -> Self { + Self { + label, + icon, + action, + separator_before: false, + destructive: false, + } + } + + /// Starts a new group: a separator is drawn above this row. + const fn grouped(mut self) -> Self { + self.separator_before = true; + self + } + + /// Marks the row as rewriting or discarding work. + const fn destructive(mut self) -> Self { + self.destructive = true; + self + } +} + +/// Every row the commit graph context menu can show, in order. +/// +/// [`GraphView::context_menu_items`] filters it for the current selection; the +/// menu's height is then derived from what survives, so no count is maintained +/// by hand. +const GRAPH_MENU_ITEMS: &[GraphMenuItem] = &[ + GraphMenuItem::new( + "Cherry-pick commit", + IconName::GitCommit, + GraphMenuAction::CherryPick, + ), + GraphMenuItem::new("Revert commit", IconName::Undo, GraphMenuAction::Revert), + GraphMenuItem::new( + "Checkout commit", + IconName::Check, + GraphMenuAction::Checkout, + ), + GraphMenuItem::new( + "Create branch here", + IconName::GitBranch, + GraphMenuAction::CreateBranch, + ), + GraphMenuItem::new("Create tag here", IconName::Tag, GraphMenuAction::CreateTag), + GraphMenuItem::new( + "Mark as good (bisect)", + IconName::Check, + GraphMenuAction::BisectGood, + ) + .grouped(), + GraphMenuItem::new( + "Mark as bad (bisect)", + IconName::X, + GraphMenuAction::BisectBad, + ) + .destructive(), + GraphMenuItem::new("Reset to here", IconName::Trash, GraphMenuAction::Reset) + .grouped() + .destructive(), + GraphMenuItem::new( + "Interactive Rebase", + IconName::GitMerge, + GraphMenuAction::InteractiveRebase, + ) + .grouped(), + // Only offered while the selection could actually be squashed — see + // `GraphView::context_menu_items`. + GraphMenuItem::new( + "Squash selected commits", + IconName::GitMerge, + GraphMenuAction::SquashSelected, + ), + GraphMenuItem::new("Copy SHA", IconName::Copy, GraphMenuAction::CopySha).grouped(), + GraphMenuItem::new( + "Copy commit message", + IconName::Edit, + GraphMenuAction::CopyMessage, + ), + GraphMenuItem::new( + "Copy author name", + IconName::User, + GraphMenuAction::CopyAuthor, + ), + GraphMenuItem::new("Copy date", IconName::Clock, GraphMenuAction::CopyDate), + GraphMenuItem::new( + "View on GitHub", + IconName::ExternalLink, + GraphMenuAction::ViewOnGithub, + ), +]; + +/// Smallest selection a squash can meld: fewer commits than this and there is +/// nothing to squash into. +const MIN_SQUASH_SELECTION: usize = 2; + +/// The context-menu rows to offer when `selected_commits` commits are selected. +/// +/// "Squash selected commits" is only offered once there are two commits to meld; +/// below that it would always fail, so it is hidden rather than shown disabled. +/// The rest of the rules — a contiguous run on HEAD's own first-parent chain, no +/// merge commit in the way — belong to the workspace's `plan_squash`, which this +/// crate sits below. Clicking the row therefore takes the same route as the +/// `graph::SquashSelected` keystroke and a selection that does not qualify is +/// refused there, in the same words. +fn menu_items_for_selection(selected_commits: usize) -> Vec<&'static GraphMenuItem> { + let squashable = selected_commits >= MIN_SQUASH_SELECTION; + GRAPH_MENU_ITEMS + .iter() + .filter(|item| squashable || item.action != GraphMenuAction::SquashSelected) + .collect() +} + /// Pre-computed unit circle vertex offsets (cos, sin) for 36-step circles. /// Computed once and reused across all frames to avoid per-frame trig calls. fn unit_circle_offsets() -> &'static [(f32, f32)] { @@ -168,6 +321,11 @@ pub enum GraphViewEvent { /// interactive rebase editor, allowing the user to reorder, squash, fixup, /// reword, or drop them. InteractiveRebase(git2::Oid), + /// Squash the selected commits together. The workspace validates the + /// selection and pre-fills the interactive rebase dialog, or explains why it + /// cannot — the same path the `graph::SquashSelected` keystroke takes, so the + /// two cannot disagree. + SquashSelected, } #[derive(Clone, Debug, PartialEq)] @@ -793,7 +951,7 @@ impl GraphView { /// exactly where the menu sits, even when clamping or scrolling occurs. fn context_menu_geometry(&self, click: Point) -> (Pixels, Pixels, Pixels, Pixels) { let menu_w = px(CONTEXT_MENU_WIDTH); - let natural_h = px(context_menu_height()); + let natural_h = px(context_menu_height(&self.context_menu_items())); let bounds = self.container_bounds; // Cap the visible height to the container so a short window scrolls // rather than overflowing and clipping unreachable items. @@ -938,6 +1096,40 @@ impl GraphView { self.total_list_items() } + /// The context-menu rows to show for the selection as it stands. See + /// [`menu_items_for_selection`]. + fn context_menu_items(&self) -> Vec<&'static GraphMenuItem> { + menu_items_for_selection(self.selected_commit_count()) + } + + /// The event a context-menu row emits, bound to the commit it was opened on. + fn menu_event(action: GraphMenuAction, commit: &CommitInfo) -> GraphViewEvent { + let oid = commit.oid; + match action { + GraphMenuAction::CherryPick => GraphViewEvent::CherryPick(oid), + GraphMenuAction::Revert => GraphViewEvent::RevertCommit(oid), + GraphMenuAction::Checkout => GraphViewEvent::CheckoutCommit(oid), + GraphMenuAction::CreateBranch => GraphViewEvent::CreateBranchAtCommit(oid), + GraphMenuAction::CreateTag => GraphViewEvent::CreateTagAtCommit(oid), + GraphMenuAction::BisectGood => GraphViewEvent::BisectGood(oid), + GraphMenuAction::BisectBad => GraphViewEvent::BisectBad(oid), + GraphMenuAction::Reset => GraphViewEvent::ResetToCommit(oid, oid.to_string()), + GraphMenuAction::InteractiveRebase => GraphViewEvent::InteractiveRebase(oid), + GraphMenuAction::SquashSelected => GraphViewEvent::SquashSelected, + GraphMenuAction::CopySha => GraphViewEvent::CopyCommitSha(oid.to_string()), + GraphMenuAction::CopyMessage => { + GraphViewEvent::CopyCommitMessage(commit.message.clone()) + } + GraphMenuAction::CopyAuthor => { + GraphViewEvent::CopyAuthorName(commit.author.name.clone()) + } + GraphMenuAction::CopyDate => { + GraphViewEvent::CopyDate(commit.time.format("%Y-%m-%d %H:%M:%S").to_string()) + } + GraphMenuAction::ViewOnGithub => GraphViewEvent::ViewOnGithub(oid), + } + } + fn dismiss_context_menu(&mut self, cx: &mut Context) { if self.context_menu.is_some() { self.context_menu = None; @@ -2762,14 +2954,8 @@ impl Render for GraphView { // Context menu overlay if let Some(ref menu_state) = self.context_menu { if let Some(commit) = self.commits.get(menu_state.commit_index) { - let oid = commit.oid; - let sha = format!("{}", oid); - let msg_clone = commit.message.clone(); - let author_name_clone = commit.author.name.clone(); - let date_clone = commit.time.format("%Y-%m-%d %H:%M:%S").to_string(); let pos = menu_state.position; let weak = cx.weak_entity(); - let sha_clone = sha.clone(); let menu_bg = colors.elevated_surface_background; let menu_border = colors.border; @@ -2777,22 +2963,7 @@ impl Render for GraphView { let menu_active = colors.ghost_element_active; let menu_accent = colors.text_accent; - let menu_items: Vec<(&str, IconName)> = vec![ - ("Cherry-pick commit", IconName::GitCommit), - ("Revert commit", IconName::Undo), - ("Checkout commit", IconName::Check), - ("Create branch here", IconName::GitBranch), - ("Create tag here", IconName::Tag), - ("Mark as good (bisect)", IconName::Check), - ("Mark as bad (bisect)", IconName::X), - ("Reset to here", IconName::Trash), - ("Interactive Rebase", IconName::GitMerge), - ("Copy SHA", IconName::Copy), - ("Copy commit message", IconName::Edit), - ("Copy author name", IconName::User), - ("Copy date", IconName::Clock), - ("View on GitHub", IconName::ExternalLink), - ]; + let items = self.context_menu_items(); // Placement and visible height, clamped to the container. Shared // with the dismiss hit-test so both agree on the menu rectangle. @@ -2832,13 +3003,8 @@ impl Render for GraphView { cx.stop_propagation(); }); - for (idx, (label_text, icon_name)) in menu_items.iter().enumerate() { - let label: SharedString = (*label_text).into(); - let icon = *icon_name; - - // Add separator before bisect options, before destructive "Reset", - // before Interactive Rebase, and before clipboard ops - if idx == 5 || idx == 7 || idx == 8 || idx == 12 { + for (idx, item) in items.iter().enumerate() { + if item.separator_before { menu = menu.child( div() .w_full() @@ -2849,237 +3015,51 @@ impl Render for GraphView { ); } - let mut item = div() - .id(ElementId::NamedInteger("ctx-action".into(), idx as u64)) - .h_flex() - .w_full() - .h(px(26.)) - .px(px(8.)) - .mx(px(4.)) - .gap(px(6.)) - .items_center() - .cursor_pointer() - .rounded(px(3.)) - .hover(move |s| s.bg(menu_hover).border_l_2().border_color(menu_accent)) - .active(move |s| s.bg(menu_active)); - - match idx { - 0 => { - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CherryPick(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 1 => { - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::RevertCommit(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 2 => { - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CheckoutCommit(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 3 => { - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CreateBranchAtCommit(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 4 => { - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CreateTagAtCommit(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 5 => { - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::BisectGood(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 6 => { - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::BisectBad(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 7 => { - let w = weak.clone(); - let sha_for_reset = sha_clone.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - let sha_val = sha_for_reset.clone(); - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::ResetToCommit(oid, sha_val)); - cx.notify(); - }) - .ok(); - }, - ); - } - 8 => { - // Interactive Rebase — emit event with the right-clicked commit's OID. - // The workspace handler will build the commit list from HEAD down - // to (including) this commit and open the rebase editor. - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::InteractiveRebase(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 9 => { - let w = weak.clone(); - let sha_for_click = sha_clone.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - let sha_val = sha_for_click.clone(); - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CopyCommitSha(sha_val)); - cx.notify(); - }) - .ok(); - }, - ); - } - 10 => { - let w = weak.clone(); - let msg_for_click = msg_clone.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - let msg_val = msg_for_click.clone(); - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CopyCommitMessage(msg_val)); - cx.notify(); - }) - .ok(); - }, - ); - } - 11 => { - let w = weak.clone(); - let author_for_click = author_name_clone.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - let author_val = author_for_click.clone(); - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CopyAuthorName(author_val)); - cx.notify(); - }) - .ok(); - }, - ); - } - 12 => { - let w = weak.clone(); - let date_for_click = date_clone.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - let date_val = date_for_click.clone(); - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CopyDate(date_val)); - cx.notify(); - }) - .ok(); - }, - ); - } - 13 => { - // View on GitHub — emit OID; workspace handler constructs the URL. - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::ViewOnGithub(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - _ => {} - } - - // Destructive actions render in error color (Bad and Reset) - let item_color = if idx == 6 || idx == 7 { - Color::Error - } else { - Color::Muted - }; - let label_color = if idx == 6 || idx == 7 { - Color::Error + // Built here rather than in the click handler so the row + // captures the commit the menu was opened on, even if the + // selection moves before the click lands. + let event = Self::menu_event(item.action, commit); + let weak = weak.clone(); + let (icon_color, label_color) = if item.destructive { + (Color::Error, Color::Error) } else { - Color::Default + (Color::Muted, Color::Default) }; - item = item - .child(Icon::new(icon).size(IconSize::XSmall).color(item_color)) - .child(Label::new(label).size(LabelSize::XSmall).color(label_color)); - - menu = menu.child(item); + menu = menu.child( + div() + .id(ElementId::NamedInteger("ctx-action".into(), idx as u64)) + .h_flex() + .w_full() + .h(px(CONTEXT_MENU_ITEM_HEIGHT)) + .px(px(8.)) + .mx(px(4.)) + .gap(px(6.)) + .items_center() + .cursor_pointer() + .rounded(px(3.)) + .hover(move |s| s.bg(menu_hover).border_l_2().border_color(menu_accent)) + .active(move |s| s.bg(menu_active)) + .on_click(move |_: &ClickEvent, _: &mut Window, cx: &mut App| { + let event = event.clone(); + weak.update(cx, |this: &mut GraphView, cx| { + this.context_menu = None; + cx.emit(event); + cx.notify(); + }) + .ok(); + }) + .child( + Icon::new(item.icon) + .size(IconSize::XSmall) + .color(icon_color), + ) + .child( + Label::new(SharedString::from(item.label)) + .size(LabelSize::XSmall) + .color(label_color), + ), + ); } let menu = menu.with_animation( @@ -3956,6 +3936,72 @@ mod tests { git2::Oid::from_bytes(&bytes).unwrap() } + use menu_items_for_selection as menu_items; + + #[test] + fn squash_is_offered_only_once_two_commits_are_selected() { + for selected in [0, 1] { + assert!( + !menu_items(selected) + .iter() + .any(|item| item.action == GraphMenuAction::SquashSelected), + "squash offered with {selected} commit(s) selected" + ); + } + for selected in [2, 5] { + assert!( + menu_items(selected) + .iter() + .any(|item| item.action == GraphMenuAction::SquashSelected), + "squash missing with {selected} commits selected" + ); + } + } + + /// The bug the hard-coded `CONTEXT_MENU_ITEM_COUNT` used to cause: an item + /// added without touching the constant left the menu sized for the old list, + /// clipping the last row. + #[test] + fn the_menu_height_follows_the_rows_it_actually_has() { + let without = context_menu_height(&menu_items(1)); + let with = context_menu_height(&menu_items(2)); + assert_eq!( + with - without, + CONTEXT_MENU_ITEM_HEIGHT, + "showing squash must add exactly one row's worth of height" + ); + + let rows = menu_items(2).len() as f32; + let separators = menu_items(2) + .iter() + .filter(|item| item.separator_before) + .count() as f32; + assert_eq!( + with, + rows * CONTEXT_MENU_ITEM_HEIGHT + + separators * CONTEXT_MENU_SEPARATOR_HEIGHT + + CONTEXT_MENU_VERTICAL_PADDING + ); + } + + #[test] + fn every_menu_row_is_distinct_and_no_group_starts_the_menu() { + let mut seen: Vec = Vec::new(); + for item in GRAPH_MENU_ITEMS { + assert!(!item.label.is_empty()); + assert!( + !seen.contains(&item.action), + "{:?} appears twice in the menu", + item.action + ); + seen.push(item.action); + } + assert!( + !GRAPH_MENU_ITEMS[0].separator_before, + "a separator above the first row draws a stray line inside the border" + ); + } + fn make_commit(oid: u8, parents: &[u8], refs: Vec) -> CommitInfo { CommitInfo { oid: make_oid(oid), diff --git a/crates/rgitui_workspace/src/workspace/commands.rs b/crates/rgitui_workspace/src/workspace/commands.rs index 75a87b5..b840fa7 100644 --- a/crates/rgitui_workspace/src/workspace/commands.rs +++ b/crates/rgitui_workspace/src/workspace/commands.rs @@ -111,7 +111,11 @@ impl Workspace { /// review it, adjust the actions and confirm through the dialog's own /// `Execute` path. Validation happens first — see [`crate::squash`] for why — /// and a rejection becomes a toast that names the condition that failed. - fn squash_selected_commits(&mut self, cx: &mut Context) { + /// + /// Both routes to squashing end here: the `graph::SquashSelected` keystroke + /// and the graph's "Squash selected commits" context-menu item, which only + /// checks that two rows are selected before emitting. + pub(super) fn squash_selected_commits(&mut self, cx: &mut Context) { let Some(tab) = self.tabs.get(self.active_tab) else { return; }; diff --git a/crates/rgitui_workspace/src/workspace/events.rs b/crates/rgitui_workspace/src/workspace/events.rs index 2111a62..4722614 100644 --- a/crates/rgitui_workspace/src/workspace/events.rs +++ b/crates/rgitui_workspace/src/workspace/events.rs @@ -2141,6 +2141,10 @@ pub(super) fn subscribe_graph( ir.show_visible(entries, format!("{} (rebase onto)", base_short), cx); }); } + // The context-menu route to squashing. It lands on the same + // helper as the `graph::SquashSelected` keystroke, so the + // validation and the rejection wording cannot drift apart. + GraphViewEvent::SquashSelected => this.squash_selected_commits(cx), } } }) From 21502cb8fad6d294a3bc0c3bc6040cbb93672b4b Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:01:12 +0100 Subject: [PATCH 15/16] feat(keymap): report view-vs-global shadowing as an informational note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `conflict.rs` compares two bindings' context predicates for textual or `is_superset` overlap, which cannot see that `GraphView && !TextInput` sits *inside* `Workspace && !modal` at dispatch time. A panel binding taking a keystroke away from a global one therefore went unreported — exactly the case that had squash silently costing `rgitui::UnstageAll` its `secondary-shift-s`. It is reported now, at a severity below a conflict. Deeper-wins scoping is usually the point (the shipped defaults have seven such overlaps: Esc means "back to the diff" in the blame and history views, `/` filters the focused list rather than searching the graph), so nothing is dropped, and an info note never becomes a load-time toast or a startup WARN — it appears on the affected row in the shortcut reference and in a new table in docs/KEYBINDINGS.md. `CONTEXT_TREE` in the registry says which element each key context is set on and what encloses it. It is hand-written, because the `key_context` calls it mirrors are spread over three crates and only run at render time, but `every_registry_context_is_in_the_tree` fails when a new `commands!` block names a context nobody placed in it, so a view cannot escape the analysis unnoticed. `shadow.rs` walks the tree into focus paths and asks gpui's own `depth_of` which binding wins where, so the verdict cannot drift from what pressing the key actually does — and the `modal` flag means an overlay is not reported as shadowing a global binding that `!modal` had already stood down. `summary.rs` carries the finding on its existing per-command note list, now tagged with a `NoteSeverity`, rather than through a second channel; `warning_count` still counts warnings only, so the defaults do not tell every user their keymap has problems. While regenerating the reference: say where `keymap.json` actually lives per platform, and that the app will create and open it for you. Co-Authored-By: Claude Fable 5 --- .../rgitui_workspace/src/keymap/generate.rs | 106 ++++- crates/rgitui_workspace/src/keymap/mod.rs | 7 +- .../rgitui_workspace/src/keymap/registry.rs | 334 ++++++++++++++ crates/rgitui_workspace/src/keymap/shadow.rs | 420 ++++++++++++++++++ crates/rgitui_workspace/src/keymap/summary.rs | 161 ++++++- crates/rgitui_workspace/src/shortcuts_help.rs | 24 +- docs/KEYBINDINGS.md | 28 +- 7 files changed, 1038 insertions(+), 42 deletions(-) create mode 100644 crates/rgitui_workspace/src/keymap/shadow.rs diff --git a/crates/rgitui_workspace/src/keymap/generate.rs b/crates/rgitui_workspace/src/keymap/generate.rs index 148f584..22f8db6 100644 --- a/crates/rgitui_workspace/src/keymap/generate.rs +++ b/crates/rgitui_workspace/src/keymap/generate.rs @@ -2,7 +2,8 @@ //! //! Two files are derived from `commands!` and checked in: //! -//! * `docs/KEYBINDINGS.md` — the user-facing reference, +//! * `docs/KEYBINDINGS.md` — the user-facing reference, including the +//! nested-context overlaps [`super::shadow`] finds in the defaults, //! * `docs/keymap.schema.json` — a JSON Schema enumerating every action name so //! editors can complete `keymap.json`. //! @@ -82,8 +83,9 @@ pub fn keybindings_markdown() -> String { out.push_str(GENERATED_NOTICE); out.push_str(" -->\n\n"); out.push_str( - "Every shortcut below is rebindable. Create `keymap.json` next to `settings.json` in \ - rgitui's config directory and bind the action names from the tables below.\n\n", + "Every shortcut below is rebindable, and press `?` in rgitui to see the ones actually \ + in force — that reference is generated from the same declaration as this page, so it \ + follows your own keybindings rather than the defaults.\n\n", ); out.push_str( "`secondary` is the platform's primary modifier: `cmd` on macOS, `ctrl` everywhere \ @@ -91,6 +93,18 @@ pub fn keybindings_markdown() -> String { command palette (`secondary-shift-p`).\n\n", ); out.push_str("## Customising\n\n"); + out.push_str( + "Keybindings live in `keymap.json`, next to `settings.json` in rgitui's config \ + directory:\n\n\ + | Platform | Path |\n\ + | --- | --- |\n\ + | Linux | `~/.config/rgitui/keymap.json` |\n\ + | macOS | `~/Library/Application Support/rgitui/keymap.json` |\n\ + | Windows | `%APPDATA%\\rgitui\\keymap.json` |\n\n\ + Run the **Open keymap.json** command from the palette, or use the button in the \ + shortcut reference, to create the file with a commented example already in it and \ + open it in your editor.\n\n", + ); out.push_str( "```jsonc\n\ [\n \ @@ -113,8 +127,8 @@ pub fn keybindings_markdown() -> String { dropped rather than silently ignored.\n\n", ); out.push_str( - "`docs/keymap.schema.json` lists every action name; point your editor at it for \ - completion.\n", + "`docs/keymap.schema.json` lists every action name with its description; associate it \ + with `keymap.json` in your editor's JSON schema settings for completion and hovers.\n", ); for view in views() { @@ -155,6 +169,45 @@ pub fn keybindings_markdown() -> String { do not.\n" ); + out.push_str(&shadowing_section()); + + out +} + +/// Renders the "Where a panel wins a keystroke" section. +/// +/// Derived from the same analysis the shortcut reference shows on the affected +/// row, so the page cannot claim a keystroke works somewhere it does not. +fn shadowing_section() -> String { + let specs = super::loader::default_specs(); + let shadows = super::shadow::detect_shadowing(&specs); + + let mut out = String::from("\n## Where a panel wins a keystroke\n\n"); + out.push_str( + "The deepest match wins, so a few of the shortcuts above cannot be reached while a \ + particular panel has focus. That is intended — the alternative would be a panel \ + unable to give a letter its own meaning. Both bindings stay active; only one of them \ + is what the keystroke does in that panel.\n\n", + ); + + if shadows.is_empty() { + out.push_str("The defaults currently have no such overlaps.\n"); + return out; + } + + out.push_str("| Keystroke | Runs | While focused | So this is out of reach |\n"); + out.push_str("| --- | --- | --- | --- |\n"); + for shadow in &shadows { + let node = super::registry::context_node(shadow.context); + let _ = writeln!( + out, + "| `{}` | `{}` | {} | `{}` |", + table_cell(&specs[shadow.inner].keystrokes), + specs[shadow.inner].action, + node.map_or(shadow.context, |node| node.label), + specs[shadow.outer].action, + ); + } out } @@ -342,15 +395,52 @@ mod tests { #[test] fn the_doc_lists_every_command_exactly_once() { + // Only the per-view tables: the shadowing table further down names some + // commands again on purpose. let markdown = keybindings_markdown(); + let tables = markdown + .split_once("\n## Key contexts") + .map_or(markdown.as_str(), |(tables, _)| tables); for meta in ALL_COMMANDS { let row = format!("| `{}` |", meta.action_name); assert_eq!( - markdown.matches(&row).count(), + tables.matches(&row).count(), 1, - "{} appears {} times in the generated doc", + "{} appears {} times in the command tables", meta.action_name, - markdown.matches(&row).count() + tables.matches(&row).count() + ); + } + } + + /// The shadowing table is only useful if it names real actions and real + /// contexts, so it is checked against the registry rather than eyeballed. + #[test] + fn the_shadowing_table_names_registry_actions() { + let section = shadowing_section(); + let rows: Vec<&str> = section + .lines() + .filter(|line| line.starts_with("| `")) + .collect(); + assert!(!rows.is_empty(), "{section}"); + + for row in rows { + let cells: Vec<&str> = row.trim_matches('|').split('|').map(str::trim).collect(); + let [_keystroke, inner, label, outer] = cells.as_slice() else { + panic!("a shadowing row has the wrong shape: {row}"); + }; + for action in [inner, outer] { + let name = action.trim_matches('`'); + assert!( + super::super::registry::command_for_action(name).is_some(), + "`{name}` in the shadowing table is not a registry action" + ); + } + assert!( + super::super::registry::CONTEXT_TREE + .iter() + .any(|node| node.label == *label && !node.modal), + "`{label}` in the shadowing table is not a non-modal element label" ); } } diff --git a/crates/rgitui_workspace/src/keymap/mod.rs b/crates/rgitui_workspace/src/keymap/mod.rs index c6a4b8d..3c3de49 100644 --- a/crates/rgitui_workspace/src/keymap/mod.rs +++ b/crates/rgitui_workspace/src/keymap/mod.rs @@ -4,6 +4,8 @@ //! * [`registry`] declares every command with it, producing [`CommandId`], the //! gpui action structs and [`ALL_COMMANDS`]. //! * [`conflict`] detects ambiguous bindings before gpui silently resolves them. +//! * [`shadow`] reports, informationally, where a panel binding wins a keystroke +//! from a global one — legitimate scoping, but worth knowing about. //! * [`loader`] reads `keymap.json` and assembles the final binding list. //! * [`display`] renders a keystroke the way the user reads it. //! * [`summary`] records what the load produced — the effective binding of every @@ -24,6 +26,7 @@ pub mod display; pub mod generate; pub mod loader; pub mod registry; +pub mod shadow; pub mod summary; use std::path::PathBuf; @@ -35,7 +38,9 @@ use gpui::{App, Global}; pub use display::{humanize_sequence, KeystrokeStyle}; pub use loader::{ensure_keymap_file, keymap_path, keymap_stub}; pub use registry::{actions, attach_actions, CommandId, CommandMeta, ALL_COMMANDS}; -pub use summary::{CommandBindings, CommandGroup, EffectiveBinding, KeymapSummary}; +pub use summary::{ + CommandBindings, CommandGroup, EffectiveBinding, KeymapNote, KeymapSummary, NoteSeverity, +}; /// Declares a view's key context and attaches the commands it handles. /// diff --git a/crates/rgitui_workspace/src/keymap/registry.rs b/crates/rgitui_workspace/src/keymap/registry.rs index 983be4e..e18623b 100644 --- a/crates/rgitui_workspace/src/keymap/registry.rs +++ b/crates/rgitui_workspace/src/keymap/registry.rs @@ -357,6 +357,248 @@ commands! { } } +/// One element of the key-context tree — a `key_context(...)` call and where the +/// element carrying it sits. +/// +/// gpui evaluates a binding's context predicate against every prefix of the path +/// from the window root down to the focused element and keeps the *deepest* match +/// ([`gpui::KeyBindingContextPredicate::depth_of`]); the deepest match wins. A +/// binding scoped to a panel therefore takes a keystroke away from a global +/// binding whenever that panel has focus. +/// +/// `commands!` cannot see that: a `view` block says which context a binding is +/// scoped *to*, never where that context sits. So the shape is written out below +/// and [`super::shadow`] walks it to reconstruct the focus paths. +pub struct KeyContextNode { + /// The element's key context, verbatim as passed to `key_context`. The first + /// identifier names the element; any others are groups it joins, like `List`. + pub context: &'static str, + /// The enclosing element's [`Self::name`], or `None` for a window root. + pub parent: Option<&'static str>, + /// Whether the workspace root carries `modal` while this element is up, which + /// is what makes `Workspace && !modal` bindings stand down. Mirrors + /// `Workspace::any_overlay_active`, the gate that actually sets it. + pub modal: bool, + /// How to name the element in a message to the user, e.g. "the commit graph". + pub label: &'static str, +} + +impl KeyContextNode { + /// The identifier naming this element — the first one in [`Self::context`]. + pub fn name(&self) -> &'static str { + self.context + .split_whitespace() + .next() + // Unreachable: `context_tree_is_well_formed` rejects an empty context. + .expect("every node names its element") + } + + /// Every identifier this element's key context sets. + pub fn identifiers(&self) -> impl Iterator { + self.context.split_whitespace() + } +} + +/// The element tree the key contexts hang off, roots first. +/// +/// Hand-written because the `key_context` calls it mirrors are spread across +/// three crates and only run at render time, so there is nothing to derive it +/// from. `every_registry_context_is_in_the_tree` fails when a `commands!` block +/// names a context that is missing here, so a new view cannot slip past +/// [`super::shadow`]'s analysis unnoticed. +pub const CONTEXT_TREE: &[KeyContextNode] = &[ + // Window roots. Each owns its own dispatch path — a binding scoped to one is + // never reachable from the other, which is why `menu::Cancel` names both. + KeyContextNode { + context: "Workspace", + parent: None, + modal: false, + label: "the main window", + }, + KeyContextNode { + context: "SettingsWindow", + parent: None, + modal: false, + label: "the settings window", + }, + // Panels. Always present, so a binding of theirs competes with a global one. + KeyContextNode { + context: "Sidebar List", + parent: Some("Workspace"), + modal: false, + label: "the sidebar", + }, + KeyContextNode { + context: "GraphView", + parent: Some("Workspace"), + modal: false, + label: "the commit graph", + }, + KeyContextNode { + context: "DetailPanel List", + parent: Some("Workspace"), + modal: false, + label: "the commit detail panel", + }, + KeyContextNode { + context: "DiffViewer", + parent: Some("Workspace"), + modal: false, + label: "the diff viewer", + }, + KeyContextNode { + context: "BlameView List", + parent: Some("Workspace"), + modal: false, + label: "the blame view", + }, + KeyContextNode { + context: "FileHistoryView List", + parent: Some("Workspace"), + modal: false, + label: "the file history", + }, + KeyContextNode { + context: "ReflogView List", + parent: Some("Workspace"), + modal: false, + label: "the reflog", + }, + KeyContextNode { + context: "SubmoduleView List", + parent: Some("Workspace"), + modal: false, + label: "the submodule list", + }, + KeyContextNode { + context: "BisectView List", + parent: Some("Workspace"), + modal: false, + label: "the bisect log", + }, + KeyContextNode { + context: "IssuesPanel List", + parent: Some("Workspace"), + modal: false, + label: "the issues panel", + }, + KeyContextNode { + context: "PrsPanel List", + parent: Some("Workspace"), + modal: false, + label: "the pull requests panel", + }, + // Overlays and dialogs. `modal: true` marks the ones + // `Workspace::any_overlay_active` counts; the three that it does not are + // spelled out below, so this table stays a description of what the app does + // rather than of what it ought to do. + KeyContextNode { + context: "CommandPalette List", + parent: Some("Workspace"), + modal: true, + label: "the command palette", + }, + KeyContextNode { + context: "SearchPanel List", + parent: Some("Workspace"), + modal: true, + label: "the working-tree search", + }, + KeyContextNode { + context: "RepoOpener List", + parent: Some("Workspace"), + modal: true, + label: "the repository picker", + }, + KeyContextNode { + context: "ShortcutsHelp", + parent: Some("Workspace"), + modal: true, + label: "the shortcut reference", + }, + KeyContextNode { + context: "InteractiveRebase List", + parent: Some("Workspace"), + modal: true, + label: "the interactive rebase editor", + }, + KeyContextNode { + context: "ThemeEditor", + parent: Some("Workspace"), + modal: true, + label: "the theme editor", + }, + KeyContextNode { + context: "BranchDialog", + parent: Some("Workspace"), + modal: true, + label: "the branch dialog", + }, + KeyContextNode { + context: "TagDialog", + parent: Some("Workspace"), + modal: true, + label: "the tag dialog", + }, + KeyContextNode { + context: "WorktreeDialog", + parent: Some("Workspace"), + modal: true, + label: "the worktree dialog", + }, + KeyContextNode { + context: "RenameDialog", + parent: Some("Workspace"), + modal: true, + label: "the rename dialog", + }, + KeyContextNode { + context: "ConfirmDialog", + parent: Some("Workspace"), + modal: true, + label: "the confirmation dialog", + }, + KeyContextNode { + context: "StashBranchDialog", + parent: Some("Workspace"), + modal: true, + label: "the stash-branch dialog", + }, + // These three are not in `any_overlay_active`, so global shortcuts stay live + // behind them. + KeyContextNode { + context: "CreatePrDialog", + parent: Some("Workspace"), + modal: false, + label: "the pull request dialog", + }, + KeyContextNode { + context: "RepoCloneDialog", + parent: Some("Workspace"), + modal: false, + label: "the clone dialog", + }, + KeyContextNode { + context: "StashSaveDialog", + parent: Some("Workspace"), + modal: false, + label: "the stash dialog", + }, +]; + +/// Identifiers that flag an element rather than name a place in the tree, so they +/// are absent from [`CONTEXT_TREE`] on purpose. +/// +/// `modal` is added to the workspace root by whichever overlay is up — see +/// [`KeyContextNode::modal`]. `TextInput` is set by [`rgitui_ui::TextInput`], +/// which can appear under any of the nodes above. +pub const CONTEXT_MARKERS: &[&str] = &["modal", "TextInput"]; + +/// The node whose element sets `name`, if any. +pub fn context_node(name: &str) -> Option<&'static KeyContextNode> { + CONTEXT_TREE.iter().find(|node| node.name() == name) +} + impl CommandId { /// Static metadata for this command. pub fn meta(self) -> &'static CommandMeta { @@ -417,6 +659,7 @@ pub fn command_for_action(action_name: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use gpui::KeyBindingContextPredicate; use std::collections::HashSet; /// The historical `CommandId::as_str()` values. These are persisted and @@ -760,6 +1003,97 @@ mod tests { ); } + /// Every identifier a context predicate mentions, negated ones included — + /// `!TextInput` still has to be accounted for by the tree or the markers. + fn predicate_identifiers(predicate: &gpui::KeyBindingContextPredicate, out: &mut Vec) { + use gpui::KeyBindingContextPredicate as P; + match predicate { + P::Identifier(name) => out.push(name.to_string()), + P::Equal(key, _) | P::NotEqual(key, _) => out.push(key.to_string()), + P::Not(inner) => predicate_identifiers(inner, out), + P::Descendant(left, right) | P::And(left, right) | P::Or(left, right) => { + predicate_identifiers(left, out); + predicate_identifiers(right, out); + } + } + } + + #[test] + fn context_tree_is_well_formed() { + let mut names = HashSet::new(); + for node in CONTEXT_TREE { + assert!( + !node.context.trim().is_empty(), + "a node has no key context at all" + ); + assert!( + !node.label.trim().is_empty(), + "{} has no user-facing label", + node.name() + ); + assert!(names.insert(node.name()), "{} appears twice", node.name()); + } + + for node in CONTEXT_TREE { + let Some(parent) = node.parent else { + continue; + }; + assert!( + names.contains(&parent), + "{}'s parent `{parent}` is not in the tree", + node.name() + ); + // Walking up must terminate at a root rather than loop. + let mut current = node; + let mut hops = 0; + while let Some(parent) = current.parent { + current = context_node(parent).expect("the parent is in the tree"); + hops += 1; + assert!( + hops <= CONTEXT_TREE.len(), + "{} sits in a parent cycle", + node.name() + ); + } + } + + let roots: Vec<&str> = CONTEXT_TREE + .iter() + .filter(|node| node.parent.is_none()) + .map(KeyContextNode::name) + .collect(); + assert_eq!( + roots, + ["Workspace", "SettingsWindow"], + "each window root owns its own dispatch path; adding one is a decision, \ + not an accident" + ); + } + + /// The guard that keeps [`CONTEXT_TREE`] honest: a new `commands!` block + /// naming a context nobody placed in the tree would escape the shadowing + /// analysis silently, so it fails the build instead. + #[test] + fn every_registry_context_is_in_the_tree() { + let mut known: HashSet<&str> = CONTEXT_MARKERS.iter().copied().collect(); + known.extend(CONTEXT_TREE.iter().flat_map(KeyContextNode::identifiers)); + + for (keystrokes, context, action) in default_bindings() { + let predicate = KeyBindingContextPredicate::parse(context) + .unwrap_or_else(|error| panic!("`{context}` does not parse: {error}")); + let mut identifiers = Vec::new(); + predicate_identifiers(&predicate, &mut identifiers); + for identifier in identifiers { + assert!( + known.contains(identifier.as_str()), + "`{identifier}` (from the `{context}` binding of `{keystrokes}` to {action}) \ + is in no CONTEXT_TREE node and is not a marker, so shadowing against it \ + cannot be detected. Add the element to CONTEXT_TREE in this file." + ); + } + } + } + #[test] fn hidden_commands_are_the_ones_absent_from_the_palette() { let palette: HashSet = crate::command_palette::palette_commands() diff --git a/crates/rgitui_workspace/src/keymap/shadow.rs b/crates/rgitui_workspace/src/keymap/shadow.rs new file mode 100644 index 0000000..a9aeeca --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/shadow.rs @@ -0,0 +1,420 @@ +//! Nested-context shadowing: a panel binding that masks a global one. +//! +//! [`super::conflict`] compares two bindings' context predicates for textual or +//! [`is_superset`] overlap, which cannot see that `GraphView && !TextInput` sits +//! *inside* `Workspace && !modal` at dispatch time. So a binding scoped to a +//! panel taking a keystroke away from a global binding used to go completely +//! unreported. +//! +//! It is reported here, and deliberately at a lower severity than a conflict: +//! deeper-wins scoping is usually the whole point — the shipped defaults rely on +//! it, which is why `Esc` means "back to the diff" in the blame view and +//! "dismiss" everywhere else. Nothing is dropped, both bindings stay applied, and +//! the finding never becomes a toast or a startup warning. It shows up in the +//! shortcuts panel and in `docs/KEYBINDINGS.md`, next to the command that lost +//! the keystroke. +//! +//! # How it is decided +//! +//! [`super::registry::CONTEXT_TREE`] says which element each key context is set +//! on and what encloses it, so every focus path the app can produce can be +//! reconstructed. Both predicates are then evaluated against that path with +//! gpui's own [`depth_of`] — the same function the keymap uses to pick a winner — +//! so the verdict cannot drift from what actually happens when the key is +//! pressed. A binding that matches at a greater depth shadows one that matches at +//! a shallower depth; two that match at the same depth are a +//! [`super::conflict`], not a shadow; and two on unrelated paths never meet. +//! +//! Everything here is pure: [`detect_shadowing`] takes the same +//! `(keystrokes, context, action)` specs the loader hands to gpui. +//! +//! [`is_superset`]: gpui::KeyBindingContextPredicate::is_superset +//! [`depth_of`]: gpui::KeyBindingContextPredicate::depth_of + +use gpui::{KeyBindingContextPredicate, KeyContext}; + +use super::conflict::{normalize_sequence, BindingSpec, NormalizedKeystroke}; +use super::registry::{context_node, KeyContextNode, CONTEXT_TREE}; + +/// A binding that another, deeper binding takes a keystroke away from. +/// +/// Both bindings stay applied — this is an observation about which one wins +/// where, not a rejection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Shadowed { + /// Index of the deeper binding, which wins while its element has focus. + pub inner: usize, + /// Index of the binding that cannot be reached there. + pub outer: usize, + /// The element that has to have focus for this to happen. + pub context: &'static str, + /// Informational explanation, filed against the [`Self::outer`] command. + pub message: String, +} + +/// The focus path ending at `node`, root first, as gpui would see it. +/// +/// The root carries `modal` when anything on the path is an overlay, because that +/// is what the workspace root does while one is open — which is in turn what makes +/// a `Workspace && !modal` binding stand down rather than be shadowed. +fn focus_path(node: &'static KeyContextNode) -> Vec { + let mut chain: Vec<&'static KeyContextNode> = vec![node]; + while let Some(parent) = chain.last().and_then(|node| node.parent) { + let Some(parent) = context_node(parent) else { + break; + }; + chain.push(parent); + // `context_tree_is_well_formed` rules cycles out; belt and braces so a + // malformed table cannot hang the keymap load. + if chain.len() > CONTEXT_TREE.len() { + break; + } + } + chain.reverse(); + + let modal = chain.iter().any(|node| node.modal); + chain + .iter() + .enumerate() + .map(|(depth, node)| { + let mut context = KeyContext::default(); + for identifier in node.identifiers() { + context.add(identifier); + } + if modal && depth == 0 { + context.add("modal"); + } + context + }) + .collect() +} + +/// A binding reduced to what shadowing cares about. +struct Candidate { + /// Index into the caller's binding list. + index: usize, + /// Parsed context predicate. `None` means "matches everywhere". + predicate: Option, + /// Normalised keystroke sequence. + keys: Vec, +} + +/// Reports every binding a deeper one masks. See the [module docs](self). +/// +/// `bindings` is in application order and the returned indices point back into +/// it, matching [`super::conflict::ConflictReport`]. Unbinds and bindings whose +/// keystrokes or context do not parse are skipped — the loader reports those, and +/// they never reach gpui. +pub fn detect_shadowing(bindings: &[BindingSpec]) -> Vec { + let candidates: Vec = bindings + .iter() + .enumerate() + .filter(|(_, binding)| !binding.is_unbind()) + .filter_map(|(index, binding)| { + let predicate = match binding.context.as_deref() { + Some(context) => Some(KeyBindingContextPredicate::parse(context).ok()?), + None => None, + }; + Some(Candidate { + index, + predicate, + keys: normalize_sequence(&binding.keystrokes)?, + }) + }) + .collect(); + + let mut found: Vec = Vec::new(); + + for node in CONTEXT_TREE { + let path = focus_path(node); + // A binding with no context is treated as if it were on the focused + // element, exactly as gpui's `binding_enabled` does. + let live: Vec<(&Candidate, usize)> = candidates + .iter() + .filter_map(|candidate| { + let depth = match &candidate.predicate { + Some(predicate) => predicate.depth_of(&path)?, + None => path.len(), + }; + Some((candidate, depth)) + }) + .collect(); + + for (inner, inner_depth) in &live { + for (outer, outer_depth) in &live { + if outer_depth >= inner_depth + || inner.keys != outer.keys + || bindings[inner.index].action == bindings[outer.index].action + { + continue; + } + // One pair can be shadowed on several paths — a `List` binding + // masked in every panel that joins the group, say. The first + // path is enough to explain it. + if found + .iter() + .any(|found| found.inner == inner.index && found.outer == outer.index) + { + continue; + } + found.push(Shadowed { + inner: inner.index, + outer: outer.index, + context: node.name(), + message: message(&bindings[inner.index], &bindings[outer.index], node), + }); + } + } + } + + found +} + +/// Explains which binding wins where, in the keystroke spelling `keymap.json` +/// uses so the sentence reads the same on every platform. +fn message(inner: &BindingSpec, outer: &BindingSpec, node: &KeyContextNode) -> String { + format!( + "`{}` runs `{}` while {} is focused, so `{}` is not reachable there. \ + Both bindings stay active.", + inner.keystrokes, inner.action, node.label, outer.action, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::keymap::conflict::{detect_conflicts, BindingSpec, NO_ACTION}; + use crate::keymap::loader::default_specs; + + fn default_binding(keystrokes: &str, context: &str, action: &str) -> BindingSpec { + BindingSpec::default_binding(keystrokes, context, action) + } + + /// `(keystrokes, inner action, outer action)` for every finding, which is what + /// the assertions below care about. + fn findings(bindings: &[BindingSpec]) -> Vec<(&str, &str, &str)> { + detect_shadowing(bindings) + .into_iter() + .map(|shadow| { + ( + bindings[shadow.inner].keystrokes.as_str(), + bindings[shadow.inner].action.as_str(), + bindings[shadow.outer].action.as_str(), + ) + }) + .collect() + } + + /// The case that motivated this: `SquashSelected` used to default to + /// `secondary-shift-s`, which the graph dispatched ahead of the workspace's + /// `UnstageAll` — invisible to conflict detection, because neither predicate + /// is a superset of the other. + #[test] + fn a_panel_binding_masking_a_global_one_is_reported() { + let bindings = [ + default_binding( + "secondary-shift-s", + "Workspace && !modal", + "rgitui::UnstageAll", + ), + default_binding( + "secondary-shift-s", + "GraphView && !TextInput", + "graph::SquashSelected", + ), + ]; + let found = detect_shadowing(&bindings); + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!(found[0].inner, 1); + assert_eq!(found[0].outer, 0); + assert_eq!(found[0].context, "GraphView"); + assert_eq!( + found[0].message, + "`secondary-shift-s` runs `graph::SquashSelected` while the commit graph is \ + focused, so `rgitui::UnstageAll` is not reachable there. Both bindings stay active." + ); + + // And conflict detection still sees nothing, which is the gap being filled. + assert!(detect_conflicts(&bindings).conflicts.is_empty()); + } + + /// Two bindings on the same element are a conflict, not a shadow: one of them + /// is genuinely dead and gets dropped. + #[test] + fn a_same_context_duplicate_is_left_to_conflict_detection() { + let bindings = [ + default_binding("secondary-s", "Workspace && !modal", "rgitui::StageAll"), + default_binding("secondary-s", "Workspace && !modal", "rgitui::Commit"), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + assert_eq!(detect_conflicts(&bindings).conflicts.len(), 1); + } + + /// Nor is a superset overlap a shadow — the same depth, so `conflict` owns it. + #[test] + fn a_superset_overlap_at_one_depth_is_not_a_shadow() { + let bindings = [ + default_binding("secondary-s", "Workspace && !modal", "rgitui::StageAll"), + default_binding("secondary-s", "Workspace", "rgitui::Commit"), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + assert!(!detect_conflicts(&bindings).conflicts.is_empty()); + } + + /// Two panels can own the same letter without either losing anything, which is + /// exactly what the per-view contexts are for. + #[test] + fn sibling_panels_sharing_a_keystroke_are_not_reported() { + let bindings = [ + default_binding("s", "GraphView && !TextInput", "graph::SquashSelected"), + default_binding("s", "Sidebar && !TextInput", "sidebar::ToggleStageRow"), + default_binding("s", "DiffViewer && !TextInput", "diff::StageSelection"), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + assert!(detect_conflicts(&bindings).conflicts.is_empty()); + } + + /// The two window roots never see each other's bindings. + #[test] + fn bindings_in_separate_windows_are_not_reported() { + let bindings = [ + default_binding("escape", "Workspace", "menu::Cancel"), + default_binding("escape", "SettingsWindow", "menu::Cancel"), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + } + + /// A global binding a modal already suppresses is not shadowed by the modal's + /// own binding: `!modal` turned it off, nothing took it away. + #[test] + fn a_modal_does_not_shadow_what_the_modal_gate_already_disabled() { + let bindings = [ + default_binding( + "tab", + "Workspace && !modal && !TextInput", + "rgitui::FocusNextPanel", + ), + default_binding("tab", "ThemeEditor", "theme::ThemeEditorNextField"), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + + // Without the `!modal` gate the same pair is a shadow, so it is the gate + // doing the work rather than the overlay being skipped. + let ungated = [ + default_binding("tab", "Workspace", "rgitui::FocusNextPanel"), + default_binding("tab", "ThemeEditor", "theme::ThemeEditorNextField"), + ]; + assert_eq!(detect_shadowing(&ungated).len(), 1); + } + + #[test] + fn a_user_binding_can_shadow_a_default() { + let mut bindings = vec![default_binding( + "ctrl-alt-9", + "Workspace && !modal", + "rgitui::Fetch", + )]; + bindings.push(BindingSpec::user_binding( + "ctrl-alt-9", + Some("DiffViewer"), + "diff::NextHunk", + )); + let found = detect_shadowing(&bindings); + assert_eq!(found.len(), 1, "{found:?}"); + assert!(found[0].message.contains("the diff viewer"), "{found:?}"); + } + + /// One command bound in two contexts is one behaviour, not a shadow of itself. + #[test] + fn a_command_does_not_shadow_itself() { + let bindings = [ + default_binding("/", "Workspace && !modal && !TextInput", "rgitui::Search"), + default_binding("/", "GraphView && !TextInput", "rgitui::Search"), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + } + + #[test] + fn an_unbind_is_not_a_shadow() { + let bindings = [ + default_binding("secondary-s", "Workspace && !modal", "rgitui::StageAll"), + BindingSpec::user_binding("secondary-s", Some("GraphView"), NO_ACTION), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + } + + #[test] + fn an_unparseable_binding_is_skipped_rather_than_reported() { + let bindings = [ + default_binding("ctrl-a-b", "Workspace", "rgitui::StageAll"), + default_binding("ctrl-a-b", "GraphView", "graph::CopyCommitSha"), + default_binding("ctrl-alt-1", "Workspace &&", "rgitui::Fetch"), + default_binding("ctrl-alt-1", "GraphView", "graph::CopyCommitMessage"), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + } + + /// The shipped defaults must not conflict — that is already pinned in the + /// loader — and the shadowing they do have is deliberate, so it is spelled out + /// here rather than merely counted. + #[test] + fn the_shipped_defaults_shadow_only_these() { + let specs = default_specs(); + assert!( + detect_conflicts(&specs).conflicts.is_empty(), + "the defaults must never conflict" + ); + + // Esc means "back to the diff" or "close the search" in the three views + // below and dismisses the topmost overlay everywhere else; `/` and Ctrl+F + // filter the focused list instead of searching the commit graph. + let mut expected = vec![ + ("escape", "blame::BlameShowDiff", "menu::Cancel"), + ("escape", "graph::GraphCancel", "menu::Cancel"), + ("escape", "history::HistoryShowDiff", "menu::Cancel"), + ("/", "detail::FileSearch", "rgitui::Search"), + ("/", "sidebar::FilterBranches", "rgitui::Search"), + ("secondary-f", "detail::FileSearch", "rgitui::Search"), + ("secondary-f", "sidebar::FilterBranches", "rgitui::Search"), + ]; + expected.sort_unstable(); + + let mut found = findings(&specs); + found.sort_unstable(); + assert_eq!( + found, expected, + "the defaults' shadowing changed — check it is still intentional" + ); + } + + /// Bare `s` for squash has to be reachable in the graph, which means nothing + /// shallower may claim it and nothing deeper may take it away. + #[test] + fn bare_s_in_the_graph_collides_with_nothing() { + let specs = default_specs(); + let squash: Vec<&BindingSpec> = specs + .iter() + .filter(|spec| spec.action == "graph::SquashSelected") + .collect(); + assert_eq!( + squash + .iter() + .map(|spec| (spec.keystrokes.as_str(), spec.context.as_deref())) + .collect::>(), + [("s", Some("GraphView && !TextInput"))] + ); + + for shadow in detect_shadowing(&specs) { + assert_ne!( + specs[shadow.outer].action, "graph::SquashSelected", + "something takes `s` away from squash: {}", + shadow.message + ); + assert_ne!( + specs[shadow.inner].action, "graph::SquashSelected", + "squash takes a keystroke away from another command: {}", + shadow.message + ); + } + } +} diff --git a/crates/rgitui_workspace/src/keymap/summary.rs b/crates/rgitui_workspace/src/keymap/summary.rs index 1db1c25..25a31c9 100644 --- a/crates/rgitui_workspace/src/keymap/summary.rs +++ b/crates/rgitui_workspace/src/keymap/summary.rs @@ -32,6 +32,17 @@ //! the affected command gets a warning saying where its keystroke went. That is //! how a rebind shows up as "this command lost its shortcut" instead of as a //! shortcut that does nothing. +//! +//! # Severity +//! +//! Not everything worth telling the user is a problem, so each note carries a +//! [`NoteSeverity`]. A [`NoteSeverity::Warning`] means something was dropped or a +//! command lost a keystroke it will not get back. A [`NoteSeverity::Info`] is the +//! nested-context shadowing [`super::shadow`] finds: both bindings are applied +//! and the scoping is very likely deliberate — the shipped defaults produce +//! several — so it must never become a toast or a startup warning. Keeping both +//! on one list means the shortcuts panel shows them in the same place, styled by +//! severity, with no second channel to keep in step. use std::sync::Arc; @@ -40,6 +51,7 @@ use super::conflict::{ }; use super::display::{self, KeystrokeStyle}; use super::registry::{command_for_action, CommandId, ALL_COMMANDS}; +use super::shadow; /// One binding of one command, as it will actually fire. #[derive(Debug, Clone, PartialEq, Eq)] @@ -61,6 +73,31 @@ impl EffectiveBinding { } } +/// How much a note matters. See the [module docs](self#severity). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NoteSeverity { + /// Something was dropped, or the command lost a keystroke for good. + Warning, + /// A deeper binding wins the keystroke somewhere. Nothing was dropped. + Info, +} + +/// One thing worth telling the user about a command's bindings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeymapNote { + /// How much it matters. + pub severity: NoteSeverity, + /// The explanation, ready to render. + pub message: String, +} + +impl KeymapNote { + /// Whether this note reports an actual problem. + pub fn is_warning(&self) -> bool { + self.severity == NoteSeverity::Warning + } +} + /// Everything the UI needs to show about one command. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandBindings { @@ -68,9 +105,10 @@ pub struct CommandBindings { pub command: CommandId, /// Bindings that will fire, in precedence order (last wins). pub bindings: Vec, - /// Problems the user should know about: a binding of theirs that was - /// dropped, or a keystroke this command lost to another. - pub warnings: Vec, + /// What the user should know: a binding of theirs that was dropped, a + /// keystroke this command lost to another, or where a panel wins one of its + /// keystrokes. Warnings first, then info, each in detection order. + pub notes: Vec, } impl CommandBindings { @@ -83,6 +121,15 @@ impl CommandBindings { pub fn is_user_defined(&self) -> bool { self.bindings.iter().any(EffectiveBinding::is_user_defined) } + + /// The messages of this command's notes at one severity. + pub fn messages(&self, severity: NoteSeverity) -> Vec<&str> { + self.notes + .iter() + .filter(|note| note.severity == severity) + .map(|note| note.message.as_str()) + .collect() + } } /// A group of commands sharing a `commands!` view block, which is also the key @@ -135,29 +182,30 @@ impl KeymapSummary { .map(|meta| CommandBindings { command: meta.id, bindings: Vec::new(), - warnings: Vec::new(), + notes: Vec::new(), }) .collect(); let mut unattributed_warnings = Vec::new(); for conflict in &report.conflicts { let ignored = &specs[conflict.ignored]; - warn( + note( &mut commands, &mut unattributed_warnings, &ignored.action, + NoteSeverity::Warning, conflict.message.clone(), ); } // Only applied specs can fire, and only in the order they were applied. - let applied: Vec<&BindingSpec> = applied.iter().map(|&index| &specs[index]).collect(); - let sequences: Vec>> = applied + let applied_specs: Vec<&BindingSpec> = applied.iter().map(|&index| &specs[index]).collect(); + let sequences: Vec>> = applied_specs .iter() .map(|spec| conflict::normalize_sequence(&spec.keystrokes)) .collect(); - for (index, spec) in applied.iter().enumerate() { + for (index, spec) in applied_specs.iter().enumerate() { if spec.is_unbind() { continue; } @@ -165,12 +213,13 @@ impl KeymapSummary { continue; }; - if let Some(shadow) = shadow_of(index, &applied, &sequences) { - warn( + if let Some(shadow) = shadow_of(index, &applied_specs, &sequences) { + note( &mut commands, &mut unattributed_warnings, &spec.action, - shadow_message(&display, applied[shadow]), + NoteSeverity::Warning, + shadow_message(&display, applied_specs[shadow]), ); continue; } @@ -189,6 +238,25 @@ impl KeymapSummary { }); } + // Last, so every command's warnings come before its info notes: a panel + // binding that wins a keystroke from a global one. Both bindings are + // applied — the finding is filed against the command that cannot reach + // its keystroke while that panel has focus. + for found in shadow::detect_shadowing(specs) { + if applied.binary_search(&found.inner).is_err() + || applied.binary_search(&found.outer).is_err() + { + continue; + } + note( + &mut commands, + &mut unattributed_warnings, + &specs[found.outer].action, + NoteSeverity::Info, + found.message, + ); + } + Self { commands, unattributed_warnings, @@ -229,9 +297,21 @@ impl KeymapSummary { .is_some_and(CommandBindings::is_user_defined) } - /// Warnings for one command: dropped bindings and lost keystrokes. - pub fn warnings(&self, id: CommandId) -> &[String] { - self.command(id).map_or(&[], |entry| &entry.warnings) + /// Everything worth saying about one command's bindings, warnings first. + pub fn notes(&self, id: CommandId) -> &[KeymapNote] { + self.command(id).map_or(&[], |entry| &entry.notes) + } + + /// Problems with one command: dropped bindings and lost keystrokes. + pub fn warnings(&self, id: CommandId) -> Vec<&str> { + self.command(id) + .map_or_else(Vec::new, |entry| entry.messages(NoteSeverity::Warning)) + } + + /// Informational notes for one command: keystrokes a panel wins from it. + pub fn infos(&self, id: CommandId) -> Vec<&str> { + self.command(id) + .map_or_else(Vec::new, |entry| entry.messages(NoteSeverity::Info)) } /// Number of bindings that came from `keymap.json`. @@ -244,10 +324,13 @@ impl KeymapSummary { } /// Number of commands carrying at least one warning. + /// + /// Informational notes are excluded on purpose: the shipped defaults produce + /// several, and counting them would tell every user their keymap has problems. pub fn warning_count(&self) -> usize { self.commands .iter() - .filter(|entry| !entry.warnings.is_empty()) + .filter(|entry| entry.notes.iter().any(KeymapNote::is_warning)) .count() + usize::from(!self.unattributed_warnings.is_empty()) } @@ -301,19 +384,24 @@ impl KeymapSummary { } } -/// Files a warning against the command that owns `action`, or against the -/// summary as a whole when the action belongs to no registry command. -fn warn( +/// Files a note against the command that owns `action`. +/// +/// A *warning* about an action no registry command owns goes to `unattributed` +/// instead, so nothing the user has to fix is lost. An *informational* note about +/// one is dropped: there is no row for it to sit next to and nothing to fix. +fn note( commands: &mut [CommandBindings], unattributed: &mut Vec, action: &str, + severity: NoteSeverity, message: String, ) { match command_for_action(action) .and_then(|id| commands.iter_mut().find(|entry| entry.command == id)) { - Some(entry) => entry.warnings.push(message), - None => unattributed.push(message), + Some(entry) => entry.notes.push(KeymapNote { severity, message }), + None if severity == NoteSeverity::Warning => unattributed.push(message), + None => {} } } @@ -424,10 +512,10 @@ mod tests { assert_eq!(binding.source, BindingSource::Default); } assert!( - entry.warnings.is_empty(), + entry.messages(NoteSeverity::Warning).is_empty(), "{} warns about the defaults: {:?}", meta.action_name, - entry.warnings + entry.notes ); } assert!(summary.unattributed_warnings.is_empty()); @@ -435,6 +523,35 @@ mod tests { assert_eq!(summary.warning_count(), 0); } + /// The defaults rely on deeper-wins scoping, so they legitimately produce + /// informational notes — and none of them may be a warning, because that is + /// what the load-time toast is driven from. + #[test] + fn the_defaults_note_where_a_panel_wins_a_keystroke() { + let summary = defaults(); + let infos = summary.infos(CommandId::Search); + assert!( + infos.iter().any(|message| { + message.contains("sidebar::FilterBranches") && message.contains("the sidebar") + }), + "{infos:?}" + ); + assert!(summary + .infos(CommandId::Cancel) + .iter() + .any(|message| message.contains("graph::GraphCancel"))); + + // Info notes must not inflate the "you have a problem" count. + assert_eq!(summary.warning_count(), 0); + assert!( + summary + .commands() + .iter() + .any(|entry| !entry.messages(NoteSeverity::Info).is_empty()), + "the defaults should have at least one info note" + ); + } + #[test] fn a_command_with_two_keystrokes_lists_both() { assert_eq!( diff --git a/crates/rgitui_workspace/src/shortcuts_help.rs b/crates/rgitui_workspace/src/shortcuts_help.rs index 043fcf0..cff36ff 100644 --- a/crates/rgitui_workspace/src/shortcuts_help.rs +++ b/crates/rgitui_workspace/src/shortcuts_help.rs @@ -7,6 +7,10 @@ //! of theirs collided, or because a keystroke now belongs to another command — //! is shown as a warning on the row it affects. Opening this panel is therefore //! how a user finds out *which* of their bindings was ignored and why. +//! +//! Rows also carry the informational notes: a panel binding winning a keystroke +//! from a global one is legitimate scoping, so it is styled as an aside and is +//! deliberately absent from the "with a problem" count in the header. use gpui::prelude::*; use gpui::{ @@ -18,7 +22,7 @@ use rgitui_ui::{ Badge, Button, ButtonSize, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize, }; -use crate::keymap::{self, CommandBindings, CommandGroup, KeymapSummary}; +use crate::keymap::{self, CommandBindings, CommandGroup, KeymapSummary, NoteSeverity}; use crate::CommandId; #[derive(Debug, Clone)] @@ -158,23 +162,25 @@ impl ShortcutsHelp { row = row.child(headline); - for warning in &entry.warnings { + for note in &entry.notes { + // Info notes are the deliberate deeper-wins scoping the defaults rely + // on, so they read as an aside rather than as something to fix. + let (icon, color) = match note.severity { + NoteSeverity::Warning => (IconName::AlertTriangle, Color::Warning), + NoteSeverity::Info => (IconName::Info, Color::Muted), + }; row = row.child( div() .h_flex() .w_full() .gap(px(6.)) .items_start() - .child( - Icon::new(IconName::AlertTriangle) - .size(IconSize::XSmall) - .color(Color::Warning), - ) + .child(Icon::new(icon).size(IconSize::XSmall).color(color)) .child( div().flex_1().min_w_0().child( - Label::new(SharedString::from(warning.clone())) + Label::new(SharedString::from(note.message.clone())) .size(LabelSize::XSmall) - .color(Color::Warning), + .color(color), ), ), ); diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index d69eae8..afe3bcc 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -2,12 +2,22 @@ -Every shortcut below is rebindable. Create `keymap.json` next to `settings.json` in rgitui's config directory and bind the action names from the tables below. +Every shortcut below is rebindable, and press `?` in rgitui to see the ones actually in force — that reference is generated from the same declaration as this page, so it follows your own keybindings rather than the defaults. `secondary` is the platform's primary modifier: `cmd` on macOS, `ctrl` everywhere else. Commands marked _unbound_ have no default keystroke and are reached from the command palette (`secondary-shift-p`). ## Customising +Keybindings live in `keymap.json`, next to `settings.json` in rgitui's config directory: + +| Platform | Path | +| --- | --- | +| Linux | `~/.config/rgitui/keymap.json` | +| macOS | `~/Library/Application Support/rgitui/keymap.json` | +| Windows | `%APPDATA%\rgitui\keymap.json` | + +Run the **Open keymap.json** command from the palette, or use the button in the shortcut reference, to create the file with a commented example already in it and open it in your editor. + ```jsonc [ { @@ -24,7 +34,7 @@ Every shortcut below is rebindable. Create `keymap.json` next to `settings.json` The file is reloaded when you save it. Bindings you add win over the defaults. Two bindings on the same keystroke in overlapping contexts, or a binding that shadows the prefix of a chord, are reported as a toast and the losing binding is dropped rather than silently ignored. -`docs/keymap.schema.json` lists every action name; point your editor at it for completion. +`docs/keymap.schema.json` lists every action name with its description; associate it with `keymap.json` in your editor's JSON schema settings for completion and hovers. ## Workspace @@ -217,3 +227,17 @@ A binding fires when its context matches somewhere on the path from the focused | a view name | the panel, overlay or dialog of that name — see the tables above | Contexts combine with `&&`, `||` and `!`, and `>` matches a descendant. `!TextInput` is false whenever a text field is anywhere on the focus path, which is why the vim-style letters carry it and the arrow keys do not. + +## Where a panel wins a keystroke + +The deepest match wins, so a few of the shortcuts above cannot be reached while a particular panel has focus. That is intended — the alternative would be a panel unable to give a letter its own meaning. Both bindings stay active; only one of them is what the keystroke does in that panel. + +| Keystroke | Runs | While focused | So this is out of reach | +| --- | --- | --- | --- | +| `/` | `sidebar::FilterBranches` | the sidebar | `rgitui::Search` | +| `secondary-f` | `sidebar::FilterBranches` | the sidebar | `rgitui::Search` | +| `escape` | `graph::GraphCancel` | the commit graph | `menu::Cancel` | +| `/` | `detail::FileSearch` | the commit detail panel | `rgitui::Search` | +| `secondary-f` | `detail::FileSearch` | the commit detail panel | `rgitui::Search` | +| `escape` | `blame::BlameShowDiff` | the blame view | `menu::Cancel` | +| `escape` | `history::HistoryShowDiff` | the file history | `menu::Cancel` | From 400fc504099062060b846c68869637131237bead Mon Sep 17 00:00:00 2001 From: Noah Clarkson Date: Mon, 3 Aug 2026 21:00:00 +1200 Subject: [PATCH 16/16] fix(keymap): stop the problem toast looping, and let unfocused dialogs take Esc Three fixes on top of the keymap work, plus the rebase onto the corrected #60. The workspace observes KeymapState and reported problems by taking them, but taking goes through update_global, which notifies global observers again -- including the observer doing the taking. gpui clears the notification dedup flag before running observers, so the re-notify always lands and the cycle never ends: the UI thread spins at 100% CPU on startup and on every keymap.json save, which is the reload this feature exists for. It now reads before writing and returns when there is nothing left to report. KeymapState::has_problems names the invariant and a unit test pins it, since the convergence rests entirely on take_problems draining the state. menu::Cancel dispatches along the focus path, so the three dialogs that never focused could not be dismissed with Esc once the old visibility-based Esc cascade was removed. Worst hit was this PR's own squash flow: opening the rebase editor from the graph left focus on the graph, where Esc resolved to graph::GraphCancel and did nothing, and `s` reopened the dialog instead of choosing squash. All three now take focus on the next render, matching the seven dialogs that already did. View-scoped bindings also stayed live under an open modal -- only the Workspace block carried !modal -- so j/k/y/s/x kept driving the panel behind a dialog. The six panel contexts now carry it too. Tab switching and go-home stay on Control, carried over from #60: macOS reserves Cmd+Tab and Cmd+H, so the registry's secondary- bindings would never have fired there. Ports the #65 provenance guards onto the new action methods, which the rebase would otherwise have reverted to the is_staged check, making a hunk staged from a historical commit edit the working tree again. Those tests move from simulated keystrokes to the action entry points, since the bindings now live in rgitui_workspace, which rgitui_diff cannot reach. Co-Authored-By: Claude Opus 5 --- crates/rgitui_diff/src/lib.rs | 58 +++++++++----- .../rgitui_workspace/src/command_palette.rs | 19 +++++ .../rgitui_workspace/src/create_pr_dialog.rs | 13 +++- .../src/interactive_rebase.rs | 18 ++++- crates/rgitui_workspace/src/keymap/loader.rs | 47 ++++++++++++ crates/rgitui_workspace/src/keymap/mod.rs | 58 +++++++++++++- .../rgitui_workspace/src/keymap/registry.rs | 50 +++++++----- crates/rgitui_workspace/src/keymap/shadow.rs | 2 +- crates/rgitui_workspace/src/keymap/summary.rs | 3 +- crates/rgitui_workspace/src/workspace/mod.rs | 10 ++- .../rgitui_workspace/src/worktree_dialog.rs | 16 +++- docs/KEYBINDINGS.md | 76 +++++++++---------- 12 files changed, 283 insertions(+), 87 deletions(-) diff --git a/crates/rgitui_diff/src/lib.rs b/crates/rgitui_diff/src/lib.rs index 3b34925..8d88710 100644 --- a/crates/rgitui_diff/src/lib.rs +++ b/crates/rgitui_diff/src/lib.rs @@ -5039,9 +5039,15 @@ mod view_tests { } /// Shows `source` in a focused viewer, selects every row so a staging - /// request would have a target, presses `s` then `u`, and returns whatever - /// staging requests came back. - fn staging_requests_after_pressing_s_and_u(source: DiffSource) -> Vec { + /// request would have a target, invokes stage-then-unstage, and returns + /// whatever staging requests came back. + /// + /// These go through the methods rather than simulated keystrokes because the + /// `s`/`u` bindings are gpui actions declared in `rgitui_workspace`, which + /// sits above this crate and so cannot be reached from here. The keystrokes + /// are covered by the keymap registry's own tests; what matters here is that + /// the entry points the actions call refuse on historical content. + fn staging_requests_after_stage_then_unstage(source: DiffSource) -> Vec { let mut probe = ViewTest::open(StagingProbe::new); probe.update(|probe, window, cx| { @@ -5051,27 +5057,32 @@ mod view_tests { }); }); - // Guard against a vacuous test: the rows must actually exist, or `s` - // would find no hunk under the selection regardless of provenance. + // Guard against a vacuous test: the rows must actually exist, or the + // selection would find no hunk regardless of provenance. probe.read(|probe, cx| { let viewer = probe.viewer.read(cx); assert!( viewer.row_count() > 0, - "display rows should be prepared before keys are pressed" + "display rows should be prepared before staging is attempted" ); }); - // ctrl-a selects every row, so `s`/`u` resolve to the whole hunk. - probe.simulate_keystroke("ctrl-a"); - probe.simulate_keystroke("s"); - probe.simulate_keystroke("u"); + probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| { + // Select every row, so the request resolves to the whole hunk. + viewer.select_all_lines(cx); + viewer.stage_selection(cx); + viewer.unstage_selection(cx); + }); + }); probe.read(|probe, _| probe.staging_requests()) } #[test] fn pressing_s_or_u_on_a_commit_diff_requests_no_staging() { - let requests = staging_requests_after_pressing_s_and_u(DiffSource::Commit(OID.to_string())); + let requests = + staging_requests_after_stage_then_unstage(DiffSource::Commit(OID.to_string())); assert!( requests.is_empty(), "a committed diff must not be stageable, but the viewer emitted {requests:?}" @@ -5080,7 +5091,8 @@ mod view_tests { #[test] fn pressing_s_or_u_on_a_stash_diff_requests_no_staging() { - let requests = staging_requests_after_pressing_s_and_u(DiffSource::Stash(OID.to_string())); + let requests = + staging_requests_after_stage_then_unstage(DiffSource::Stash(OID.to_string())); assert!( requests.is_empty(), "a stashed diff must not be stageable, but the viewer emitted {requests:?}" @@ -5091,7 +5103,7 @@ mod view_tests { /// keys had simply stopped working everywhere. #[test] fn pressing_s_on_a_worktree_diff_still_requests_staging() { - let requests = staging_requests_after_pressing_s_and_u(DiffSource::Worktree); + let requests = staging_requests_after_stage_then_unstage(DiffSource::Worktree); assert_eq!( requests, vec!["HunkStageRequested(0)".to_string()], @@ -5102,7 +5114,7 @@ mod view_tests { /// The mirror control case: the index unstages on `u` and ignores `s`. #[test] fn pressing_u_on_an_index_diff_still_requests_unstaging() { - let requests = staging_requests_after_pressing_s_and_u(DiffSource::Index); + let requests = staging_requests_after_stage_then_unstage(DiffSource::Index); assert_eq!( requests, vec!["HunkUnstageRequested(0)".to_string()], @@ -5126,7 +5138,11 @@ mod view_tests { }); }); - probe.simulate_keystroke("p"); + probe.update(|probe, _window, cx| { + probe + .viewer + .update(cx, |viewer, cx| viewer.toggle_partial_mode(cx)); + }); probe.read(|probe, cx| { assert!( !probe.viewer.read(cx).partial_mode, @@ -5146,9 +5162,17 @@ mod view_tests { }); }); - probe.simulate_keystroke("p"); + probe.update(|probe, _window, cx| { + probe + .viewer + .update(cx, |viewer, cx| viewer.toggle_partial_mode(cx)); + }); probe.read(|probe, cx| assert!(probe.viewer.read(cx).partial_mode)); - probe.simulate_keystroke("p"); + probe.update(|probe, _window, cx| { + probe + .viewer + .update(cx, |viewer, cx| viewer.toggle_partial_mode(cx)); + }); probe.read(|probe, cx| assert!(!probe.viewer.read(cx).partial_mode)); } } diff --git a/crates/rgitui_workspace/src/command_palette.rs b/crates/rgitui_workspace/src/command_palette.rs index e5fe1aa..de49f39 100644 --- a/crates/rgitui_workspace/src/command_palette.rs +++ b/crates/rgitui_workspace/src/command_palette.rs @@ -33,6 +33,9 @@ pub struct CommandContext { pub in_progress_operation: bool, /// True when the user has a GitHub token configured. pub has_github_token: bool, + /// True when the commit graph has more than one commit selected, which is + /// what the multi-commit operations (squash, for one) need. + pub has_multi_commit_selection: bool, } impl CommandContext { @@ -48,9 +51,19 @@ impl CommandContext { has_staged: false, in_progress_operation: false, has_github_token: false, + has_multi_commit_selection: false, } } + /// Records whether the commit graph has more than one commit selected. + /// + /// Kept off [`Self::from_parts`], which maps *repository* state: this flag + /// comes from the graph view's selection and changes far more often. + pub fn with_multi_commit_selection(mut self, selected: bool) -> Self { + self.has_multi_commit_selection = selected; + self + } + /// Build a context from primitive inputs. Keeps the `RepoState` → /// `worktree_clean` / `is_bisecting` / `in_progress_operation` mapping in /// one place so `open_repo`, refresh, and future callers cannot drift. @@ -81,6 +94,7 @@ impl CommandContext { | rgitui_git::RepoState::RevertSequence ), has_github_token, + has_multi_commit_selection: false, } } } @@ -130,6 +144,11 @@ pub(crate) const fn in_progress_operation(ctx: CommandContext) -> bool { ctx.in_progress_operation } +/// Show only when the commit graph has two or more commits selected. +pub(crate) const fn has_multi_commit_selection(ctx: CommandContext) -> bool { + ctx.has_multi_commit_selection +} + /// The command identifier, its gpui action and its default keybinding are /// generated by the `commands!` macro in `keymap::registry`. impl CommandId { diff --git a/crates/rgitui_workspace/src/create_pr_dialog.rs b/crates/rgitui_workspace/src/create_pr_dialog.rs index e9f1cbd..4009af1 100644 --- a/crates/rgitui_workspace/src/create_pr_dialog.rs +++ b/crates/rgitui_workspace/src/create_pr_dialog.rs @@ -40,6 +40,10 @@ pub struct CreatePrDialog { github_token: Option, github_owner: String, github_repo: String, + /// Set when the dialog is opened without a `Window`, so the next render can + /// take focus. gpui dispatches actions only along the focus path, so a + /// dialog that never focuses cannot receive `menu::Cancel` (Esc). + pending_focus: bool, focus_handle: FocusHandle, } @@ -81,6 +85,7 @@ impl CreatePrDialog { base_branch: String::new(), draft: false, visible: false, + pending_focus: false, is_loading: false, error_message: None, github_token: None, @@ -132,6 +137,7 @@ impl CreatePrDialog { cx: &mut Context, ) { self.visible = true; + self.pending_focus = true; self.head_branch = head_branch; self.base_branch = base_branch; self.is_loading = false; @@ -244,11 +250,16 @@ impl CreatePrDialog { } impl Render for CreatePrDialog { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { if !self.visible { return div().id("create-pr-dialog").into_any_element(); } + if self.pending_focus { + self.pending_focus = false; + self.title_input.update(cx, |e, cx| e.focus(window, cx)); + } + let colors = cx.colors().clone(); let head_label: SharedString = self.head_branch.clone().into(); let base_label: SharedString = self.base_branch.clone().into(); diff --git a/crates/rgitui_workspace/src/interactive_rebase.rs b/crates/rgitui_workspace/src/interactive_rebase.rs index 4c48137..905b979 100644 --- a/crates/rgitui_workspace/src/interactive_rebase.rs +++ b/crates/rgitui_workspace/src/interactive_rebase.rs @@ -85,6 +85,12 @@ pub struct InteractiveRebase { editing_reword: Option, /// Text field backing reword editing. reword_editor: Entity, + /// Set when the dialog is opened without a `Window`, so the next render can + /// take focus. gpui dispatches actions only along the focus path, so until + /// this dialog focuses, neither `menu::Cancel` (Esc) nor its own + /// `p`/`r`/`s`/`f`/`d` action bindings reach it — and the graph underneath + /// keeps consuming those keys instead. + pending_focus: bool, focus_handle: FocusHandle, // Drag-to-reorder state @@ -114,6 +120,7 @@ impl InteractiveRebase { pub fn new(cx: &mut Context) -> Self { Self { visible: false, + pending_focus: false, entries: Vec::new(), target_ref: String::new(), selected_index: 0, @@ -152,7 +159,8 @@ impl InteractiveRebase { cx.notify(); } - /// Show without focusing (for contexts where Window is unavailable). + /// Show the dialog, taking focus on the next render (for contexts where + /// `Window` is unavailable). pub fn show_visible( &mut self, entries: Vec, @@ -166,6 +174,7 @@ impl InteractiveRebase { self.dragging_index = None; self.drag_hover_index = None; self.visible = true; + self.pending_focus = true; cx.notify(); } @@ -457,13 +466,18 @@ impl InteractiveRebase { } impl Render for InteractiveRebase { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); if !self.visible { return div().id("interactive-rebase").into_any_element(); } + if self.pending_focus { + self.pending_focus = false; + self.focus_handle.focus(window, cx); + } + let entry_count = self.entries.len(); let header_text: SharedString = format!("Rebasing {} commits onto {}", entry_count, self.target_ref).into(); diff --git a/crates/rgitui_workspace/src/keymap/loader.rs b/crates/rgitui_workspace/src/keymap/loader.rs index 0d1bda8..dfa79ed 100644 --- a/crates/rgitui_workspace/src/keymap/loader.rs +++ b/crates/rgitui_workspace/src/keymap/loader.rs @@ -665,6 +665,53 @@ mod tests { assert_eq!(plan.keep.len(), default_specs().len()); } + /// The planning tests above substitute [`resolve_known_actions`] for + /// `App::build_action`, so they would still pass if a generated action name + /// did not match what gpui actually registered — and every binding would + /// then fail to load at runtime, leaving the app with no shortcuts and a + /// toast per command. This drives the real resolver instead. + #[gpui::test] + fn the_defaults_load_against_the_real_action_registry(cx: &mut gpui::TestAppContext) { + let loaded = cx.update(|cx| load_from_content("", cx)); + + assert!( + loaded.problems.is_empty(), + "the shipped defaults do not load cleanly: {:?}", + loaded.problems + ); + assert_eq!( + loaded.bindings.len(), + default_specs().len(), + "some default bindings were dropped on the way to gpui" + ); + } + + /// A user binding naming an action gpui does not know must be reported and + /// skipped, not silently dropped along with the rest of the file. + #[gpui::test] + fn an_unknown_user_action_is_reported_by_the_real_resolver(cx: &mut gpui::TestAppContext) { + let loaded = cx.update(|cx| { + load_from_content( + r#"[{ "context": "Workspace", "bindings": { + "ctrl-alt-1": "rgitui::NoSuchCommand" + }}]"#, + cx, + ) + }); + + assert_eq!(loaded.problems.len(), 1, "{:?}", loaded.problems); + assert!( + loaded.problems[0].contains("rgitui::NoSuchCommand"), + "{:?}", + loaded.problems + ); + assert_eq!( + loaded.bindings.len(), + default_specs().len(), + "the defaults must survive one bad user binding" + ); + } + #[test] fn an_unknown_action_name_is_reported_and_the_load_continues() { let plan = plan( diff --git a/crates/rgitui_workspace/src/keymap/mod.rs b/crates/rgitui_workspace/src/keymap/mod.rs index 3c3de49..9e5f299 100644 --- a/crates/rgitui_workspace/src/keymap/mod.rs +++ b/crates/rgitui_workspace/src/keymap/mod.rs @@ -100,6 +100,17 @@ impl KeymapState { pub fn take_problems(&mut self) -> Vec { std::mem::take(&mut self.problems) } + + /// Whether there is anything left to report. + /// + /// The workspace observes this global and reports problems by *taking* them, + /// but taking goes through `update_global`, which notifies global observers + /// again. Checking this first — and doing nothing when it is false — is what + /// stops that from being an endless cycle, so + /// `take_problems()` must always leave it `false`. + pub fn has_problems(&self) -> bool { + !self.problems.is_empty() + } } /// The bindings in force, for rendering a shortcut anywhere in the UI. @@ -138,8 +149,9 @@ pub fn command_tooltip( /// Keeps the `keymap.json` watcher alive for the lifetime of the app. struct KeymapWatcher { - #[allow(dead_code, reason = "dropping the watcher stops the notifications")] - watcher: Box, + /// Never read: the watcher stops delivering notifications when dropped, so + /// this field exists purely to keep it alive as long as the app is. + _watcher: Box, } impl Global for KeymapWatcher {} @@ -226,7 +238,7 @@ fn watch_keymap_file(cx: &mut App) { } cx.set_global(KeymapWatcher { - watcher: Box::new(watcher), + _watcher: Box::new(watcher), }); cx.spawn(async move |cx: &mut gpui::AsyncApp| { @@ -239,3 +251,43 @@ fn watch_keymap_file(cx: &mut App) { }) .detach(); } + +#[cfg(test)] +mod tests { + use super::*; + + fn state_with_problems(problems: &[&str]) -> KeymapState { + KeymapState { + problems: problems.iter().map(|p| (*p).to_string()).collect(), + binding_count: 0, + generation: 1, + summary: Arc::new(KeymapSummary::default()), + } + } + + /// The workspace reports keymap problems from inside a global observer, and + /// reporting them goes through `update_global`, which notifies that same + /// observer again. Convergence rests entirely on taking the problems making + /// `has_problems` false, so the next pass returns without touching the + /// global. If this ever stops holding, the app spins at 100% CPU on startup + /// and on every keymap.json save. + #[test] + fn taking_the_problems_leaves_nothing_to_report() { + let mut state = state_with_problems(&["bad binding", "unknown action"]); + assert!(state.has_problems()); + + let taken = state.take_problems(); + + assert_eq!(taken.len(), 2); + assert!( + !state.has_problems(), + "take_problems must drain the state, or the observer cycle never ends" + ); + assert!(state.take_problems().is_empty()); + } + + #[test] + fn a_clean_load_has_nothing_to_report() { + assert!(!state_with_problems(&[]).has_problems()); + } +} diff --git a/crates/rgitui_workspace/src/keymap/registry.rs b/crates/rgitui_workspace/src/keymap/registry.rs index e18623b..a51ac2e 100644 --- a/crates/rgitui_workspace/src/keymap/registry.rs +++ b/crates/rgitui_workspace/src/keymap/registry.rs @@ -138,8 +138,10 @@ commands! { Settings "secondary-," in "Workspace"; /// Open the repository picker. OpenRepo "secondary-o" in "Workspace"; + // Control rather than the primary modifier: macOS reserves Cmd+H for + // Hide Application, so a `secondary-` binding would never fire there. /// Close every tab and return to the workspace home screen. - WorkspaceHome "secondary-h"; + WorkspaceHome "ctrl-h"; /// Reopen the most recently saved workspace. RestoreLastWorkspace unbound; /// Show the keyboard shortcut reference. @@ -184,10 +186,13 @@ commands! { OpenThemeEditor ["secondary-shift-t", "alt-9"] in "Workspace"; /// Toggle the command palette. CommandPalette "secondary-shift-p" in "Workspace" [hidden]; + // Control rather than the primary modifier: macOS reserves Cmd+Tab for + // the application switcher, and the WindowServer swallows it before the + // app sees it. Ctrl+Tab is also the native chord there. /// Activate the next repository tab. - NextTab "secondary-tab" [hidden]; + NextTab "ctrl-tab" [hidden]; /// Activate the previous repository tab. - PrevTab "secondary-shift-tab" [hidden]; + PrevTab "ctrl-shift-tab" [hidden]; /// Close the active repository tab. CloseTab "secondary-w" [hidden]; /// Move keyboard focus to the sidebar. @@ -232,15 +237,15 @@ commands! { SelectLast ["end", "shift-g" in "List && !TextInput"] [hidden]; } - view GraphView in graph context "GraphView && !TextInput" { + view GraphView in graph context "GraphView && !modal && !TextInput" { /// Select the next commit in the graph. - GraphSelectNext ["down" in "GraphView", "j"] [hidden]; + GraphSelectNext ["down" in "GraphView && !modal", "j"] [hidden]; /// Select the previous commit in the graph. - GraphSelectPrev ["up" in "GraphView", "k"] [hidden]; + GraphSelectPrev ["up" in "GraphView && !modal", "k"] [hidden]; /// Select the newest commit in the graph. - GraphSelectFirst ["home" in "GraphView", "g"] [hidden]; + GraphSelectFirst ["home" in "GraphView && !modal", "g"] [hidden]; /// Select the oldest loaded commit in the graph. - GraphSelectLast ["end" in "GraphView", "shift-g"] [hidden]; + GraphSelectLast ["end" in "GraphView && !modal", "shift-g"] [hidden]; /// Add the next commit in the graph to the selection. GraphExtendSelectionNext ["shift-down", "shift-j"] [hidden]; /// Add the previous commit in the graph to the selection. @@ -252,7 +257,7 @@ commands! { /// Squash the selected commits into the oldest of them. SquashSelected "s" if has_multi_commit_selection [hidden]; /// Close the graph search, or dismiss the graph context menu. - GraphCancel "escape" in "GraphView" [hidden]; + GraphCancel "escape" in "GraphView && !modal" [hidden]; /// Copy the selected commit's SHA to the clipboard. CopyCommitSha "y" [hidden]; /// Copy the selected commit's message to the clipboard. @@ -262,7 +267,7 @@ commands! { // The diff viewer hosts no text field, but the bare letters below still carry // `!TextInput` so that `bare_character_bindings_stand_down_for_text_input` // holds for every binding in the registry without exceptions. - view DiffViewer in diff context "DiffViewer && !TextInput" { + view DiffViewer in diff context "DiffViewer && !modal && !TextInput" { /// Move the diff cursor down one row. DiffSelectNext ["down", "j"] [hidden]; /// Move the diff cursor up one row. @@ -293,7 +298,7 @@ commands! { SelectAllDiffLines "secondary-a" [hidden]; } - view DetailPanel in detail context "DetailPanel && !TextInput" { + view DetailPanel in detail context "DetailPanel && !modal && !TextInput" { /// Switch the changed-files list between the flat and tree layouts. ToggleFileTree "v" [hidden]; /// Show the previous commit's details. @@ -301,28 +306,28 @@ commands! { /// Show the next commit's details. NextCommitDetails "]" [hidden]; /// Filter the changed-files list. - FileSearch ["/", "secondary-f" in "DetailPanel"] [hidden]; + FileSearch ["/", "secondary-f" in "DetailPanel && !modal"] [hidden]; } - view Sidebar in sidebar context "Sidebar && !TextInput" { + view Sidebar in sidebar context "Sidebar && !modal && !TextInput" { /// Stage or unstage the selected file. ToggleStageRow "s" [hidden]; /// Discard the selected change, or delete the selected branch, tag or stash. - DiscardRow ["x", "delete" in "Sidebar"] [hidden]; + DiscardRow ["x", "delete" in "Sidebar && !modal"] [hidden]; /// Filter the branch list. - FilterBranches ["/", "secondary-f" in "Sidebar"] [hidden]; + FilterBranches ["/", "secondary-f" in "Sidebar && !modal"] [hidden]; } - view BlameView in blame context "BlameView && !TextInput" { + view BlameView in blame context "BlameView && !modal && !TextInput" { /// Leave the blame view and go back to the diff. - BlameShowDiff ["escape" in "BlameView", "d"] [hidden]; + BlameShowDiff ["escape" in "BlameView && !modal", "d"] [hidden]; /// Show the blamed file's commit history. BlameShowHistory "h" [hidden]; } - view FileHistoryView in history context "FileHistoryView && !TextInput" { + view FileHistoryView in history context "FileHistoryView && !modal && !TextInput" { /// Leave the file history and go back to the diff. - HistoryShowDiff ["escape" in "FileHistoryView", "d"] [hidden]; + HistoryShowDiff ["escape" in "FileHistoryView && !modal", "d"] [hidden]; /// Blame the file whose history is shown. HistoryShowBlame "b" [hidden]; } @@ -995,11 +1000,14 @@ mod tests { fn a_view_context_is_inherited_unless_overridden() { assert_eq!( CommandId::BlameShowDiff.default_bindings(), - &[("escape", "BlameView"), ("d", "BlameView && !TextInput")] + &[ + ("escape", "BlameView && !modal"), + ("d", "BlameView && !modal && !TextInput") + ] ); assert_eq!( CommandId::BlameShowHistory.default_bindings(), - &[("h", "BlameView && !TextInput")] + &[("h", "BlameView && !modal && !TextInput")] ); } diff --git a/crates/rgitui_workspace/src/keymap/shadow.rs b/crates/rgitui_workspace/src/keymap/shadow.rs index a9aeeca..c2d786a 100644 --- a/crates/rgitui_workspace/src/keymap/shadow.rs +++ b/crates/rgitui_workspace/src/keymap/shadow.rs @@ -401,7 +401,7 @@ mod tests { .iter() .map(|spec| (spec.keystrokes.as_str(), spec.context.as_deref())) .collect::>(), - [("s", Some("GraphView && !TextInput"))] + [("s", Some("GraphView && !modal && !TextInput"))] ); for shadow in detect_shadowing(&specs) { diff --git a/crates/rgitui_workspace/src/keymap/summary.rs b/crates/rgitui_workspace/src/keymap/summary.rs index 25a31c9..183edcc 100644 --- a/crates/rgitui_workspace/src/keymap/summary.rs +++ b/crates/rgitui_workspace/src/keymap/summary.rs @@ -720,7 +720,8 @@ mod tests { .find(|group| group.view == "DiffViewer") .expect("the diff viewer is a group"); assert!( - diff.description().contains("DiffViewer && !TextInput"), + diff.description() + .contains("DiffViewer && !modal && !TextInput"), "{}", diff.description() ); diff --git a/crates/rgitui_workspace/src/workspace/mod.rs b/crates/rgitui_workspace/src/workspace/mod.rs index 864395b..cde6eb9 100644 --- a/crates/rgitui_workspace/src/workspace/mod.rs +++ b/crates/rgitui_workspace/src/workspace/mod.rs @@ -651,7 +651,15 @@ impl Workspace { /// Drains the problems so each is shown once, whether it came from the /// startup load or from a later reload triggered by saving the file. pub fn show_keymap_problems(&mut self, cx: &mut Context) { - if cx.try_global::().is_none() { + // Read before writing. This runs as a `KeymapState` global observer, and + // `update_global` notifies global observers on release — so taking the + // problems unconditionally re-enters this method forever, pinning the UI + // thread at 100% CPU on startup and on every keymap.json save. Bailing + // out when there is nothing left to report ends the cycle after one pass. + let has_problems = cx + .try_global::() + .is_some_and(crate::keymap::KeymapState::has_problems); + if !has_problems { return; } let problems = diff --git a/crates/rgitui_workspace/src/worktree_dialog.rs b/crates/rgitui_workspace/src/worktree_dialog.rs index 1f4c8d1..a6ff6a4 100644 --- a/crates/rgitui_workspace/src/worktree_dialog.rs +++ b/crates/rgitui_workspace/src/worktree_dialog.rs @@ -27,6 +27,11 @@ pub struct WorktreeDialog { branch_editor: Entity, error_message: Option, visible: bool, + /// Set when the dialog is opened without a `Window`, so the next render can + /// take focus. Without it the dialog never enters the focus path, and gpui + /// dispatches actions — including `menu::Cancel` — only along that path, so + /// Esc would not dismiss it. + pending_focus: bool, focus_handle: FocusHandle, } @@ -88,6 +93,7 @@ impl WorktreeDialog { branch_editor, error_message: None, visible: false, + pending_focus: false, focus_handle, } } @@ -106,9 +112,10 @@ impl WorktreeDialog { cx.notify(); } - /// Show the dialog without focusing. + /// Show the dialog, taking focus on the next render. pub fn show_visible(&mut self, branch: Option, cx: &mut Context) { self.visible = true; + self.pending_focus = true; self.name_editor.update(cx, |e, cx| e.clear(cx)); self.path_editor.update(cx, |e, cx| e.clear(cx)); self.branch_editor.update(cx, |e, cx| e.clear(cx)); @@ -202,11 +209,16 @@ impl WorktreeDialog { } impl Render for WorktreeDialog { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { if !self.visible { return div().id("worktree-dialog").into_any_element(); } + if self.pending_focus { + self.pending_focus = false; + self.name_editor.update(cx, |e, cx| e.focus(window, cx)); + } + let colors = cx.colors().clone(); let name_text = self.name_editor.read(cx).text().to_string(); let path_text = self.path_editor.read(cx).text().to_string(); diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index afe3bcc..71a2389 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -74,7 +74,7 @@ The file is reloaded when you save it. Bindings you add win over the defaults. T | `f5` | `Workspace && !modal` | `rgitui::Refresh` | Reload the repository state from disk. | | `secondary-,` | `Workspace` | `rgitui::Settings` | Open the settings window. | | `secondary-o` | `Workspace` | `rgitui::OpenRepo` | Open the repository picker. | -| `secondary-h` | `Workspace && !modal` | `rgitui::WorkspaceHome` | Close every tab and return to the workspace home screen. | +| `ctrl-h` | `Workspace && !modal` | `rgitui::WorkspaceHome` | Close every tab and return to the workspace home screen. | | _unbound_ | — | `rgitui::RestoreLastWorkspace` | Reopen the most recently saved workspace. | | `?` | `Workspace && !TextInput` | `rgitui::Shortcuts` | Show the keyboard shortcut reference. | | `secondary-shift-b` | `Workspace && !modal` | `rgitui::SwitchBranch` | Focus the sidebar to switch branches. | @@ -97,8 +97,8 @@ The file is reloaded when you save it. Bindings you add win over the defaults. T | _unbound_ | — | `rgitui::StashBranch` | Create a branch from a stash entry. | | `secondary-shift-t` or `alt-9` | `Workspace` | `rgitui::OpenThemeEditor` | Open the theme editor. | | `secondary-shift-p` | `Workspace` | `rgitui::CommandPalette` | Toggle the command palette. | -| `secondary-tab` | `Workspace && !modal` | `rgitui::NextTab` | Activate the next repository tab. | -| `secondary-shift-tab` | `Workspace && !modal` | `rgitui::PrevTab` | Activate the previous repository tab. | +| `ctrl-tab` | `Workspace && !modal` | `rgitui::NextTab` | Activate the next repository tab. | +| `ctrl-shift-tab` | `Workspace && !modal` | `rgitui::PrevTab` | Activate the previous repository tab. | | `secondary-w` | `Workspace && !modal` | `rgitui::CloseTab` | Close the active repository tab. | | `alt-1` | `Workspace && !modal` | `rgitui::FocusSidebar` | Move keyboard focus to the sidebar. | | `alt-2` | `Workspace && !modal` | `rgitui::FocusGraph` | Move keyboard focus to the commit graph. | @@ -127,66 +127,66 @@ The file is reloaded when you save it. Bindings you add win over the defaults. T | Keystroke | Context | Action | Description | | --- | --- | --- | --- | -| `down` or `j` | `GraphView` or `GraphView && !TextInput` | `graph::GraphSelectNext` | Select the next commit in the graph. | -| `up` or `k` | `GraphView` or `GraphView && !TextInput` | `graph::GraphSelectPrev` | Select the previous commit in the graph. | -| `home` or `g` | `GraphView` or `GraphView && !TextInput` | `graph::GraphSelectFirst` | Select the newest commit in the graph. | -| `end` or `shift-g` | `GraphView` or `GraphView && !TextInput` | `graph::GraphSelectLast` | Select the oldest loaded commit in the graph. | -| `shift-down` or `shift-j` | `GraphView && !TextInput` | `graph::GraphExtendSelectionNext` | Add the next commit in the graph to the selection. | -| `shift-up` or `shift-k` | `GraphView && !TextInput` | `graph::GraphExtendSelectionPrev` | Add the previous commit in the graph to the selection. | -| `s` | `GraphView && !TextInput` | `graph::SquashSelected` | Squash the selected commits into the oldest of them. | -| `escape` | `GraphView` | `graph::GraphCancel` | Close the graph search, or dismiss the graph context menu. | -| `y` | `GraphView && !TextInput` | `graph::CopyCommitSha` | Copy the selected commit's SHA to the clipboard. | -| `shift-c` | `GraphView && !TextInput` | `graph::CopyCommitMessage` | Copy the selected commit's message to the clipboard. | +| `down` or `j` | `GraphView && !modal` or `GraphView && !modal && !TextInput` | `graph::GraphSelectNext` | Select the next commit in the graph. | +| `up` or `k` | `GraphView && !modal` or `GraphView && !modal && !TextInput` | `graph::GraphSelectPrev` | Select the previous commit in the graph. | +| `home` or `g` | `GraphView && !modal` or `GraphView && !modal && !TextInput` | `graph::GraphSelectFirst` | Select the newest commit in the graph. | +| `end` or `shift-g` | `GraphView && !modal` or `GraphView && !modal && !TextInput` | `graph::GraphSelectLast` | Select the oldest loaded commit in the graph. | +| `shift-down` or `shift-j` | `GraphView && !modal && !TextInput` | `graph::GraphExtendSelectionNext` | Add the next commit in the graph to the selection. | +| `shift-up` or `shift-k` | `GraphView && !modal && !TextInput` | `graph::GraphExtendSelectionPrev` | Add the previous commit in the graph to the selection. | +| `s` | `GraphView && !modal && !TextInput` | `graph::SquashSelected` | Squash the selected commits into the oldest of them. | +| `escape` | `GraphView && !modal` | `graph::GraphCancel` | Close the graph search, or dismiss the graph context menu. | +| `y` | `GraphView && !modal && !TextInput` | `graph::CopyCommitSha` | Copy the selected commit's SHA to the clipboard. | +| `shift-c` | `GraphView && !modal && !TextInput` | `graph::CopyCommitMessage` | Copy the selected commit's message to the clipboard. | ## DiffViewer | Keystroke | Context | Action | Description | | --- | --- | --- | --- | -| `down` or `j` | `DiffViewer && !TextInput` | `diff::DiffSelectNext` | Move the diff cursor down one row. | -| `up` or `k` | `DiffViewer && !TextInput` | `diff::DiffSelectPrev` | Move the diff cursor up one row. | -| `home` or `g` | `DiffViewer && !TextInput` | `diff::DiffSelectFirst` | Move the diff cursor to the first row. | -| `end` or `shift-g` | `DiffViewer && !TextInput` | `diff::DiffSelectLast` | Move the diff cursor to the last row. | -| `]` | `DiffViewer && !TextInput` | `diff::NextHunk` | Jump to the next hunk. | -| `[` | `DiffViewer && !TextInput` | `diff::PrevHunk` | Jump to the previous hunk. | -| `d` | `DiffViewer && !TextInput` | `diff::ToggleDiffDisplayMode` | Cycle the diff viewer's display mode. | -| `p` | `DiffViewer && !TextInput` | `diff::TogglePartialSelection` | Toggle line-level selection in the diff viewer. | -| `s` or `shift-s` | `DiffViewer && !TextInput` | `diff::StageSelection` | Stage the hunks or lines under the diff selection. | -| `u` or `shift-u` | `DiffViewer && !TextInput` | `diff::UnstageSelection` | Unstage the hunks or lines under the diff selection. | -| `alt-s` | `DiffViewer && !TextInput` | `diff::StageCurrentHunk` | Stage the hunk under the diff cursor. | -| `alt-u` | `DiffViewer && !TextInput` | `diff::UnstageCurrentHunk` | Unstage the hunk under the diff cursor. | -| `secondary-c` | `DiffViewer && !TextInput` | `diff::CopyDiffSelection` | Copy the selected diff lines to the clipboard. | -| `secondary-a` | `DiffViewer && !TextInput` | `diff::SelectAllDiffLines` | Select every line in the diff. | +| `down` or `j` | `DiffViewer && !modal && !TextInput` | `diff::DiffSelectNext` | Move the diff cursor down one row. | +| `up` or `k` | `DiffViewer && !modal && !TextInput` | `diff::DiffSelectPrev` | Move the diff cursor up one row. | +| `home` or `g` | `DiffViewer && !modal && !TextInput` | `diff::DiffSelectFirst` | Move the diff cursor to the first row. | +| `end` or `shift-g` | `DiffViewer && !modal && !TextInput` | `diff::DiffSelectLast` | Move the diff cursor to the last row. | +| `]` | `DiffViewer && !modal && !TextInput` | `diff::NextHunk` | Jump to the next hunk. | +| `[` | `DiffViewer && !modal && !TextInput` | `diff::PrevHunk` | Jump to the previous hunk. | +| `d` | `DiffViewer && !modal && !TextInput` | `diff::ToggleDiffDisplayMode` | Cycle the diff viewer's display mode. | +| `p` | `DiffViewer && !modal && !TextInput` | `diff::TogglePartialSelection` | Toggle line-level selection in the diff viewer. | +| `s` or `shift-s` | `DiffViewer && !modal && !TextInput` | `diff::StageSelection` | Stage the hunks or lines under the diff selection. | +| `u` or `shift-u` | `DiffViewer && !modal && !TextInput` | `diff::UnstageSelection` | Unstage the hunks or lines under the diff selection. | +| `alt-s` | `DiffViewer && !modal && !TextInput` | `diff::StageCurrentHunk` | Stage the hunk under the diff cursor. | +| `alt-u` | `DiffViewer && !modal && !TextInput` | `diff::UnstageCurrentHunk` | Unstage the hunk under the diff cursor. | +| `secondary-c` | `DiffViewer && !modal && !TextInput` | `diff::CopyDiffSelection` | Copy the selected diff lines to the clipboard. | +| `secondary-a` | `DiffViewer && !modal && !TextInput` | `diff::SelectAllDiffLines` | Select every line in the diff. | ## DetailPanel | Keystroke | Context | Action | Description | | --- | --- | --- | --- | -| `v` | `DetailPanel && !TextInput` | `detail::ToggleFileTree` | Switch the changed-files list between the flat and tree layouts. | -| `[` | `DetailPanel && !TextInput` | `detail::PrevCommitDetails` | Show the previous commit's details. | -| `]` | `DetailPanel && !TextInput` | `detail::NextCommitDetails` | Show the next commit's details. | -| `/` or `secondary-f` | `DetailPanel && !TextInput` or `DetailPanel` | `detail::FileSearch` | Filter the changed-files list. | +| `v` | `DetailPanel && !modal && !TextInput` | `detail::ToggleFileTree` | Switch the changed-files list between the flat and tree layouts. | +| `[` | `DetailPanel && !modal && !TextInput` | `detail::PrevCommitDetails` | Show the previous commit's details. | +| `]` | `DetailPanel && !modal && !TextInput` | `detail::NextCommitDetails` | Show the next commit's details. | +| `/` or `secondary-f` | `DetailPanel && !modal && !TextInput` or `DetailPanel && !modal` | `detail::FileSearch` | Filter the changed-files list. | ## Sidebar | Keystroke | Context | Action | Description | | --- | --- | --- | --- | -| `s` | `Sidebar && !TextInput` | `sidebar::ToggleStageRow` | Stage or unstage the selected file. | -| `x` or `delete` | `Sidebar && !TextInput` or `Sidebar` | `sidebar::DiscardRow` | Discard the selected change, or delete the selected branch, tag or stash. | -| `/` or `secondary-f` | `Sidebar && !TextInput` or `Sidebar` | `sidebar::FilterBranches` | Filter the branch list. | +| `s` | `Sidebar && !modal && !TextInput` | `sidebar::ToggleStageRow` | Stage or unstage the selected file. | +| `x` or `delete` | `Sidebar && !modal && !TextInput` or `Sidebar && !modal` | `sidebar::DiscardRow` | Discard the selected change, or delete the selected branch, tag or stash. | +| `/` or `secondary-f` | `Sidebar && !modal && !TextInput` or `Sidebar && !modal` | `sidebar::FilterBranches` | Filter the branch list. | ## BlameView | Keystroke | Context | Action | Description | | --- | --- | --- | --- | -| `escape` or `d` | `BlameView` or `BlameView && !TextInput` | `blame::BlameShowDiff` | Leave the blame view and go back to the diff. | -| `h` | `BlameView && !TextInput` | `blame::BlameShowHistory` | Show the blamed file's commit history. | +| `escape` or `d` | `BlameView && !modal` or `BlameView && !modal && !TextInput` | `blame::BlameShowDiff` | Leave the blame view and go back to the diff. | +| `h` | `BlameView && !modal && !TextInput` | `blame::BlameShowHistory` | Show the blamed file's commit history. | ## FileHistoryView | Keystroke | Context | Action | Description | | --- | --- | --- | --- | -| `escape` or `d` | `FileHistoryView` or `FileHistoryView && !TextInput` | `history::HistoryShowDiff` | Leave the file history and go back to the diff. | -| `b` | `FileHistoryView && !TextInput` | `history::HistoryShowBlame` | Blame the file whose history is shown. | +| `escape` or `d` | `FileHistoryView && !modal` or `FileHistoryView && !modal && !TextInput` | `history::HistoryShowDiff` | Leave the file history and go back to the diff. | +| `b` | `FileHistoryView && !modal && !TextInput` | `history::HistoryShowBlame` | Blame the file whose history is shown. | ## InteractiveRebase