From 53fca25ba55db83824c04da4ea4fdfebce9b8d2b Mon Sep 17 00:00:00 2001 From: David Alecrim <35930364+davidalecrim1@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:49:06 -0300 Subject: [PATCH] git_ui: Add ability to delete git worktrees from picker (#50015) Adds the ability to delete a git worktree directly from the worktree picker, inspired by the existing branch delete functionality. (`cmd-shift-backspace` on macOS, `ctrl-shift-backspace` on Linux/Windows). Screenshot: Screenshot 2026-02-24 at 16 01 05 Release Notes: - Added the ability to delete a git worktree from the worktree picker --------- Co-authored-by: Anthony Eid --- assets/keymaps/default-linux.json | 1 + assets/keymaps/default-macos.json | 1 + assets/keymaps/default-windows.json | 1 + crates/git_ui/src/git_picker.rs | 16 ++++- crates/git_ui/src/worktree_picker.rs | 88 +++++++++++++++++++++++++++- crates/project/src/git_store.rs | 2 +- 6 files changed, 106 insertions(+), 3 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 87e76829966b501df4139d4942de604c4fc42d65..7e01245ec62b2590a1c88fef5946b7d06463968d 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1312,6 +1312,7 @@ "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", "ctrl-space": "git::WorktreeFromDefault", + "ctrl-shift-backspace": "git::DeleteWorktree", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ccb3a7fa9116b0771dda94e75e467c4572cdaf2c..43d6419575fc698110cd5a033c01127ac6543f9a 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1417,6 +1417,7 @@ "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", "ctrl-space": "git::WorktreeFromDefault", + "cmd-shift-backspace": "git::DeleteWorktree", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 251c3d6541a611737027900e659a94271ed36526..22541368cecfc6a645e2b8b7ce55a6711491a012 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1333,6 +1333,7 @@ "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", "ctrl-space": "git::WorktreeFromDefault", + "ctrl-shift-backspace": "git::DeleteWorktree", }, }, { diff --git a/crates/git_ui/src/git_picker.rs b/crates/git_ui/src/git_picker.rs index 82ef9c9516b7c145edbf26d6c5b8927189525cab..6cf82327b43abe6c3784e4ec8ca3d16161edfda7 100644 --- a/crates/git_ui/src/git_picker.rs +++ b/crates/git_ui/src/git_picker.rs @@ -15,7 +15,7 @@ use workspace::{ModalView, Workspace, pane}; use crate::branch_picker::{self, BranchList, DeleteBranch, FilterRemotes}; use crate::stash_picker::{self, DropStashItem, ShowStashItem, StashList}; use crate::worktree_picker::{ - self, WorktreeFromDefault, WorktreeFromDefaultOnWindow, WorktreeList, + self, DeleteWorktree, WorktreeFromDefault, WorktreeFromDefaultOnWindow, WorktreeList, }; actions!( @@ -408,6 +408,19 @@ impl GitPicker { } } + fn handle_worktree_delete( + &mut self, + _: &DeleteWorktree, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(worktree_list) = &self.worktree_list { + worktree_list.update(cx, |list, cx| { + list.handle_delete(&DeleteWorktree, window, cx); + }); + } + } + fn handle_drop_stash( &mut self, _: &DropStashItem, @@ -524,6 +537,7 @@ impl Render for GitPicker { .when(self.tab == GitPickerTab::Worktrees, |el| { el.on_action(cx.listener(Self::handle_worktree_from_default)) .on_action(cx.listener(Self::handle_worktree_from_default_on_window)) + .on_action(cx.listener(Self::handle_worktree_delete)) }) .when(self.tab == GitPickerTab::Stash, |el| { el.on_action(cx.listener(Self::handle_drop_stash)) diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 9f70c29da86ee52668984f92b247331524fc5936..6c35e7c99ffb8f6efa1a2bd7a07c2ded8d158668 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -22,7 +22,16 @@ use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*}; use util::ResultExt; use workspace::{ModalView, MultiWorkspace, Workspace, notifications::DetachAndPromptErr}; -actions!(git, [WorktreeFromDefault, WorktreeFromDefaultOnWindow]); +use crate::git_panel::show_error_toast; + +actions!( + git, + [ + WorktreeFromDefault, + WorktreeFromDefaultOnWindow, + DeleteWorktree + ] +); pub fn open( workspace: &mut Workspace, @@ -181,6 +190,19 @@ impl WorktreeList { ); }) } + + pub fn handle_delete( + &mut self, + _: &DeleteWorktree, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + picker + .delegate + .delete_at(picker.delegate.selected_index, window, cx) + }) + } } impl ModalView for WorktreeList {} impl EventEmitter for WorktreeList {} @@ -203,6 +225,9 @@ impl Render for WorktreeList { .on_action(cx.listener(|this, _: &WorktreeFromDefaultOnWindow, w, cx| { this.handle_new_worktree(true, w, cx) })) + .on_action(cx.listener(|this, _: &DeleteWorktree, window, cx| { + this.handle_delete(&DeleteWorktree, window, cx) + })) .child(self.picker.clone()) .when(!self.embedded, |el| { el.on_mouse_down_out({ @@ -420,6 +445,57 @@ impl WorktreeListDelegate { .as_ref() .and_then(|repo| repo.read(cx).branch.as_ref().map(|b| b.name())) } + + fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context>) { + let Some(entry) = self.matches.get(idx).cloned() else { + return; + }; + if entry.is_new { + return; + } + let Some(repo) = self.repo.clone() else { + return; + }; + let workspace = self.workspace.clone(); + let path = entry.worktree.path; + + cx.spawn_in(window, async move |picker, cx| { + let result = repo + .update(cx, |repo, _| repo.remove_worktree(path.clone(), false)) + .await?; + + if let Err(e) = result { + log::error!("Failed to remove worktree: {}", e); + if let Some(workspace) = workspace.upgrade() { + cx.update(|_window, cx| { + show_error_toast( + workspace, + format!("worktree remove {}", path.display()), + e, + cx, + ) + })?; + } + return Ok(()); + } + + picker.update_in(cx, |picker, _, cx| { + picker.delegate.matches.retain(|e| e.worktree.path != path); + if let Some(all_worktrees) = &mut picker.delegate.all_worktrees { + all_worktrees.retain(|w| w.path != path); + } + 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(); + } } async fn open_remote_worktree( @@ -778,6 +854,16 @@ impl PickerDelegate for WorktreeListDelegate { } else { Some( footer_container + .child( + Button::new("delete-worktree", "Delete") + .key_binding( + KeyBinding::for_action_in(&DeleteWorktree, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(DeleteWorktree.boxed_clone(), cx) + }), + ) .child( Button::new("open-in-new-window", "Open in New Window") .key_binding( diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index fdafea73fd0ca797616cc58fc9e4b6a3c2101224..eed16761974876247df2e5936f9db9fbdd8fafcc 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -5732,7 +5732,7 @@ impl Repository { pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver> { self.send_job( - Some("git worktree remove".into()), + Some(format!("git worktree remove: {}", path.display()).into()), move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => {