vcs_menu: Streamline branch creation from branch selector (#18712)

Marshall Bowers created

This PR streamlines the branch creation from the branch selector when
searching for a branch that does not exist.

The branch selector will show the available branches, as it does today:

<img width="576" alt="Screenshot 2024-10-03 at 4 01 25 PM"
src="https://github.com/user-attachments/assets/e1904f5b-4aad-4f88-901d-ab9422ec18bb">

When entering the name of a branch that does not exist, the picker will
be populated with an entry to create a new branch:

<img width="570" alt="Screenshot 2024-10-03 at 4 01 37 PM"
src="https://github.com/user-attachments/assets/07f8d12c-9422-4fd8-a6dc-ae450e297a13">

Selecting that entry will create the branch and switch to it.

Release Notes:

- Streamlined creating a new branch from the branch selector.

Change summary

crates/vcs_menu/src/lib.rs | 156 +++++++++++++++++++--------------------
1 file changed, 75 insertions(+), 81 deletions(-)

Detailed changes

crates/vcs_menu/src/lib.rs 🔗

@@ -74,8 +74,23 @@ impl Render for BranchList {
     }
 }
 
+#[derive(Debug, Clone)]
+enum BranchEntry {
+    Branch(StringMatch),
+    NewBranch { name: String },
+}
+
+impl BranchEntry {
+    fn name(&self) -> &str {
+        match self {
+            Self::Branch(branch) => &branch.string,
+            Self::NewBranch { name } => &name,
+        }
+    }
+}
+
 pub struct BranchListDelegate {
-    matches: Vec<StringMatch>,
+    matches: Vec<BranchEntry>,
     all_branches: Vec<Branch>,
     workspace: View<Workspace>,
     selected_index: usize,
@@ -194,8 +209,14 @@ impl PickerDelegate for BranchListDelegate {
             picker
                 .update(&mut cx, |picker, _| {
                     let delegate = &mut picker.delegate;
-                    delegate.matches = matches;
+                    delegate.matches = matches.into_iter().map(BranchEntry::Branch).collect();
                     if delegate.matches.is_empty() {
+                        if !query.is_empty() {
+                            delegate.matches.push(BranchEntry::NewBranch {
+                                name: query.trim().replace(' ', "-"),
+                            });
+                        }
+
                         delegate.selected_index = 0;
                     } else {
                         delegate.selected_index =
@@ -208,32 +229,44 @@ impl PickerDelegate for BranchListDelegate {
     }
 
     fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
-        let current_pick = self.selected_index();
-        let Some(current_pick) = self
-            .matches
-            .get(current_pick)
-            .map(|pick| pick.string.clone())
-        else {
+        let Some(branch) = self.matches.get(self.selected_index()) else {
             return;
         };
-        cx.spawn(|picker, mut cx| async move {
-            picker
-                .update(&mut cx, |this, cx| {
-                    let project = this.delegate.workspace.read(cx).project().read(cx);
-                    let repo = project
-                        .get_first_worktree_root_repo(cx)
-                        .context("failed to get root repository for first worktree")?;
-                    let status = repo
-                        .change_branch(&current_pick);
-                    if status.is_err() {
-                        this.delegate.display_error_toast(format!("Failed to checkout branch '{current_pick}', check for conflicts or unstashed files"), cx);
-                        status?;
-                    }
-                    cx.emit(DismissEvent);
+        cx.spawn({
+            let branch = branch.clone();
+            |picker, mut cx| async move {
+                picker
+                    .update(&mut cx, |this, cx| {
+                        let project = this.delegate.workspace.read(cx).project().read(cx);
+                        let repo = project
+                            .get_first_worktree_root_repo(cx)
+                            .context("failed to get root repository for first worktree")?;
 
-                    Ok::<(), anyhow::Error>(())
-                })
-                .log_err();
+                        let branch_to_checkout = match branch {
+                            BranchEntry::Branch(branch) => branch.string,
+                            BranchEntry::NewBranch { name: branch_name } => {
+                                let status = repo.create_branch(&branch_name);
+                                if status.is_err() {
+                                    this.delegate.display_error_toast(format!("Failed to create branch '{branch_name}', check for conflicts or unstashed files"), cx);
+                                    status?;
+                                }
+
+                                branch_name
+                            }
+                        };
+
+                        let status = repo.change_branch(&branch_to_checkout);
+                        if status.is_err() {
+                            this.delegate.display_error_toast(format!("Failed to checkout branch '{branch_to_checkout}', check for conflicts or unstashed files"), cx);
+                            status?;
+                        }
+
+                        cx.emit(DismissEvent);
+
+                        Ok::<(), anyhow::Error>(())
+                    })
+                    .log_err();
+            }
         })
         .detach();
     }
@@ -250,19 +283,28 @@ impl PickerDelegate for BranchListDelegate {
     ) -> Option<Self::ListItem> {
         let hit = &self.matches[ix];
         let shortened_branch_name =
-            util::truncate_and_trailoff(&hit.string, self.branch_name_trailoff_after);
-        let highlights: Vec<_> = hit
-            .positions
-            .iter()
-            .filter(|index| index < &&self.branch_name_trailoff_after)
-            .copied()
-            .collect();
+            util::truncate_and_trailoff(&hit.name(), self.branch_name_trailoff_after);
+
         Some(
             ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
                 .inset(true)
                 .spacing(ListItemSpacing::Sparse)
                 .selected(selected)
-                .start_slot(HighlightedLabel::new(shortened_branch_name, highlights)),
+                .map(|parent| match hit {
+                    BranchEntry::Branch(branch) => {
+                        let highlights: Vec<_> = branch
+                            .positions
+                            .iter()
+                            .filter(|index| index < &&self.branch_name_trailoff_after)
+                            .copied()
+                            .collect();
+
+                        parent.child(HighlightedLabel::new(shortened_branch_name, highlights))
+                    }
+                    BranchEntry::NewBranch { name } => {
+                        parent.child(Label::new(format!("Create branch '{name}'")))
+                    }
+                }),
         )
     }
 
@@ -289,52 +331,4 @@ impl PickerDelegate for BranchListDelegate {
         };
         Some(v_flex().mt_1().child(label).into_any_element())
     }
-
-    fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
-        if self.last_query.is_empty() {
-            return None;
-        }
-
-        Some(
-            h_flex()
-                .p_2()
-                .border_t_1()
-                .border_color(cx.theme().colors().border_variant)
-                .justify_end()
-                .child(h_flex().w_full())
-                .child(
-                    Button::new("branch-picker-create-branch-button", "Create Branch")
-                        .icon(IconName::Plus)
-                        .icon_size(IconSize::Small)
-                        .icon_color(Color::Muted)
-                        .icon_position(IconPosition::Start)
-                        .on_click(cx.listener(|_, _, cx| {
-                            cx.spawn(|picker, mut cx| async move {
-                                picker.update(&mut cx, |this, cx| {
-                                    let project =
-                                        this.delegate.workspace.read(cx).project().read(cx);
-                                    let current_pick = &this.delegate.last_query;
-                                    let repo = project.get_first_worktree_root_repo(cx).context(
-                                        "failed to get root repository for first worktree",
-                                    )?;
-                                    let status = repo.create_branch(current_pick);
-                                    if status.is_err() {
-                                        this.delegate.display_error_toast(format!("Failed to create branch '{current_pick}', check for conflicts or unstashed files"), cx);
-                                        status?;
-                                    }
-                                    let status = repo.change_branch(current_pick);
-                                    if status.is_err() {
-                                        this.delegate.display_error_toast(format!("Failed to check branch '{current_pick}', check for conflicts or unstashed files"), cx);
-                                        status?;
-                                    }
-                                    this.cancel(&Default::default(), cx);
-                                    Ok::<(), anyhow::Error>(())
-                                })
-                            })
-                            .detach_and_log_err(cx);
-                        }))
-                )
-                .into_any_element(),
-        )
-    }
 }