Git keyboard shortcuts (#26374)

Conrad Irwin created

Closes #26040

Release Notes:

- git: Add keyboard shortcuts (when the panel is open) for fetch `ctrl-g
ctrl-g`, pull `ctrl-g down`, push `ctrl-g up`, force-push `ctrl-g
shift-up`, open diff `ctrl-g d`

Change summary

assets/keymaps/default-linux.json | 27 +++++++++-
assets/keymaps/default-macos.json | 34 +++++++++----
crates/git_ui/src/git_panel.rs    | 84 +++++++++++++++++++++++++-------
crates/git_ui/src/git_ui.rs       | 82 +++++++++++++++++++------------
4 files changed, 160 insertions(+), 67 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -731,14 +731,16 @@
       "up": "menu::SelectPrevious",
       "down": "menu::SelectNext",
       "enter": "menu::Confirm",
+      "alt-y": "git::StageFile",
+      "alt-shift-y": "git::UnstageFile",
+      "ctrl-alt-y": "git::ToggleStaged",
       "space": "git::ToggleStaged",
-      "ctrl-space": "git::StageAll",
-      "ctrl-shift-space": "git::UnstageAll",
       "tab": "git_panel::FocusEditor",
       "shift-tab": "git_panel::FocusEditor",
       "escape": "git_panel::ToggleFocus",
       "ctrl-enter": "git::Commit",
-      "alt-enter": "menu::SecondaryConfirm"
+      "alt-enter": "menu::SecondaryConfirm",
+      "backspace": "git::RestoreFile"
     }
   },
   {
@@ -749,10 +751,27 @@
       "alt-l": "git::GenerateCommitMessage"
     }
   },
+  {
+    "context": "GitPanel",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-g ctrl-g": "git::Fetch",
+      "ctrl-g up": "git::Push",
+      "ctrl-g down": "git::Pull",
+      "ctrl-g shift-up": "git::ForcePush",
+      "ctrl-g d": "git::Diff",
+      "ctrl-g backspace": "git::RestoreTrackedFiles",
+      "ctrl-g shift-backspace": "git::TrashUntrackedFiles",
+      "ctrl-space": "git::StageAll",
+      "ctrl-shift-space": "git::UnstageAll"
+    }
+  },
   {
     "context": "GitDiff > Editor",
     "bindings": {
-      "ctrl-enter": "git::Commit"
+      "ctrl-enter": "git::Commit",
+      "ctrl-space": "git::StageAll",
+      "ctrl-shift-space": "git::UnstageAll"
     }
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -763,28 +763,25 @@
       "cmd-up": "menu::SelectFirst",
       "cmd-down": "menu::SelectLast",
       "enter": "menu::Confirm",
+      "cmd-alt-y": "git::ToggleStaged",
       "space": "git::ToggleStaged",
-      "cmd-shift-space": "git::StageAll",
-      "ctrl-shift-space": "git::UnstageAll",
+      "cmd-y": "git::StageFile",
+      "cmd-shift-y": "git::UnstageFile",
       "alt-down": "git_panel::FocusEditor",
       "tab": "git_panel::FocusEditor",
       "shift-tab": "git_panel::FocusEditor",
       "escape": "git_panel::ToggleFocus",
-      "cmd-enter": "git::Commit"
+      "cmd-enter": "git::Commit",
+      "backspace": "git::RestoreFile"
     }
   },
   {
     "context": "GitDiff > Editor",
     "use_key_equivalents": true,
     "bindings": {
-      "cmd-enter": "git::Commit"
-    }
-  },
-  {
-    "context": "AskPass > Editor",
-    "use_key_equivalents": true,
-    "bindings": {
-      "enter": "menu::Confirm"
+      "cmd-enter": "git::Commit",
+      "cmd-ctrl-y": "git::StageAll",
+      "cmd-ctrl-shift-y": "git::UnstageAll"
     }
   },
   {
@@ -800,6 +797,21 @@
       "alt-tab": "git::GenerateCommitMessage"
     }
   },
+  {
+    "context": "GitPanel",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-g ctrl-g": "git::Fetch",
+      "ctrl-g up": "git::Push",
+      "ctrl-g down": "git::Pull",
+      "ctrl-g shift-up": "git::ForcePush",
+      "ctrl-g d": "git::Diff",
+      "ctrl-g backspace": "git::RestoreTrackedFiles",
+      "ctrl-g shift-backspace": "git::TrashUntrackedFiles",
+      "cmd-ctrl-y": "git::StageAll",
+      "cmd-ctrl-shift-y": "git::UnstageAll"
+    }
+  },
   {
     "context": "GitCommit > Editor",
     "use_key_equivalents": true,

crates/git_ui/src/git_panel.rs 🔗

@@ -102,9 +102,14 @@ enum TrashCancel {
     Cancel,
 }
 
-fn git_panel_context_menu(window: &mut Window, cx: &mut App) -> Entity<ContextMenu> {
+fn git_panel_context_menu(
+    focus_handle: Option<FocusHandle>,
+    window: &mut Window,
+    cx: &mut App,
+) -> Entity<ContextMenu> {
     ContextMenu::build(window, cx, |context_menu, _, _| {
         context_menu
+            .when_some(focus_handle, |el, focus_handle| el.context(focus_handle))
             .action("Stage All", StageAll.boxed_clone())
             .action("Unstage All", UnstageAll.boxed_clone())
             .separator()
@@ -1232,6 +1237,35 @@ impl GitPanel {
         }
     }
 
+    fn stage_selected(&mut self, _: &git::StageFile, _window: &mut Window, cx: &mut Context<Self>) {
+        let Some(selected_entry) = self.get_selected_entry() else {
+            return;
+        };
+        let Some(status_entry) = selected_entry.status_entry() else {
+            return;
+        };
+        if status_entry.staging != StageStatus::Staged {
+            self.change_file_stage(true, vec![status_entry.clone()], cx);
+        }
+    }
+
+    fn unstage_selected(
+        &mut self,
+        _: &git::UnstageFile,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(selected_entry) = self.get_selected_entry() else {
+            return;
+        };
+        let Some(status_entry) = selected_entry.status_entry() else {
+            return;
+        };
+        if status_entry.staging != StageStatus::Unstaged {
+            self.change_file_stage(false, vec![status_entry.clone()], cx);
+        }
+    }
+
     fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
         if self
             .commit_editor
@@ -2425,8 +2459,12 @@ impl GitPanel {
 
         self.panel_header_container(window, cx)
             .child(
-                Button::new("diff", "Open diff")
-                    .tooltip(Tooltip::for_action_title("Open diff", &Diff))
+                Button::new("diff", "Open Diff")
+                    .tooltip(Tooltip::for_action_title_in(
+                        "Open diff",
+                        &Diff,
+                        &self.focus_handle,
+                    ))
                     .on_click(|_, _, cx| {
                         cx.defer(|cx| {
                             cx.dispatch_action(&Diff);
@@ -2436,7 +2474,11 @@ impl GitPanel {
             .child(div().flex_grow()) // spacer
             .child(
                 Button::new("stage-unstage-all", text)
-                    .tooltip(Tooltip::for_action_title(tooltip, action.as_ref()))
+                    .tooltip(Tooltip::for_action_title_in(
+                        tooltip,
+                        action.as_ref(),
+                        &self.focus_handle,
+                    ))
                     .on_click(move |_, _, cx| {
                         let action = action.boxed_clone();
                         cx.defer(move |cx| {
@@ -2886,6 +2928,7 @@ impl GitPanel {
         };
         let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
             context_menu
+                .context(self.focus_handle.clone())
                 .action(stage_title, ToggleStaged.boxed_clone())
                 .action(restore_title, git::RestoreFile.boxed_clone())
                 .separator()
@@ -2902,7 +2945,7 @@ impl GitPanel {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let context_menu = git_panel_context_menu(window, cx);
+        let context_menu = git_panel_context_menu(Some(self.focus_handle.clone()), window, cx);
         self.set_context_menu(context_menu, position, window, cx);
     }
 
@@ -3169,10 +3212,16 @@ impl Render for GitPanel {
             .track_focus(&self.focus_handle)
             .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
             .when(has_write_access && !project.is_read_only(cx), |this| {
-                this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
-                    this.toggle_staged_for_selected(&ToggleStaged, window, cx)
-                }))
-                .on_action(cx.listener(GitPanel::commit))
+                this.on_action(cx.listener(Self::toggle_staged_for_selected))
+                    .on_action(cx.listener(GitPanel::commit))
+                    .on_action(cx.listener(Self::stage_all))
+                    .on_action(cx.listener(Self::unstage_all))
+                    .on_action(cx.listener(Self::stage_selected))
+                    .on_action(cx.listener(Self::unstage_selected))
+                    .on_action(cx.listener(Self::restore_tracked_files))
+                    .on_action(cx.listener(Self::revert_selected))
+                    .on_action(cx.listener(Self::clean_all))
+                    .on_action(cx.listener(Self::generate_commit_message_action))
             })
             .on_action(cx.listener(Self::select_first))
             .on_action(cx.listener(Self::select_next))
@@ -3181,16 +3230,9 @@ impl Render for GitPanel {
             .on_action(cx.listener(Self::close_panel))
             .on_action(cx.listener(Self::open_diff))
             .on_action(cx.listener(Self::open_file))
-            .on_action(cx.listener(Self::revert_selected))
             .on_action(cx.listener(Self::focus_changes_list))
             .on_action(cx.listener(Self::focus_editor))
-            .on_action(cx.listener(Self::toggle_staged_for_selected))
-            .on_action(cx.listener(Self::stage_all))
-            .on_action(cx.listener(Self::unstage_all))
-            .on_action(cx.listener(Self::restore_tracked_files))
-            .on_action(cx.listener(Self::clean_all))
             .on_action(cx.listener(Self::expand_commit_editor))
-            .on_action(cx.listener(Self::generate_commit_message_action))
             .when(has_write_access && has_co_authors, |git_panel| {
                 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
             })
@@ -3414,14 +3456,18 @@ impl PanelRepoFooter {
         }
     }
 
-    fn render_overflow_menu(&self, id: impl Into<ElementId>) -> impl IntoElement {
+    fn render_overflow_menu(&self, id: impl Into<ElementId>, cx: &App) -> impl IntoElement {
+        let focus_handle = self
+            .git_panel
+            .as_ref()
+            .map(|git_panel| git_panel.focus_handle(cx));
         PopoverMenu::new(id.into())
             .trigger(
                 IconButton::new("overflow-menu-trigger", IconName::EllipsisVertical)
                     .icon_size(IconSize::Small)
                     .icon_color(Color::Muted),
             )
-            .menu(move |window, cx| Some(git_panel_context_menu(window, cx)))
+            .menu(move |window, cx| Some(git_panel_context_menu(focus_handle.clone(), window, cx)))
             .anchor(Corner::TopRight)
     }
 }
@@ -3537,7 +3583,7 @@ impl RenderOnce for PanelRepoFooter {
                     .gap_1()
                     .flex_shrink_0()
                     .children(spinner)
-                    .child(self.render_overflow_menu(overflow_menu_id))
+                    .child(self.render_overflow_menu(overflow_menu_id, cx))
                     .when_some(branch, |this, branch| {
                         let mut focus_handle = None;
                         if let Some(git_panel) = self.git_panel.as_ref() {

crates/git_ui/src/git_ui.rs 🔗

@@ -29,41 +29,43 @@ pub fn init(cx: &mut App) {
 
     cx.observe_new(|workspace: &mut Workspace, _, cx| {
         let project = workspace.project().read(cx);
-        if project.is_via_collab() {
+        if project.is_read_only(cx) {
             return;
         }
-        workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
-            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
-                return;
-            };
-            panel.update(cx, |panel, cx| {
-                panel.fetch(window, cx);
+        if !project.is_via_collab() {
+            workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
+                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+                    return;
+                };
+                panel.update(cx, |panel, cx| {
+                    panel.fetch(window, cx);
+                });
             });
-        });
-        workspace.register_action(|workspace, _: &git::Push, window, cx| {
-            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
-                return;
-            };
-            panel.update(cx, |panel, cx| {
-                panel.push(false, window, cx);
+            workspace.register_action(|workspace, _: &git::Push, window, cx| {
+                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+                    return;
+                };
+                panel.update(cx, |panel, cx| {
+                    panel.push(false, window, cx);
+                });
             });
-        });
-        workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
-            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
-                return;
-            };
-            panel.update(cx, |panel, cx| {
-                panel.push(true, window, cx);
+            workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
+                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+                    return;
+                };
+                panel.update(cx, |panel, cx| {
+                    panel.push(true, window, cx);
+                });
             });
-        });
-        workspace.register_action(|workspace, _: &git::Pull, window, cx| {
-            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
-                return;
-            };
-            panel.update(cx, |panel, cx| {
-                panel.pull(window, cx);
+            workspace.register_action(|workspace, _: &git::Pull, window, cx| {
+                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+                    return;
+                };
+                panel.update(cx, |panel, cx| {
+                    panel.pull(window, cx);
+                });
             });
-        });
+        }
         workspace.register_action(|workspace, action: &git::StageAll, window, cx| {
             let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
                 return;
@@ -173,6 +175,7 @@ mod remote_button {
             0,
             0,
             Some(IconName::ArrowCircle),
+            keybinding_target.clone(),
             move |_, window, cx| {
                 window.dispatch_action(Box::new(git::Fetch), cx);
             },
@@ -200,6 +203,7 @@ mod remote_button {
             ahead as usize,
             0,
             None,
+            keybinding_target.clone(),
             move |_, window, cx| {
                 window.dispatch_action(Box::new(git::Push), cx);
             },
@@ -228,6 +232,7 @@ mod remote_button {
             ahead as usize,
             behind as usize,
             None,
+            keybinding_target.clone(),
             move |_, window, cx| {
                 window.dispatch_action(Box::new(git::Pull), cx);
             },
@@ -254,6 +259,7 @@ mod remote_button {
             0,
             0,
             Some(IconName::ArrowUpFromLine),
+            keybinding_target.clone(),
             move |_, window, cx| {
                 window.dispatch_action(Box::new(git::Push), cx);
             },
@@ -280,6 +286,7 @@ mod remote_button {
             0,
             0,
             Some(IconName::ArrowUpFromLine),
+            keybinding_target.clone(),
             move |_, window, cx| {
                 window.dispatch_action(Box::new(git::Push), cx);
             },
@@ -321,7 +328,10 @@ mod remote_button {
         }
     }
 
-    fn render_git_action_menu(id: impl Into<ElementId>) -> impl IntoElement {
+    fn render_git_action_menu(
+        id: impl Into<ElementId>,
+        keybinding_target: Option<FocusHandle>,
+    ) -> impl IntoElement {
         PopoverMenu::new(id.into())
             .trigger(
                 ui::ButtonLike::new_rounded_right("split-button-right")
@@ -336,6 +346,9 @@ mod remote_button {
             .menu(move |window, cx| {
                 Some(ContextMenu::build(window, cx, |context_menu, _, _| {
                     context_menu
+                        .when_some(keybinding_target.clone(), |el, keybinding_target| {
+                            el.context(keybinding_target.clone())
+                        })
                         .action("Fetch", git::Fetch.boxed_clone())
                         .action("Pull", git::Pull.boxed_clone())
                         .separator()
@@ -353,12 +366,14 @@ mod remote_button {
     }
 
     impl SplitButton {
+        #[allow(clippy::too_many_arguments)]
         fn new(
             id: impl Into<SharedString>,
             left_label: impl Into<SharedString>,
             ahead_count: usize,
             behind_count: usize,
             left_icon: Option<IconName>,
+            keybinding_target: Option<FocusHandle>,
             left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
             tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
         ) -> Self {
@@ -416,9 +431,10 @@ mod remote_button {
             .on_click(left_on_click)
             .tooltip(tooltip);
 
-            let right = render_git_action_menu(ElementId::Name(
-                format!("split-button-right-{}", id).into(),
-            ))
+            let right = render_git_action_menu(
+                ElementId::Name(format!("split-button-right-{}", id).into()),
+                keybinding_target,
+            )
             .into_any_element();
 
             Self { left, right }