Detailed changes
@@ -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"
+ }
}
]
@@ -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"
+ }
}
]
@@ -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"
+ }
}
]
@@ -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
@@ -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();
@@ -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
}
@@ -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::{
@@ -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,
@@ -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;
@@ -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;
@@ -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,
@@ -4731,6 +4731,7 @@ mod tests {
"assistant",
"assistant2",
"auto_update",
+ "branch_picker",
"bedrock",
"branches",
"buffer_search",