Add branch rename action to Git panel

Guillaume Launay created

Also add it to the menu next to branch name

Change summary

crates/git/src/git.rs           |   2 
crates/git/src/repository.rs    |  16 ++++
crates/git_ui/src/git_ui.rs     | 135 +++++++++++++++++++++++++++++++++++
crates/project/src/git_store.rs |  18 ++++
crates/proto/proto/git.proto    |   7 +
crates/proto/proto/zed.proto    |   5 +
6 files changed, 182 insertions(+), 1 deletion(-)

Detailed changes

crates/git/src/git.rs 🔗

@@ -95,6 +95,8 @@ actions!(
         OpenModifiedFiles,
         /// Clones a repository.
         Clone,
+        /// Renames the current branch.
+        RenameBranch,
     ]
 );
 

crates/git/src/repository.rs 🔗

@@ -344,6 +344,7 @@ pub trait GitRepository: Send + Sync {
 
     fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
     fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
+    fn rename_branch(&self, new_name: String) -> BoxFuture<'_, Result<()>>;
 
     fn reset(
         &self,
@@ -1094,6 +1095,21 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
+    fn rename_branch(&self, new_name: String) -> BoxFuture<'_, Result<()>> {
+        let git_binary_path = self.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", "-m", &new_name])
+                    .await?;
+                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.git_binary_path.clone();

crates/git_ui/src/git_ui.rs 🔗

@@ -4,6 +4,13 @@ use ::settings::Settings;
 use command_palette_hooks::CommandPaletteFilter;
 use commit_modal::CommitModal;
 use editor::{Editor, EditorElement, EditorStyle, actions::DiffClipboardWithSelectionData};
+use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, SharedString};
+use ui::{
+    Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render, Styled,
+    StyledExt, div, h_flex, rems, v_flex,
+};
+use workspace::{ModalView, notifications::DetachAndPromptErr};
+
 mod blame_ui;
 use git::{
     repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
@@ -14,7 +21,9 @@ use gpui::{
     Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, TextStyle,
     Window, actions,
 };
+use menu::{Cancel, Confirm};
 use onboarding::GitOnboardingModal;
+use project::git_store::Repository;
 use project_diff::ProjectDiff;
 use theme::ThemeSettings;
 use ui::prelude::*;
@@ -185,6 +194,9 @@ pub fn init(cx: &mut App) {
         workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
             open_modified_files(workspace, window, cx);
         });
+        workspace.register_action(|workspace, _: &git::RenameBranch, window, cx| {
+            rename_current_branch(workspace, window, cx);
+        });
         workspace.register_action(
             |workspace, action: &DiffClipboardWithSelectionData, window, cx| {
                 if let Some(task) = TextDiffView::open(action, workspace, window, cx) {
@@ -228,6 +240,127 @@ pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
     GitStatusIcon::new(status)
 }
 
+struct RenameBranchModal {
+    current_branch: SharedString,
+    editor: Entity<Editor>,
+    repo: Entity<Repository>,
+}
+
+impl RenameBranchModal {
+    fn new(
+        current_branch: String,
+        repo: Entity<Repository>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let editor = cx.new(|cx| {
+            let mut editor = Editor::single_line(window, cx);
+            editor.set_text(current_branch.clone(), window, cx);
+            editor
+        });
+        Self {
+            current_branch: current_branch.into(),
+            editor,
+            repo,
+        }
+    }
+
+    fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
+        cx.emit(DismissEvent);
+    }
+
+    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        let new_name = self.editor.read(cx).text(cx);
+        if new_name.is_empty() || new_name == self.current_branch.as_ref() {
+            cx.emit(DismissEvent);
+            return;
+        }
+
+        let repo = self.repo.clone();
+        cx.spawn(async move |_, cx| {
+            repo.update(cx, |repo, _| repo.rename_branch(new_name))?
+                .await??;
+
+            Ok(())
+        })
+        .detach_and_prompt_err("Failed to rename branch", window, cx, |_, _, _| None);
+        cx.emit(DismissEvent);
+    }
+}
+
+impl EventEmitter<DismissEvent> for RenameBranchModal {}
+impl ModalView for RenameBranchModal {}
+impl Focusable for RenameBranchModal {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.editor.focus_handle(cx)
+    }
+}
+
+impl Render for RenameBranchModal {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex()
+            .key_context("RenameBranchModal")
+            .on_action(cx.listener(Self::cancel))
+            .on_action(cx.listener(Self::confirm))
+            .elevation_2(cx)
+            .w(rems(34.))
+            .child(
+                h_flex()
+                    .px_3()
+                    .pt_2()
+                    .pb_1()
+                    .w_full()
+                    .gap_1p5()
+                    .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall))
+                    .child(Headline::new("Rename Branch").size(HeadlineSize::XSmall)),
+            )
+            .child(
+                div()
+                    .px_3()
+                    .pb_3()
+                    .w_full()
+                    .child(
+                        div()
+                            .mb_2()
+                            .text_sm()
+                            .text_color(cx.theme().colors().text_muted)
+                            .child(format!("Current: {}", self.current_branch)),
+                    )
+                    .child(self.editor.clone()),
+            )
+    }
+}
+
+fn rename_current_branch(
+    workspace: &mut Workspace,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+        return;
+    };
+    let current_branch = panel.update(cx, |panel, cx| {
+        let Some(repo) = panel.active_repository.as_ref() else {
+            return None;
+        };
+        let repo = repo.read(cx);
+        repo.branch.as_ref().map(|branch| branch.name().to_string())
+    });
+
+    let Some(current_branch_name) = current_branch else {
+        return;
+    };
+
+    let repo = panel.read(cx).active_repository.clone();
+    let Some(repo) = repo else {
+        return;
+    };
+
+    workspace.toggle_modal(window, cx, |window, cx| {
+        RenameBranchModal::new(current_branch_name, repo, window, cx)
+    });
+}
+
 fn render_remote_button(
     id: impl Into<SharedString>,
     branch: &Branch,
@@ -467,6 +600,8 @@ mod remote_button {
                         .action("Push", git::Push.boxed_clone())
                         .action("Push To", git::PushTo.boxed_clone())
                         .action("Force Push", git::ForcePush.boxed_clone())
+                        .separator()
+                        .action("Rename Branch", git::RenameBranch.boxed_clone())
                 }))
             })
             .anchor(Corner::TopRight)

crates/project/src/git_store.rs 🔗

@@ -4199,6 +4199,24 @@ impl Repository {
         )
     }
 
+    pub fn rename_branch(&mut self, new_name: String) -> oneshot::Receiver<Result<()>> {
+        let _id = self.id;
+        self.send_job(
+            Some(format!("git branch -m {new_name}").into()),
+            move |repo, _cx| async move {
+                match repo {
+                    RepositoryState::Local { backend, .. } => backend.rename_branch(new_name).await,
+                    RepositoryState::Remote { .. } => {
+                        // Remote branch renaming not implemented yet
+                        Err(anyhow::anyhow!(
+                            "Branch renaming is not supported for remote repositories yet"
+                        ))
+                    }
+                }
+            },
+        )
+    }
+
     pub fn check_for_pushed_commits(&mut self) -> oneshot::Receiver<Result<Vec<SharedString>>> {
         let id = self.id;
         self.send_job(None, move |repo, _cx| async move {

crates/proto/proto/git.proto 🔗

@@ -180,6 +180,13 @@ message GitChangeBranch {
     string branch_name = 4;
 }
 
+message GitRenameBranch {
+    uint64 project_id = 1;
+    reserved 2;
+    uint64 repository_id = 3;
+    string new_name = 4;
+}
+
 message GitDiff {
     uint64 project_id = 1;
     reserved 2;

crates/proto/proto/zed.proto 🔗

@@ -402,7 +402,10 @@ message Envelope {
         GetCrashFilesResponse get_crash_files_response = 362;
 
         GitClone git_clone = 363;
-        GitCloneResponse git_clone_response = 364; // current max
+        GitCloneResponse git_clone_response = 364;
+        
+        GitRenameBranch git_rename_branch = 365; // current max
+
     }
 
     reserved 87 to 88;