git: Add UI for deleting branches (#42703)

Closes #42641

Release Notes:

- Added UI for deleting Git branches

---------

Co-authored-by: Jakub Konka <kubkon@jakubkonka.com>
This commit is contained in:
Mayank Verma
2025-11-28 12:59:54 +00:00
committed by GitHub
co-authored by Jakub Konka
parent 87e3d6e014
commit c18481ed13
12 changed files with 311 additions and 63 deletions
+7
View File
@@ -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"
}
}
]
+7
View File
@@ -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"
}
}
]
+7
View File
@@ -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"
}
}
]
+4
View File
@@ -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
+17
View File
@@ -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();
+155 -11
View File
@@ -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
}
+59 -51
View File
@@ -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::{
+42
View File
@@ -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,
+6
View File
@@ -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;
+3 -1
View File
@@ -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;
+3
View File
@@ -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,
+1
View File
@@ -4731,6 +4731,7 @@ mod tests {
"assistant",
"assistant2",
"auto_update",
"branch_picker",
"bedrock",
"branches",
"buffer_search",