git: Add UI for deleting branches (#42703)

Mayank Verma and Jakub Konka created

Closes #42641

Release Notes:

- Added UI for deleting Git branches

---------

Co-authored-by: Jakub Konka <kubkon@jakubkonka.com>

Change summary

assets/keymaps/default-linux.json   |   7 +
assets/keymaps/default-macos.json   |   7 +
assets/keymaps/default-windows.json |   7 +
crates/fs/src/fake_git_repo.rs      |   4 
crates/git/src/repository.rs        |  17 +++
crates/git_ui/src/branch_picker.rs  | 166 ++++++++++++++++++++++++++++--
crates/git_ui/src/git_panel.rs      | 110 +++++++++++---------
crates/project/src/git_store.rs     |  42 +++++++
crates/proto/proto/git.proto        |   6 +
crates/proto/proto/zed.proto        |   4 
crates/proto/src/proto.rs           |   3 
crates/zed/src/zed.rs               |   1 
12 files changed, 311 insertions(+), 63 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -1334,5 +1334,12 @@
       "alt-left": "dev::Zeta2ContextGoBack",
       "alt-right": "dev::Zeta2ContextGoForward"
     }
+  },
+  {
+    "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-shift-backspace": "branch_picker::DeleteBranch"
+    }
   }
 ]

assets/keymaps/default-macos.json 🔗

@@ -1438,5 +1438,12 @@
       "alt-left": "dev::Zeta2ContextGoBack",
       "alt-right": "dev::Zeta2ContextGoForward"
     }
+  },
+  {
+    "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-shift-backspace": "branch_picker::DeleteBranch"
+    }
   }
 ]

assets/keymaps/default-windows.json 🔗

@@ -1361,5 +1361,12 @@
       "alt-left": "dev::Zeta2ContextGoBack",
       "alt-right": "dev::Zeta2ContextGoForward"
     }
+  },
+  {
+    "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-shift-backspace": "branch_picker::DeleteBranch"
+    }
   }
 ]

crates/fs/src/fake_git_repo.rs 🔗

@@ -432,6 +432,10 @@ impl GitRepository for FakeGitRepository {
         })
     }
 
+    fn delete_branch(&self, _name: String) -> BoxFuture<'_, Result<()>> {
+        unimplemented!()
+    }
+
     fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result<git::blame::Blame>> {
         self.with_state_async(false, move |state| {
             state

crates/git/src/repository.rs 🔗

@@ -435,6 +435,8 @@ pub trait GitRepository: Send + Sync {
     -> BoxFuture<'_, Result<()>>;
     fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
 
+    fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
+
     fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;
 
     fn create_worktree(
@@ -1414,6 +1416,21 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
+    fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
+        let git_binary_path = self.any_git_binary_path.clone();
+        let working_directory = self.working_directory();
+        let executor = self.executor.clone();
+
+        self.executor
+            .spawn(async move {
+                GitBinary::new(git_binary_path, working_directory?, executor)
+                    .run(&["branch", "-d", &name])
+                    .await?;
+                anyhow::Ok(())
+            })
+            .boxed()
+    }
+
     fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.any_git_binary_path.clone();

crates/git_ui/src/branch_picker.rs 🔗

@@ -4,9 +4,9 @@ use fuzzy::StringMatchCandidate;
 use collections::HashSet;
 use git::repository::Branch;
 use gpui::{
-    App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
-    IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, SharedString, Styled,
-    Subscription, Task, Window, rems,
+    Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render,
+    SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems,
 };
 use picker::{Picker, PickerDelegate, PickerEditorPosition};
 use project::git_store::Repository;
@@ -14,13 +14,25 @@ use project::project_settings::ProjectSettings;
 use settings::Settings;
 use std::sync::Arc;
 use time::OffsetDateTime;
-use ui::{HighlightedLabel, ListItem, ListItemSpacing, Tooltip, prelude::*};
+use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
 use util::ResultExt;
 use workspace::notifications::DetachAndPromptErr;
 use workspace::{ModalView, Workspace};
 
+use crate::{branch_picker, git_panel::show_error_toast};
+
+actions!(
+    branch_picker,
+    [
+        /// Deletes the selected git branch.
+        DeleteBranch
+    ]
+);
+
 pub fn register(workspace: &mut Workspace) {
-    workspace.register_action(open);
+    workspace.register_action(|workspace, branch: &zed_actions::git::Branch, window, cx| {
+        open(workspace, branch, window, cx);
+    });
     workspace.register_action(switch);
     workspace.register_action(checkout_branch);
 }
@@ -49,10 +61,18 @@ pub fn open(
     window: &mut Window,
     cx: &mut Context<Workspace>,
 ) {
+    let workspace_handle = workspace.weak_handle();
     let repository = workspace.project().read(cx).active_repository(cx);
     let style = BranchListStyle::Modal;
     workspace.toggle_modal(window, cx, |window, cx| {
-        BranchList::new(repository, style, rems(34.), window, cx)
+        BranchList::new(
+            Some(workspace_handle),
+            repository,
+            style,
+            rems(34.),
+            window,
+            cx,
+        )
     })
 }
 
@@ -62,7 +82,14 @@ pub fn popover(
     cx: &mut App,
 ) -> Entity<BranchList> {
     cx.new(|cx| {
-        let list = BranchList::new(repository, BranchListStyle::Popover, rems(20.), window, cx);
+        let list = BranchList::new(
+            None,
+            repository,
+            BranchListStyle::Popover,
+            rems(20.),
+            window,
+            cx,
+        );
         list.focus_handle(cx).focus(window);
         list
     })
@@ -77,11 +104,13 @@ enum BranchListStyle {
 pub struct BranchList {
     width: Rems,
     pub picker: Entity<Picker<BranchListDelegate>>,
+    picker_focus_handle: FocusHandle,
     _subscription: Subscription,
 }
 
 impl BranchList {
     fn new(
+        workspace: Option<WeakEntity<Workspace>>,
         repository: Option<Entity<Repository>>,
         style: BranchListStyle,
         width: Rems,
@@ -148,8 +177,12 @@ impl BranchList {
         })
         .detach_and_log_err(cx);
 
-        let delegate = BranchListDelegate::new(repository, style);
+        let delegate = BranchListDelegate::new(workspace, repository, style, cx);
         let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
+        let picker_focus_handle = picker.focus_handle(cx);
+        picker.update(cx, |picker, _| {
+            picker.delegate.focus_handle = picker_focus_handle.clone();
+        });
 
         let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
             cx.emit(DismissEvent);
@@ -157,6 +190,7 @@ impl BranchList {
 
         Self {
             picker,
+            picker_focus_handle,
             width,
             _subscription,
         }
@@ -171,13 +205,26 @@ impl BranchList {
         self.picker
             .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
     }
+
+    fn handle_delete_branch(
+        &mut self,
+        _: &branch_picker::DeleteBranch,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.picker.update(cx, |picker, cx| {
+            picker
+                .delegate
+                .delete_branch_at(picker.delegate.selected_index, window, cx)
+        })
+    }
 }
 impl ModalView for BranchList {}
 impl EventEmitter<DismissEvent> for BranchList {}
 
 impl Focusable for BranchList {
-    fn focus_handle(&self, cx: &App) -> FocusHandle {
-        self.picker.focus_handle(cx)
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.picker_focus_handle.clone()
     }
 }
 
@@ -187,6 +234,7 @@ impl Render for BranchList {
             .key_context("GitBranchSelector")
             .w(self.width)
             .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
+            .on_action(cx.listener(Self::handle_delete_branch))
             .child(self.picker.clone())
             .on_mouse_down_out({
                 cx.listener(move |this, _, window, cx| {
@@ -206,6 +254,7 @@ struct BranchEntry {
 }
 
 pub struct BranchListDelegate {
+    workspace: Option<WeakEntity<Workspace>>,
     matches: Vec<BranchEntry>,
     all_branches: Option<Vec<Branch>>,
     default_branch: Option<SharedString>,
@@ -214,11 +263,18 @@ pub struct BranchListDelegate {
     selected_index: usize,
     last_query: String,
     modifiers: Modifiers,
+    focus_handle: FocusHandle,
 }
 
 impl BranchListDelegate {
-    fn new(repo: Option<Entity<Repository>>, style: BranchListStyle) -> Self {
+    fn new(
+        workspace: Option<WeakEntity<Workspace>>,
+        repo: Option<Entity<Repository>>,
+        style: BranchListStyle,
+        cx: &mut Context<BranchList>,
+    ) -> Self {
         Self {
+            workspace,
             matches: vec![],
             repo,
             style,
@@ -227,6 +283,7 @@ impl BranchListDelegate {
             selected_index: 0,
             last_query: Default::default(),
             modifiers: Default::default(),
+            focus_handle: cx.focus_handle(),
         }
     }
 
@@ -255,6 +312,59 @@ impl BranchListDelegate {
         });
         cx.emit(DismissEvent);
     }
+
+    fn delete_branch_at(&self, idx: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        let Some(branch_entry) = self.matches.get(idx) else {
+            return;
+        };
+        let Some(repo) = self.repo.clone() else {
+            return;
+        };
+
+        let workspace = self.workspace.clone();
+        let branch_name = branch_entry.branch.name().to_string();
+        let branch_ref = branch_entry.branch.ref_name.clone();
+
+        cx.spawn_in(window, async move |picker, cx| {
+            let result = repo
+                .update(cx, |repo, _| repo.delete_branch(branch_name.clone()))?
+                .await?;
+
+            if let Err(e) = result {
+                log::error!("Failed to delete branch: {}", e);
+
+                if let Some(workspace) = workspace.and_then(|w| w.upgrade()) {
+                    cx.update(|_window, cx| {
+                        show_error_toast(workspace, format!("branch -d {branch_name}"), e, cx)
+                    })?;
+                }
+
+                return Ok(());
+            }
+
+            picker.update_in(cx, |picker, _, cx| {
+                picker
+                    .delegate
+                    .matches
+                    .retain(|entry| entry.branch.ref_name != branch_ref);
+
+                if let Some(all_branches) = &mut picker.delegate.all_branches {
+                    all_branches.retain(|branch| branch.ref_name != branch_ref);
+                }
+
+                if picker.delegate.matches.is_empty() {
+                    picker.delegate.selected_index = 0;
+                } else if picker.delegate.selected_index >= picker.delegate.matches.len() {
+                    picker.delegate.selected_index = picker.delegate.matches.len() - 1;
+                }
+
+                cx.notify();
+            })?;
+
+            anyhow::Ok(())
+        })
+        .detach();
+    }
 }
 
 impl PickerDelegate for BranchListDelegate {
@@ -372,6 +482,7 @@ impl PickerDelegate for BranchListDelegate {
         let Some(entry) = self.matches.get(self.selected_index()) else {
             return;
         };
+
         if entry.is_new {
             let from_branch = if secondary {
                 self.default_branch.clone()
@@ -565,6 +676,39 @@ impl PickerDelegate for BranchListDelegate {
         )
     }
 
+    fn render_footer(
+        &self,
+        _window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<AnyElement> {
+        let focus_handle = self.focus_handle.clone();
+
+        Some(
+            h_flex()
+                .w_full()
+                .p_1p5()
+                .gap_0p5()
+                .justify_end()
+                .border_t_1()
+                .border_color(cx.theme().colors().border_variant)
+                .child(
+                    Button::new("delete-branch", "Delete")
+                        .key_binding(
+                            KeyBinding::for_action_in(
+                                &branch_picker::DeleteBranch,
+                                &focus_handle,
+                                cx,
+                            )
+                            .map(|kb| kb.size(rems_from_px(12.))),
+                        )
+                        .on_click(|_, window, cx| {
+                            window.dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx);
+                        }),
+                )
+                .into_any(),
+        )
+    }
+
     fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
         None
     }

crates/git_ui/src/git_panel.rs 🔗

@@ -3024,35 +3024,10 @@ impl GitPanel {
     }
 
     fn show_error_toast(&self, action: impl Into<SharedString>, e: anyhow::Error, cx: &mut App) {
-        let action = action.into();
         let Some(workspace) = self.workspace.upgrade() else {
             return;
         };
-
-        let message = e.to_string().trim().to_string();
-        if message
-            .matches(git::repository::REMOTE_CANCELLED_BY_USER)
-            .next()
-            .is_some()
-        { // Hide the cancelled by user message
-        } else {
-            workspace.update(cx, |workspace, cx| {
-                let workspace_weak = cx.weak_entity();
-                let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| {
-                    this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
-                        .action("View Log", move |window, cx| {
-                            let message = message.clone();
-                            let action = action.clone();
-                            workspace_weak
-                                .update(cx, move |workspace, cx| {
-                                    Self::open_output(action, workspace, &message, window, cx)
-                                })
-                                .ok();
-                        })
-                });
-                workspace.toggle_status_toast(toast, cx)
-            });
-        }
+        show_error_toast(workspace, action, e, cx)
     }
 
     fn show_commit_message_error<E>(weak_this: &WeakEntity<Self>, err: &E, cx: &mut AsyncApp)
@@ -3097,7 +3072,7 @@ impl GitPanel {
                                 format!("stdout:\n{}\nstderr:\n{}", output.stdout, output.stderr);
                             workspace_weak
                                 .update(cx, move |workspace, cx| {
-                                    Self::open_output(operation, workspace, &output, window, cx)
+                                    open_output(operation, workspace, &output, window, cx)
                                 })
                                 .ok();
                         }),
@@ -3110,30 +3085,6 @@ impl GitPanel {
         });
     }
 
-    fn open_output(
-        operation: impl Into<SharedString>,
-        workspace: &mut Workspace,
-        output: &str,
-        window: &mut Window,
-        cx: &mut Context<Workspace>,
-    ) {
-        let operation = operation.into();
-        let buffer = cx.new(|cx| Buffer::local(output, cx));
-        buffer.update(cx, |buffer, cx| {
-            buffer.set_capability(language::Capability::ReadOnly, cx);
-        });
-        let editor = cx.new(|cx| {
-            let mut editor = Editor::for_buffer(buffer, None, window, cx);
-            editor.buffer().update(cx, |buffer, cx| {
-                buffer.set_title(format!("Output from git {operation}"), cx);
-            });
-            editor.set_read_only(true);
-            editor
-        });
-
-        workspace.add_item_to_center(Box::new(editor), window, cx);
-    }
-
     pub fn can_commit(&self) -> bool {
         (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
     }
@@ -5178,6 +5129,63 @@ impl Component for PanelRepoFooter {
     }
 }
 
+fn open_output(
+    operation: impl Into<SharedString>,
+    workspace: &mut Workspace,
+    output: &str,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    let operation = operation.into();
+    let buffer = cx.new(|cx| Buffer::local(output, cx));
+    buffer.update(cx, |buffer, cx| {
+        buffer.set_capability(language::Capability::ReadOnly, cx);
+    });
+    let editor = cx.new(|cx| {
+        let mut editor = Editor::for_buffer(buffer, None, window, cx);
+        editor.buffer().update(cx, |buffer, cx| {
+            buffer.set_title(format!("Output from git {operation}"), cx);
+        });
+        editor.set_read_only(true);
+        editor
+    });
+
+    workspace.add_item_to_center(Box::new(editor), window, cx);
+}
+
+pub(crate) fn show_error_toast(
+    workspace: Entity<Workspace>,
+    action: impl Into<SharedString>,
+    e: anyhow::Error,
+    cx: &mut App,
+) {
+    let action = action.into();
+    let message = e.to_string().trim().to_string();
+    if message
+        .matches(git::repository::REMOTE_CANCELLED_BY_USER)
+        .next()
+        .is_some()
+    { // Hide the cancelled by user message
+    } else {
+        workspace.update(cx, |workspace, cx| {
+            let workspace_weak = cx.weak_entity();
+            let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| {
+                this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
+                    .action("View Log", move |window, cx| {
+                        let message = message.clone();
+                        let action = action.clone();
+                        workspace_weak
+                            .update(cx, move |workspace, cx| {
+                                open_output(action, workspace, &message, window, cx)
+                            })
+                            .ok();
+                    })
+            });
+            workspace.toggle_status_toast(toast, cx)
+        });
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use git::{

crates/project/src/git_store.rs 🔗

@@ -472,6 +472,7 @@ impl GitStore {
         client.add_entity_request_handler(Self::handle_change_branch);
         client.add_entity_request_handler(Self::handle_create_branch);
         client.add_entity_request_handler(Self::handle_rename_branch);
+        client.add_entity_request_handler(Self::handle_delete_branch);
         client.add_entity_request_handler(Self::handle_git_init);
         client.add_entity_request_handler(Self::handle_push);
         client.add_entity_request_handler(Self::handle_pull);
@@ -2247,6 +2248,24 @@ impl GitStore {
         Ok(proto::Ack {})
     }
 
+    async fn handle_delete_branch(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitDeleteBranch>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+        let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+        let branch_name = envelope.payload.branch_name;
+
+        repository_handle
+            .update(&mut cx, |repository_handle, _| {
+                repository_handle.delete_branch(branch_name)
+            })?
+            .await??;
+
+        Ok(proto::Ack {})
+    }
+
     async fn handle_show(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::GitShow>,
@@ -5035,6 +5054,29 @@ impl Repository {
         )
     }
 
+    pub fn delete_branch(&mut self, branch_name: String) -> oneshot::Receiver<Result<()>> {
+        let id = self.id;
+        self.send_job(
+            Some(format!("git branch -d {branch_name}").into()),
+            move |repo, _cx| async move {
+                match repo {
+                    RepositoryState::Local(state) => state.backend.delete_branch(branch_name).await,
+                    RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+                        client
+                            .request(proto::GitDeleteBranch {
+                                project_id: project_id.0,
+                                repository_id: id.to_proto(),
+                                branch_name,
+                            })
+                            .await?;
+
+                        Ok(())
+                    }
+                }
+            },
+        )
+    }
+
     pub fn rename_branch(
         &mut self,
         branch: String,

crates/proto/proto/git.proto 🔗

@@ -190,6 +190,12 @@ message GitRenameBranch {
     string new_name = 4;
 }
 
+message GitDeleteBranch {
+    uint64 project_id = 1;
+    uint64 repository_id = 2;
+    string branch_name = 3;
+}
+
 message GitDiff {
     uint64 project_id = 1;
     reserved 2;

crates/proto/proto/zed.proto 🔗

@@ -439,7 +439,9 @@ message Envelope {
 
         ExternalExtensionAgentsUpdated external_extension_agents_updated = 394;
 
-        RunGitHook run_git_hook = 395; // current max
+        RunGitHook run_git_hook = 395;
+
+        GitDeleteBranch git_delete_branch = 396; // current max
     }
 
     reserved 87 to 88;

crates/proto/src/proto.rs 🔗

@@ -290,6 +290,7 @@ messages!(
     (RemoveRepository, Foreground),
     (UsersResponse, Foreground),
     (GitReset, Background),
+    (GitDeleteBranch, Background),
     (GitCheckoutFiles, Background),
     (GitShow, Background),
     (GitCommitDetails, Background),
@@ -492,6 +493,7 @@ request_messages!(
     (RegisterBufferWithLanguageServers, Ack),
     (GitShow, GitCommitDetails),
     (GitReset, Ack),
+    (GitDeleteBranch, Ack),
     (GitCheckoutFiles, Ack),
     (SetIndexText, Ack),
     (Push, RemoteMessageResponse),
@@ -656,6 +658,7 @@ entity_messages!(
     RegisterBufferWithLanguageServers,
     GitShow,
     GitReset,
+    GitDeleteBranch,
     GitCheckoutFiles,
     SetIndexText,
     ToggleLspLogs,

crates/zed/src/zed.rs 🔗

@@ -4731,6 +4731,7 @@ mod tests {
                 "assistant",
                 "assistant2",
                 "auto_update",
+                "branch_picker",
                 "bedrock",
                 "branches",
                 "buffer_search",