From c18481ed13f6c0024b9b78ad1e4664c49100791f Mon Sep 17 00:00:00 2001 From: Mayank Verma Date: Fri, 28 Nov 2025 18:29:54 +0530 Subject: [PATCH] git: Add UI for deleting branches (#42703) Closes #42641 Release Notes: - Added UI for deleting Git branches --------- Co-authored-by: Jakub Konka --- 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(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 7ddabfc9f55577c8ced3fbad0cc881cd4bb183d0..de55eac818beeb882c286cc2200699409363c5b3 100644 --- a/assets/keymaps/default-linux.json +++ b/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" + } } ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index a298db28e63fd761f2f6d58827a7bcf5c8b39962..46ccd9407763de164d5cd5e7a520d668c6d1a8c4 100644 --- a/assets/keymaps/default-macos.json +++ b/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" + } } ] diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 6173380255562e72acae0a128eba505dc6295abc..c72fc198c4ba3679b7e3bf98e3509d26fe0f6788 100644 --- a/assets/keymaps/default-windows.json +++ b/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" + } } ] diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 05a751531444eedc61d69937ec1eeb5ddb29d314..2b19b0bf85f11e846154f6b6781c884bb1e3c0fe 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/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> { self.with_state_async(false, move |state| { state diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index e4e13bac9dc217a0ea718db0842014fa1a255e46..110396b0450ada5a97d8c3362f9cc367f260fd0e 100644 --- a/crates/git/src/repository.rs +++ b/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>>; 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> { let working_directory = self.working_directory(); let git_binary_path = self.any_git_binary_path.clone(); diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 3ae9059b2a12f178931a5271b92c5fdf44f319d4..92c2f92ca342be270aa25f9e1a7ee96f5e06a585 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/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, ) { + 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 { 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_focus_handle: FocusHandle, _subscription: Subscription, } impl BranchList { fn new( + workspace: Option>, repository: Option>, 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.picker.update(cx, |picker, cx| { + picker + .delegate + .delete_branch_at(picker.delegate.selected_index, window, cx) + }) + } } impl ModalView for BranchList {} impl EventEmitter 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>, matches: Vec, all_branches: Option>, default_branch: Option, @@ -214,11 +263,18 @@ pub struct BranchListDelegate { selected_index: usize, last_query: String, modifiers: Modifiers, + focus_handle: FocusHandle, } impl BranchListDelegate { - fn new(repo: Option>, style: BranchListStyle) -> Self { + fn new( + workspace: Option>, + repo: Option>, + style: BranchListStyle, + cx: &mut Context, + ) -> 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>) { + 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>, + ) -> Option { + 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 { None } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index a35fda8f6be782d34152ba0ec81a5d62607d8ac2..a986c62440c0a370de8ebbf5a4e3d528010a3f0c 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3024,35 +3024,10 @@ impl GitPanel { } fn show_error_toast(&self, action: impl Into, 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(weak_this: &WeakEntity, 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, - workspace: &mut Workspace, - output: &str, - window: &mut Window, - cx: &mut Context, - ) { - 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, + workspace: &mut Workspace, + output: &str, + window: &mut Window, + cx: &mut Context, +) { + 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, + action: impl Into, + 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::{ diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 055b91f2ee89f01122637fe82aec2c1bc82a4eea..b0aef8f8bcc4eba3d228bfb02c803585e0b14eb8 100644 --- a/crates/project/src/git_store.rs +++ b/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, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + 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, envelope: TypedEnvelope, @@ -5035,6 +5054,29 @@ impl Repository { ) } + pub fn delete_branch(&mut self, branch_name: String) -> oneshot::Receiver> { + 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, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 3dcd71fe441dd113e0c2435c333cad74a5010c1f..a2a99a7d42cc1148dd5ed5e6a74baabf7b60908d 100644 --- a/crates/proto/proto/git.proto +++ b/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; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 7a2c86887fcd0ffa8f64e5362568b7bd0e12ec7b..edcab3ef2b76c05591b4c350d90cec4ad2382f8f 100644 --- a/crates/proto/proto/zed.proto +++ b/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; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index bcffe5ae01616c469bde2e730feff3e8e777e572..44576bc369b08213372fc894bd15fd63a66c70a8 100644 --- a/crates/proto/src/proto.rs +++ b/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, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 180f53a46b93eaa93ea355ece256807a16d03f43..c1b5b791d9479844eb0c5af6f517a3af0140ccd8 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4731,6 +4731,7 @@ mod tests { "assistant", "assistant2", "auto_update", + "branch_picker", "bedrock", "branches", "buffer_search",