branch_picker.rs

   1use anyhow::Context as _;
   2use editor::Editor;
   3use fuzzy::StringMatchCandidate;
   4
   5use collections::HashSet;
   6use git::repository::Branch;
   7use gpui::http_client::Url;
   8use gpui::{
   9    Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
  10    InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render,
  11    SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems,
  12};
  13use picker::{Picker, PickerDelegate, PickerEditorPosition};
  14use project::git_store::Repository;
  15use project::project_settings::ProjectSettings;
  16use settings::Settings;
  17use std::sync::Arc;
  18use time::OffsetDateTime;
  19use ui::{
  20    Divider, HighlightedLabel, KeyBinding, ListHeader, ListItem, ListItemSpacing, Tooltip,
  21    prelude::*,
  22};
  23use util::ResultExt;
  24use workspace::notifications::DetachAndPromptErr;
  25use workspace::{ModalView, Workspace};
  26
  27use crate::{branch_picker, git_panel::show_error_toast};
  28
  29actions!(
  30    branch_picker,
  31    [
  32        /// Deletes the selected git branch or remote.
  33        DeleteBranch,
  34        /// Filter the list of remotes
  35        FilterRemotes
  36    ]
  37);
  38
  39pub fn checkout_branch(
  40    workspace: &mut Workspace,
  41    _: &zed_actions::git::CheckoutBranch,
  42    window: &mut Window,
  43    cx: &mut Context<Workspace>,
  44) {
  45    open(workspace, &zed_actions::git::Branch, window, cx);
  46}
  47
  48pub fn switch(
  49    workspace: &mut Workspace,
  50    _: &zed_actions::git::Switch,
  51    window: &mut Window,
  52    cx: &mut Context<Workspace>,
  53) {
  54    open(workspace, &zed_actions::git::Branch, window, cx);
  55}
  56
  57pub fn open(
  58    workspace: &mut Workspace,
  59    _: &zed_actions::git::Branch,
  60    window: &mut Window,
  61    cx: &mut Context<Workspace>,
  62) {
  63    let workspace_handle = workspace.weak_handle();
  64    let project = workspace.project().clone();
  65
  66    // Check if there's a worktree override from the project dropdown.
  67    // This ensures the branch picker shows branches for the project the user
  68    // explicitly selected in the title bar, not just the focused file's project.
  69    // This is only relevant if for multi-projects workspaces.
  70    let repository = workspace
  71        .active_worktree_override()
  72        .and_then(|override_id| {
  73            let project_ref = project.read(cx);
  74            project_ref
  75                .worktree_for_id(override_id, cx)
  76                .and_then(|worktree| {
  77                    let worktree_abs_path = worktree.read(cx).abs_path();
  78                    let git_store = project_ref.git_store().read(cx);
  79                    git_store
  80                        .repositories()
  81                        .values()
  82                        .find(|repo| {
  83                            let repo_path = &repo.read(cx).work_directory_abs_path;
  84                            *repo_path == worktree_abs_path
  85                                || worktree_abs_path.starts_with(repo_path.as_ref())
  86                        })
  87                        .cloned()
  88                })
  89        })
  90        .or_else(|| project.read(cx).active_repository(cx));
  91
  92    workspace.toggle_modal(window, cx, |window, cx| {
  93        BranchList::new(
  94            workspace_handle,
  95            repository,
  96            BranchListStyle::Modal,
  97            rems(34.),
  98            window,
  99            cx,
 100        )
 101    })
 102}
 103
 104pub fn popover(
 105    workspace: WeakEntity<Workspace>,
 106    modal_style: bool,
 107    repository: Option<Entity<Repository>>,
 108    window: &mut Window,
 109    cx: &mut App,
 110) -> Entity<BranchList> {
 111    let (style, width) = if modal_style {
 112        (BranchListStyle::Modal, rems(34.))
 113    } else {
 114        (BranchListStyle::Popover, rems(20.))
 115    };
 116
 117    cx.new(|cx| {
 118        let list = BranchList::new(workspace, repository, style, width, window, cx);
 119        list.focus_handle(cx).focus(window, cx);
 120        list
 121    })
 122}
 123
 124pub fn create_embedded(
 125    workspace: WeakEntity<Workspace>,
 126    repository: Option<Entity<Repository>>,
 127    width: Rems,
 128    window: &mut Window,
 129    cx: &mut Context<BranchList>,
 130) -> BranchList {
 131    BranchList::new_embedded(workspace, repository, width, window, cx)
 132}
 133
 134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 135enum BranchListStyle {
 136    Modal,
 137    Popover,
 138}
 139
 140pub struct BranchList {
 141    width: Rems,
 142    pub picker: Entity<Picker<BranchListDelegate>>,
 143    picker_focus_handle: FocusHandle,
 144    _subscription: Option<Subscription>,
 145    embedded: bool,
 146}
 147
 148impl BranchList {
 149    fn new(
 150        workspace: WeakEntity<Workspace>,
 151        repository: Option<Entity<Repository>>,
 152        style: BranchListStyle,
 153        width: Rems,
 154        window: &mut Window,
 155        cx: &mut Context<Self>,
 156    ) -> Self {
 157        let mut this = Self::new_inner(workspace, repository, style, width, false, window, cx);
 158        this._subscription = Some(cx.subscribe(&this.picker, |_, _, _, cx| {
 159            cx.emit(DismissEvent);
 160        }));
 161        this
 162    }
 163
 164    fn new_inner(
 165        workspace: WeakEntity<Workspace>,
 166        repository: Option<Entity<Repository>>,
 167        style: BranchListStyle,
 168        width: Rems,
 169        embedded: bool,
 170        window: &mut Window,
 171        cx: &mut Context<Self>,
 172    ) -> Self {
 173        let all_branches_request = repository
 174            .clone()
 175            .map(|repository| repository.update(cx, |repository, _| repository.branches()));
 176
 177        let default_branch_request = repository.clone().map(|repository| {
 178            repository.update(cx, |repository, _| repository.default_branch(false))
 179        });
 180
 181        cx.spawn_in(window, async move |this, cx| {
 182            let mut all_branches = all_branches_request
 183                .context("No active repository")?
 184                .await??;
 185            let default_branch = default_branch_request
 186                .context("No active repository")?
 187                .await
 188                .map(Result::ok)
 189                .ok()
 190                .flatten()
 191                .flatten();
 192
 193            let all_branches = cx
 194                .background_spawn(async move {
 195                    let remote_upstreams: HashSet<_> = all_branches
 196                        .iter()
 197                        .filter_map(|branch| {
 198                            branch
 199                                .upstream
 200                                .as_ref()
 201                                .filter(|upstream| upstream.is_remote())
 202                                .map(|upstream| upstream.ref_name.clone())
 203                        })
 204                        .collect();
 205
 206                    all_branches.retain(|branch| !remote_upstreams.contains(&branch.ref_name));
 207
 208                    all_branches.sort_by_key(|branch| {
 209                        (
 210                            !branch.is_head, // Current branch (is_head=true) comes first
 211                            branch
 212                                .most_recent_commit
 213                                .as_ref()
 214                                .map(|commit| 0 - commit.commit_timestamp),
 215                        )
 216                    });
 217
 218                    all_branches
 219                })
 220                .await;
 221
 222            let _ = this.update_in(cx, |this, window, cx| {
 223                this.picker.update(cx, |picker, cx| {
 224                    picker.delegate.default_branch = default_branch;
 225                    picker.delegate.all_branches = Some(all_branches);
 226                    picker.refresh(window, cx);
 227                })
 228            });
 229
 230            anyhow::Ok(())
 231        })
 232        .detach_and_log_err(cx);
 233
 234        let delegate = BranchListDelegate::new(workspace, repository, style, cx);
 235        let picker = cx.new(|cx| {
 236            Picker::uniform_list(delegate, window, cx)
 237                .show_scrollbar(true)
 238                .modal(!embedded)
 239        });
 240        let picker_focus_handle = picker.focus_handle(cx);
 241
 242        picker.update(cx, |picker, _| {
 243            picker.delegate.focus_handle = picker_focus_handle.clone();
 244        });
 245
 246        Self {
 247            picker,
 248            picker_focus_handle,
 249            width,
 250            _subscription: None,
 251            embedded,
 252        }
 253    }
 254
 255    fn new_embedded(
 256        workspace: WeakEntity<Workspace>,
 257        repository: Option<Entity<Repository>>,
 258        width: Rems,
 259        window: &mut Window,
 260        cx: &mut Context<Self>,
 261    ) -> Self {
 262        let mut this = Self::new_inner(
 263            workspace,
 264            repository,
 265            BranchListStyle::Modal,
 266            width,
 267            true,
 268            window,
 269            cx,
 270        );
 271        this._subscription = Some(cx.subscribe(&this.picker, |_, _, _, cx| {
 272            cx.emit(DismissEvent);
 273        }));
 274        this
 275    }
 276
 277    pub fn handle_modifiers_changed(
 278        &mut self,
 279        ev: &ModifiersChangedEvent,
 280        _: &mut Window,
 281        cx: &mut Context<Self>,
 282    ) {
 283        self.picker
 284            .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
 285    }
 286
 287    pub fn handle_delete(
 288        &mut self,
 289        _: &branch_picker::DeleteBranch,
 290        window: &mut Window,
 291        cx: &mut Context<Self>,
 292    ) {
 293        self.picker.update(cx, |picker, cx| {
 294            picker
 295                .delegate
 296                .delete_at(picker.delegate.selected_index, window, cx)
 297        })
 298    }
 299
 300    pub fn handle_filter(
 301        &mut self,
 302        _: &branch_picker::FilterRemotes,
 303        window: &mut Window,
 304        cx: &mut Context<Self>,
 305    ) {
 306        self.picker.update(cx, |picker, cx| {
 307            picker.delegate.branch_filter = picker.delegate.branch_filter.invert();
 308            picker.update_matches(picker.query(cx), window, cx);
 309            picker.refresh_placeholder(window, cx);
 310            cx.notify();
 311        });
 312    }
 313}
 314impl ModalView for BranchList {}
 315impl EventEmitter<DismissEvent> for BranchList {}
 316
 317impl Focusable for BranchList {
 318    fn focus_handle(&self, _cx: &App) -> FocusHandle {
 319        self.picker_focus_handle.clone()
 320    }
 321}
 322
 323impl Render for BranchList {
 324    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 325        v_flex()
 326            .key_context("GitBranchSelector")
 327            .w(self.width)
 328            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
 329            .on_action(cx.listener(Self::handle_delete))
 330            .on_action(cx.listener(Self::handle_filter))
 331            .child(self.picker.clone())
 332            .when(!self.embedded, |this| {
 333                this.on_mouse_down_out({
 334                    cx.listener(move |this, _, window, cx| {
 335                        this.picker.update(cx, |this, cx| {
 336                            this.cancel(&Default::default(), window, cx);
 337                        })
 338                    })
 339                })
 340            })
 341    }
 342}
 343
 344#[derive(Debug, Clone, PartialEq)]
 345enum Entry {
 346    Branch {
 347        branch: Branch,
 348        positions: Vec<usize>,
 349    },
 350    NewUrl {
 351        url: String,
 352    },
 353    NewBranch {
 354        name: String,
 355    },
 356    NewRemoteName {
 357        name: String,
 358        url: SharedString,
 359    },
 360}
 361
 362impl Entry {
 363    fn as_branch(&self) -> Option<&Branch> {
 364        match self {
 365            Entry::Branch { branch, .. } => Some(branch),
 366            _ => None,
 367        }
 368    }
 369
 370    fn name(&self) -> &str {
 371        match self {
 372            Entry::Branch { branch, .. } => branch.name(),
 373            Entry::NewUrl { url, .. } => url.as_str(),
 374            Entry::NewBranch { name, .. } => name.as_str(),
 375            Entry::NewRemoteName { name, .. } => name.as_str(),
 376        }
 377    }
 378
 379    #[cfg(test)]
 380    fn is_new_url(&self) -> bool {
 381        matches!(self, Self::NewUrl { .. })
 382    }
 383
 384    #[cfg(test)]
 385    fn is_new_branch(&self) -> bool {
 386        matches!(self, Self::NewBranch { .. })
 387    }
 388}
 389
 390#[derive(Clone, Copy, PartialEq)]
 391enum BranchFilter {
 392    /// Show both local and remote branches.
 393    All,
 394    /// Only show remote branches.
 395    Remote,
 396}
 397
 398impl BranchFilter {
 399    fn invert(&self) -> Self {
 400        match self {
 401            BranchFilter::All => BranchFilter::Remote,
 402            BranchFilter::Remote => BranchFilter::All,
 403        }
 404    }
 405}
 406
 407pub struct BranchListDelegate {
 408    workspace: WeakEntity<Workspace>,
 409    matches: Vec<Entry>,
 410    all_branches: Option<Vec<Branch>>,
 411    default_branch: Option<SharedString>,
 412    repo: Option<Entity<Repository>>,
 413    style: BranchListStyle,
 414    selected_index: usize,
 415    last_query: String,
 416    modifiers: Modifiers,
 417    branch_filter: BranchFilter,
 418    state: PickerState,
 419    focus_handle: FocusHandle,
 420}
 421
 422#[derive(Debug)]
 423enum PickerState {
 424    /// When we display list of branches/remotes
 425    List,
 426    /// When we set an url to create a new remote
 427    NewRemote,
 428    /// When we confirm the new remote url (after NewRemote)
 429    CreateRemote(SharedString),
 430    /// When we set a new branch to create
 431    NewBranch,
 432}
 433
 434impl BranchListDelegate {
 435    fn new(
 436        workspace: WeakEntity<Workspace>,
 437        repo: Option<Entity<Repository>>,
 438        style: BranchListStyle,
 439        cx: &mut Context<BranchList>,
 440    ) -> Self {
 441        Self {
 442            workspace,
 443            matches: vec![],
 444            repo,
 445            style,
 446            all_branches: None,
 447            default_branch: None,
 448            selected_index: 0,
 449            last_query: Default::default(),
 450            modifiers: Default::default(),
 451            branch_filter: BranchFilter::All,
 452            state: PickerState::List,
 453            focus_handle: cx.focus_handle(),
 454        }
 455    }
 456
 457    fn create_branch(
 458        &self,
 459        from_branch: Option<SharedString>,
 460        new_branch_name: SharedString,
 461        window: &mut Window,
 462        cx: &mut Context<Picker<Self>>,
 463    ) {
 464        let Some(repo) = self.repo.clone() else {
 465            return;
 466        };
 467        let new_branch_name = new_branch_name.to_string().replace(' ', "-");
 468        let base_branch = from_branch.map(|b| b.to_string());
 469        cx.spawn(async move |_, cx| {
 470            repo.update(cx, |repo, _| {
 471                repo.create_branch(new_branch_name, base_branch)
 472            })
 473            .await??;
 474
 475            Ok(())
 476        })
 477        .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| {
 478            Some(e.to_string())
 479        });
 480        cx.emit(DismissEvent);
 481    }
 482
 483    fn create_remote(
 484        &self,
 485        remote_name: String,
 486        remote_url: String,
 487        window: &mut Window,
 488        cx: &mut Context<Picker<Self>>,
 489    ) {
 490        let Some(repo) = self.repo.clone() else {
 491            return;
 492        };
 493
 494        let receiver = repo.update(cx, |repo, _| repo.create_remote(remote_name, remote_url));
 495
 496        cx.background_spawn(async move { receiver.await? })
 497            .detach_and_prompt_err("Failed to create remote", window, cx, |e, _, _cx| {
 498                Some(e.to_string())
 499            });
 500        cx.emit(DismissEvent);
 501    }
 502
 503    fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
 504        let Some(entry) = self.matches.get(idx).cloned() else {
 505            return;
 506        };
 507        let Some(repo) = self.repo.clone() else {
 508            return;
 509        };
 510
 511        let workspace = self.workspace.clone();
 512
 513        cx.spawn_in(window, async move |picker, cx| {
 514            let mut is_remote = false;
 515            let result = match &entry {
 516                Entry::Branch { branch, .. } => match branch.remote_name() {
 517                    Some(remote_name) => {
 518                        is_remote = true;
 519                        repo.update(cx, |repo, _| repo.remove_remote(remote_name.to_string()))
 520                            .await?
 521                    }
 522                    None => {
 523                        repo.update(cx, |repo, _| repo.delete_branch(branch.name().to_string()))
 524                            .await?
 525                    }
 526                },
 527                _ => {
 528                    log::error!("Failed to delete remote: wrong entry to delete");
 529                    return Ok(());
 530                }
 531            };
 532
 533            if let Err(e) = result {
 534                if is_remote {
 535                    log::error!("Failed to delete remote: {}", e);
 536                } else {
 537                    log::error!("Failed to delete branch: {}", e);
 538                }
 539
 540                if let Some(workspace) = workspace.upgrade() {
 541                    cx.update(|_window, cx| {
 542                        if is_remote {
 543                            show_error_toast(
 544                                workspace,
 545                                format!("remote remove {}", entry.name()),
 546                                e,
 547                                cx,
 548                            )
 549                        } else {
 550                            show_error_toast(
 551                                workspace,
 552                                format!("branch -d {}", entry.name()),
 553                                e,
 554                                cx,
 555                            )
 556                        }
 557                    })?;
 558                }
 559
 560                return Ok(());
 561            }
 562
 563            picker.update_in(cx, |picker, _, cx| {
 564                picker.delegate.matches.retain(|e| e != &entry);
 565
 566                if let Entry::Branch { branch, .. } = &entry {
 567                    if let Some(all_branches) = &mut picker.delegate.all_branches {
 568                        all_branches.retain(|e| e.ref_name != branch.ref_name);
 569                    }
 570                }
 571
 572                if picker.delegate.matches.is_empty() {
 573                    picker.delegate.selected_index = 0;
 574                } else if picker.delegate.selected_index >= picker.delegate.matches.len() {
 575                    picker.delegate.selected_index = picker.delegate.matches.len() - 1;
 576                }
 577
 578                cx.notify();
 579            })?;
 580
 581            anyhow::Ok(())
 582        })
 583        .detach();
 584    }
 585}
 586
 587impl PickerDelegate for BranchListDelegate {
 588    type ListItem = ListItem;
 589
 590    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
 591        match self.state {
 592            PickerState::List | PickerState::NewRemote | PickerState::NewBranch => {
 593                match self.branch_filter {
 594                    BranchFilter::All => "Select branch or remote…",
 595                    BranchFilter::Remote => "Select remote…",
 596                }
 597            }
 598            PickerState::CreateRemote(_) => "Enter a name for this remote…",
 599        }
 600        .into()
 601    }
 602
 603    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
 604        match self.state {
 605            PickerState::CreateRemote(_) => {
 606                Some(SharedString::new_static("Remote name can't be empty"))
 607            }
 608            _ => None,
 609        }
 610    }
 611
 612    fn render_editor(
 613        &self,
 614        editor: &Entity<Editor>,
 615        _window: &mut Window,
 616        _cx: &mut Context<Picker<Self>>,
 617    ) -> Div {
 618        let focus_handle = self.focus_handle.clone();
 619
 620        v_flex()
 621            .when(
 622                self.editor_position() == PickerEditorPosition::End,
 623                |this| this.child(Divider::horizontal()),
 624            )
 625            .child(
 626                h_flex()
 627                    .overflow_hidden()
 628                    .flex_none()
 629                    .h_9()
 630                    .px_2p5()
 631                    .child(editor.clone())
 632                    .when(
 633                        self.editor_position() == PickerEditorPosition::End,
 634                        |this| {
 635                            let tooltip_label = match self.branch_filter {
 636                                BranchFilter::All => "Filter Remote Branches",
 637                                BranchFilter::Remote => "Show All Branches",
 638                            };
 639
 640                            this.gap_1().justify_between().child({
 641                                IconButton::new("filter-remotes", IconName::Filter)
 642                                    .toggle_state(self.branch_filter == BranchFilter::Remote)
 643                                    .tooltip(move |_, cx| {
 644                                        Tooltip::for_action_in(
 645                                            tooltip_label,
 646                                            &branch_picker::FilterRemotes,
 647                                            &focus_handle,
 648                                            cx,
 649                                        )
 650                                    })
 651                                    .on_click(|_click, window, cx| {
 652                                        window.dispatch_action(
 653                                            branch_picker::FilterRemotes.boxed_clone(),
 654                                            cx,
 655                                        );
 656                                    })
 657                            })
 658                        },
 659                    ),
 660            )
 661            .when(
 662                self.editor_position() == PickerEditorPosition::Start,
 663                |this| this.child(Divider::horizontal()),
 664            )
 665    }
 666
 667    fn editor_position(&self) -> PickerEditorPosition {
 668        match self.style {
 669            BranchListStyle::Modal => PickerEditorPosition::Start,
 670            BranchListStyle::Popover => PickerEditorPosition::End,
 671        }
 672    }
 673
 674    fn match_count(&self) -> usize {
 675        self.matches.len()
 676    }
 677
 678    fn selected_index(&self) -> usize {
 679        self.selected_index
 680    }
 681
 682    fn set_selected_index(
 683        &mut self,
 684        ix: usize,
 685        _window: &mut Window,
 686        _: &mut Context<Picker<Self>>,
 687    ) {
 688        self.selected_index = ix;
 689    }
 690
 691    fn update_matches(
 692        &mut self,
 693        query: String,
 694        window: &mut Window,
 695        cx: &mut Context<Picker<Self>>,
 696    ) -> Task<()> {
 697        let Some(all_branches) = self.all_branches.clone() else {
 698            return Task::ready(());
 699        };
 700
 701        let branch_filter = self.branch_filter;
 702        cx.spawn_in(window, async move |picker, cx| {
 703            let branch_matches_filter = |branch: &Branch| match branch_filter {
 704                BranchFilter::All => true,
 705                BranchFilter::Remote => branch.is_remote(),
 706            };
 707
 708            let mut matches: Vec<Entry> = if query.is_empty() {
 709                let mut matches: Vec<Entry> = all_branches
 710                    .into_iter()
 711                    .filter(|branch| branch_matches_filter(branch))
 712                    .map(|branch| Entry::Branch {
 713                        branch,
 714                        positions: Vec::new(),
 715                    })
 716                    .collect();
 717
 718                // Keep the existing recency sort within each group, but show local branches first.
 719                matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote()));
 720
 721                matches
 722            } else {
 723                let branches = all_branches
 724                    .iter()
 725                    .filter(|branch| branch_matches_filter(branch))
 726                    .collect::<Vec<_>>();
 727                let candidates = branches
 728                    .iter()
 729                    .enumerate()
 730                    .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
 731                    .collect::<Vec<StringMatchCandidate>>();
 732                let mut matches: Vec<Entry> = fuzzy::match_strings(
 733                    &candidates,
 734                    &query,
 735                    true,
 736                    true,
 737                    10000,
 738                    &Default::default(),
 739                    cx.background_executor().clone(),
 740                )
 741                .await
 742                .into_iter()
 743                .map(|candidate| Entry::Branch {
 744                    branch: branches[candidate.candidate_id].clone(),
 745                    positions: candidate.positions,
 746                })
 747                .collect();
 748
 749                // Keep fuzzy-relevance ordering within local/remote groups, but show locals first.
 750                matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote()));
 751
 752                matches
 753            };
 754            picker
 755                .update(cx, |picker, _| {
 756                    if let PickerState::CreateRemote(url) = &picker.delegate.state {
 757                        let query = query.replace(' ', "-");
 758                        if !query.is_empty() {
 759                            picker.delegate.matches = vec![Entry::NewRemoteName {
 760                                name: query.clone(),
 761                                url: url.clone(),
 762                            }];
 763                            picker.delegate.selected_index = 0;
 764                        } else {
 765                            picker.delegate.matches = Vec::new();
 766                            picker.delegate.selected_index = 0;
 767                        }
 768                        picker.delegate.last_query = query;
 769                        return;
 770                    }
 771
 772                    if !query.is_empty()
 773                        && !matches.first().is_some_and(|entry| entry.name() == query)
 774                    {
 775                        let query = query.replace(' ', "-");
 776                        let is_url = query.trim_start_matches("git@").parse::<Url>().is_ok();
 777                        let entry = if is_url {
 778                            Entry::NewUrl { url: query }
 779                        } else {
 780                            Entry::NewBranch { name: query }
 781                        };
 782                        // Only transition to NewBranch/NewRemote states when we only show their list item
 783                        // Otherwise, stay in List state so footer buttons remain visible
 784                        picker.delegate.state = if matches.is_empty() {
 785                            if is_url {
 786                                PickerState::NewRemote
 787                            } else {
 788                                PickerState::NewBranch
 789                            }
 790                        } else {
 791                            PickerState::List
 792                        };
 793                        matches.push(entry);
 794                    } else {
 795                        picker.delegate.state = PickerState::List;
 796                    }
 797                    let delegate = &mut picker.delegate;
 798                    delegate.matches = matches;
 799                    if delegate.matches.is_empty() {
 800                        delegate.selected_index = 0;
 801                    } else {
 802                        delegate.selected_index =
 803                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
 804                    }
 805                    delegate.last_query = query;
 806                })
 807                .log_err();
 808        })
 809    }
 810
 811    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
 812        let Some(entry) = self.matches.get(self.selected_index()) else {
 813            return;
 814        };
 815
 816        match entry {
 817            Entry::Branch { branch, .. } => {
 818                let current_branch = self.repo.as_ref().map(|repo| {
 819                    repo.read_with(cx, |repo, _| {
 820                        repo.branch.as_ref().map(|branch| branch.ref_name.clone())
 821                    })
 822                });
 823
 824                if current_branch
 825                    .flatten()
 826                    .is_some_and(|current_branch| current_branch == branch.ref_name)
 827                {
 828                    cx.emit(DismissEvent);
 829                    return;
 830                }
 831
 832                let Some(repo) = self.repo.clone() else {
 833                    return;
 834                };
 835
 836                let branch = branch.clone();
 837                cx.spawn(async move |_, cx| {
 838                    repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))
 839                        .await??;
 840
 841                    anyhow::Ok(())
 842                })
 843                .detach_and_prompt_err(
 844                    "Failed to change branch",
 845                    window,
 846                    cx,
 847                    |_, _, _| None,
 848                );
 849            }
 850            Entry::NewUrl { url } => {
 851                self.state = PickerState::CreateRemote(url.clone().into());
 852                self.matches = Vec::new();
 853                self.selected_index = 0;
 854
 855                cx.defer_in(window, |picker, window, cx| {
 856                    picker.refresh_placeholder(window, cx);
 857                    picker.set_query("", window, cx);
 858                    cx.notify();
 859                });
 860
 861                // returning early to prevent dismissing the modal, so a user can enter
 862                // a remote name first.
 863                return;
 864            }
 865            Entry::NewRemoteName { name, url } => {
 866                self.create_remote(name.clone(), url.to_string(), window, cx);
 867            }
 868            Entry::NewBranch { name } => {
 869                let from_branch = if secondary {
 870                    self.default_branch.clone()
 871                } else {
 872                    None
 873                };
 874                self.create_branch(from_branch, name.into(), window, cx);
 875            }
 876        }
 877
 878        cx.emit(DismissEvent);
 879    }
 880
 881    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
 882        self.state = PickerState::List;
 883        cx.emit(DismissEvent);
 884    }
 885
 886    fn render_match(
 887        &self,
 888        ix: usize,
 889        selected: bool,
 890        _window: &mut Window,
 891        cx: &mut Context<Picker<Self>>,
 892    ) -> Option<Self::ListItem> {
 893        let entry = &self.matches.get(ix)?;
 894
 895        let (commit_time, author_name, subject) = entry
 896            .as_branch()
 897            .and_then(|branch| {
 898                branch.most_recent_commit.as_ref().map(|commit| {
 899                    let subject = commit.subject.clone();
 900                    let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
 901                        .unwrap_or_else(|_| OffsetDateTime::now_utc());
 902                    let local_offset =
 903                        time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
 904                    let formatted_time = time_format::format_localized_timestamp(
 905                        commit_time,
 906                        OffsetDateTime::now_utc(),
 907                        local_offset,
 908                        time_format::TimestampFormat::Relative,
 909                    );
 910                    let author = commit.author_name.clone();
 911                    (Some(formatted_time), Some(author), Some(subject))
 912                })
 913            })
 914            .unwrap_or_else(|| (None, None, None));
 915
 916        let entry_icon = match entry {
 917            Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => {
 918                Icon::new(IconName::Plus).color(Color::Muted)
 919            }
 920            Entry::Branch { branch, .. } => {
 921                if branch.is_remote() {
 922                    Icon::new(IconName::Screen).color(Color::Muted)
 923                } else {
 924                    Icon::new(IconName::GitBranchAlt).color(Color::Muted)
 925                }
 926            }
 927        };
 928
 929        let entry_title = match entry {
 930            Entry::NewUrl { .. } => Label::new("Create Remote Repository")
 931                .single_line()
 932                .truncate()
 933                .into_any_element(),
 934            Entry::NewBranch { name } => Label::new(format!("Create Branch: \"{name}\""))
 935                .single_line()
 936                .truncate()
 937                .into_any_element(),
 938            Entry::NewRemoteName { name, .. } => Label::new(format!("Create Remote: \"{name}\""))
 939                .single_line()
 940                .truncate()
 941                .into_any_element(),
 942            Entry::Branch { branch, positions } => {
 943                HighlightedLabel::new(branch.name().to_string(), positions.clone())
 944                    .single_line()
 945                    .truncate()
 946                    .into_any_element()
 947            }
 948        };
 949
 950        let focus_handle = self.focus_handle.clone();
 951        let is_new_items = matches!(
 952            entry,
 953            Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. }
 954        );
 955
 956        let deleted_branch_icon = |entry_ix: usize, is_head_branch: bool| {
 957            IconButton::new(("delete", entry_ix), IconName::Trash)
 958                .tooltip(move |_, cx| {
 959                    Tooltip::for_action_in(
 960                        "Delete Branch",
 961                        &branch_picker::DeleteBranch,
 962                        &focus_handle,
 963                        cx,
 964                    )
 965                })
 966                .disabled(is_head_branch)
 967                .on_click(cx.listener(move |this, _, window, cx| {
 968                    this.delegate.delete_at(entry_ix, window, cx);
 969                }))
 970        };
 971
 972        let create_from_default_button = self.default_branch.as_ref().map(|default_branch| {
 973            let tooltip_label: SharedString = format!("Create New From: {default_branch}").into();
 974            let focus_handle = self.focus_handle.clone();
 975
 976            IconButton::new("create_from_default", IconName::GitBranchPlus)
 977                .tooltip(move |_, cx| {
 978                    Tooltip::for_action_in(
 979                        tooltip_label.clone(),
 980                        &menu::SecondaryConfirm,
 981                        &focus_handle,
 982                        cx,
 983                    )
 984                })
 985                .on_click(cx.listener(|this, _, window, cx| {
 986                    this.delegate.confirm(true, window, cx);
 987                }))
 988                .into_any_element()
 989        });
 990
 991        Some(
 992            ListItem::new(format!("vcs-menu-{ix}"))
 993                .inset(true)
 994                .spacing(ListItemSpacing::Sparse)
 995                .toggle_state(selected)
 996                .child(
 997                    h_flex()
 998                        .w_full()
 999                        .gap_3()
1000                        .flex_grow()
1001                        .child(entry_icon)
1002                        .child(
1003                            v_flex()
1004                                .id("info_container")
1005                                .w_full()
1006                                .child(entry_title)
1007                                .child(
1008                                    h_flex()
1009                                        .w_full()
1010                                        .justify_between()
1011                                        .gap_1p5()
1012                                        .when(self.style == BranchListStyle::Modal, |el| {
1013                                            el.child(div().max_w_96().child({
1014                                                let message = match entry {
1015                                                    Entry::NewUrl { url } => {
1016                                                        format!("Based off {url}")
1017                                                    }
1018                                                    Entry::NewRemoteName { url, .. } => {
1019                                                        format!("Based off {url}")
1020                                                    }
1021                                                    Entry::NewBranch { .. } => {
1022                                                        if let Some(current_branch) =
1023                                                            self.repo.as_ref().and_then(|repo| {
1024                                                                repo.read(cx)
1025                                                                    .branch
1026                                                                    .as_ref()
1027                                                                    .map(|b| b.name())
1028                                                            })
1029                                                        {
1030                                                            format!("Based off {}", current_branch)
1031                                                        } else {
1032                                                            "Based off the current branch"
1033                                                                .to_string()
1034                                                        }
1035                                                    }
1036                                                    Entry::Branch { .. } => {
1037                                                        let show_author_name =
1038                                                            ProjectSettings::get_global(cx)
1039                                                                .git
1040                                                                .branch_picker
1041                                                                .show_author_name;
1042
1043                                                        subject.map_or(
1044                                                            "No commits found".into(),
1045                                                            |subject| {
1046                                                                if show_author_name
1047                                                                    && let Some(author) =
1048                                                                        author_name
1049                                                                {
1050                                                                    format!(
1051                                                                        "{}{}",
1052                                                                        author, subject
1053                                                                    )
1054                                                                } else {
1055                                                                    subject.to_string()
1056                                                                }
1057                                                            },
1058                                                        )
1059                                                    }
1060                                                };
1061
1062                                                Label::new(message)
1063                                                    .size(LabelSize::Small)
1064                                                    .color(Color::Muted)
1065                                                    .truncate()
1066                                            }))
1067                                        })
1068                                        .when_some(commit_time, |label, commit_time| {
1069                                            label.child(
1070                                                Label::new(commit_time)
1071                                                    .size(LabelSize::Small)
1072                                                    .color(Color::Muted),
1073                                            )
1074                                        }),
1075                                )
1076                                .when_some(
1077                                    entry.as_branch().map(|b| b.name().to_string()),
1078                                    |this, branch_name| this.tooltip(Tooltip::text(branch_name)),
1079                                ),
1080                        ),
1081                )
1082                .when(
1083                    self.editor_position() == PickerEditorPosition::End && !is_new_items,
1084                    |this| {
1085                        this.map(|this| {
1086                            let is_head_branch =
1087                                entry.as_branch().is_some_and(|branch| branch.is_head);
1088                            if self.selected_index() == ix {
1089                                this.end_slot(deleted_branch_icon(ix, is_head_branch))
1090                            } else {
1091                                this.end_hover_slot(deleted_branch_icon(ix, is_head_branch))
1092                            }
1093                        })
1094                    },
1095                )
1096                .when_some(
1097                    if self.editor_position() == PickerEditorPosition::End && is_new_items {
1098                        create_from_default_button
1099                    } else {
1100                        None
1101                    },
1102                    |this, create_from_default_button| {
1103                        this.map(|this| {
1104                            if self.selected_index() == ix {
1105                                this.end_slot(create_from_default_button)
1106                            } else {
1107                                this.end_hover_slot(create_from_default_button)
1108                            }
1109                        })
1110                    },
1111                ),
1112        )
1113    }
1114
1115    fn render_header(
1116        &self,
1117        _window: &mut Window,
1118        _cx: &mut Context<Picker<Self>>,
1119    ) -> Option<AnyElement> {
1120        matches!(self.state, PickerState::List).then(|| {
1121            let label = match self.branch_filter {
1122                BranchFilter::All => "Branches",
1123                BranchFilter::Remote => "Remotes",
1124            };
1125
1126            ListHeader::new(label).inset(true).into_any_element()
1127        })
1128    }
1129
1130    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1131        if self.editor_position() == PickerEditorPosition::End {
1132            return None;
1133        }
1134        let focus_handle = self.focus_handle.clone();
1135
1136        let footer_container = || {
1137            h_flex()
1138                .w_full()
1139                .p_1p5()
1140                .border_t_1()
1141                .border_color(cx.theme().colors().border_variant)
1142        };
1143
1144        match self.state {
1145            PickerState::List => {
1146                let selected_entry = self.matches.get(self.selected_index);
1147
1148                let branch_from_default_button = self
1149                    .default_branch
1150                    .as_ref()
1151                    .filter(|_| matches!(selected_entry, Some(Entry::NewBranch { .. })))
1152                    .map(|default_branch| {
1153                        let button_label = format!("Create New From: {default_branch}");
1154
1155                        Button::new("branch-from-default", button_label)
1156                            .key_binding(
1157                                KeyBinding::for_action_in(
1158                                    &menu::SecondaryConfirm,
1159                                    &focus_handle,
1160                                    cx,
1161                                )
1162                                .map(|kb| kb.size(rems_from_px(12.))),
1163                            )
1164                            .on_click(cx.listener(|this, _, window, cx| {
1165                                this.delegate.confirm(true, window, cx);
1166                            }))
1167                    });
1168
1169                let delete_and_select_btns = h_flex()
1170                    .gap_1()
1171                    .child(
1172                        Button::new("delete-branch", "Delete")
1173                            .key_binding(
1174                                KeyBinding::for_action_in(
1175                                    &branch_picker::DeleteBranch,
1176                                    &focus_handle,
1177                                    cx,
1178                                )
1179                                .map(|kb| kb.size(rems_from_px(12.))),
1180                            )
1181                            .on_click(|_, window, cx| {
1182                                window
1183                                    .dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx);
1184                            }),
1185                    )
1186                    .child(
1187                        Button::new("select_branch", "Select")
1188                            .key_binding(
1189                                KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1190                                    .map(|kb| kb.size(rems_from_px(12.))),
1191                            )
1192                            .on_click(cx.listener(|this, _, window, cx| {
1193                                this.delegate.confirm(false, window, cx);
1194                            })),
1195                    );
1196
1197                Some(
1198                    footer_container()
1199                        .map(|this| {
1200                            if branch_from_default_button.is_some() {
1201                                this.justify_end().when_some(
1202                                    branch_from_default_button,
1203                                    |this, button| {
1204                                        this.child(button).child(
1205                                            Button::new("create", "Create")
1206                                                .key_binding(
1207                                                    KeyBinding::for_action_in(
1208                                                        &menu::Confirm,
1209                                                        &focus_handle,
1210                                                        cx,
1211                                                    )
1212                                                    .map(|kb| kb.size(rems_from_px(12.))),
1213                                                )
1214                                                .on_click(cx.listener(|this, _, window, cx| {
1215                                                    this.delegate.confirm(false, window, cx);
1216                                                })),
1217                                        )
1218                                    },
1219                                )
1220                            } else {
1221                                this.justify_between()
1222                                    .child({
1223                                        let focus_handle = focus_handle.clone();
1224                                        Button::new("filter-remotes", "Filter Remotes")
1225                                            .toggle_state(matches!(
1226                                                self.branch_filter,
1227                                                BranchFilter::Remote
1228                                            ))
1229                                            .key_binding(
1230                                                KeyBinding::for_action_in(
1231                                                    &branch_picker::FilterRemotes,
1232                                                    &focus_handle,
1233                                                    cx,
1234                                                )
1235                                                .map(|kb| kb.size(rems_from_px(12.))),
1236                                            )
1237                                            .on_click(|_click, window, cx| {
1238                                                window.dispatch_action(
1239                                                    branch_picker::FilterRemotes.boxed_clone(),
1240                                                    cx,
1241                                                );
1242                                            })
1243                                    })
1244                                    .child(delete_and_select_btns)
1245                            }
1246                        })
1247                        .into_any_element(),
1248                )
1249            }
1250            PickerState::NewBranch => {
1251                let branch_from_default_button =
1252                    self.default_branch.as_ref().map(|default_branch| {
1253                        let button_label = format!("Create New From: {default_branch}");
1254
1255                        Button::new("branch-from-default", button_label)
1256                            .key_binding(
1257                                KeyBinding::for_action_in(
1258                                    &menu::SecondaryConfirm,
1259                                    &focus_handle,
1260                                    cx,
1261                                )
1262                                .map(|kb| kb.size(rems_from_px(12.))),
1263                            )
1264                            .on_click(cx.listener(|this, _, window, cx| {
1265                                this.delegate.confirm(true, window, cx);
1266                            }))
1267                    });
1268
1269                Some(
1270                    footer_container()
1271                        .gap_1()
1272                        .justify_end()
1273                        .when_some(branch_from_default_button, |this, button| {
1274                            this.child(button)
1275                        })
1276                        .child(
1277                            Button::new("branch-from-default", "Create")
1278                                .key_binding(
1279                                    KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1280                                        .map(|kb| kb.size(rems_from_px(12.))),
1281                                )
1282                                .on_click(cx.listener(|this, _, window, cx| {
1283                                    this.delegate.confirm(false, window, cx);
1284                                })),
1285                        )
1286                        .into_any_element(),
1287                )
1288            }
1289            PickerState::CreateRemote(_) => Some(
1290                footer_container()
1291                    .justify_end()
1292                    .child(
1293                        Button::new("branch-from-default", "Confirm")
1294                            .key_binding(
1295                                KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1296                                    .map(|kb| kb.size(rems_from_px(12.))),
1297                            )
1298                            .on_click(cx.listener(|this, _, window, cx| {
1299                                this.delegate.confirm(false, window, cx);
1300                            }))
1301                            .disabled(self.last_query.is_empty()),
1302                    )
1303                    .into_any_element(),
1304            ),
1305            PickerState::NewRemote => None,
1306        }
1307    }
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312    use std::collections::HashSet;
1313
1314    use super::*;
1315    use git::repository::{CommitSummary, Remote};
1316    use gpui::{AppContext, TestAppContext, VisualTestContext};
1317    use project::{FakeFs, Project};
1318    use rand::{Rng, rngs::StdRng};
1319    use serde_json::json;
1320    use settings::SettingsStore;
1321    use util::path;
1322
1323    fn init_test(cx: &mut TestAppContext) {
1324        cx.update(|cx| {
1325            let settings_store = SettingsStore::test(cx);
1326            cx.set_global(settings_store);
1327            theme::init(theme::LoadThemes::JustBase, cx);
1328        });
1329    }
1330
1331    fn create_test_branch(
1332        name: &str,
1333        is_head: bool,
1334        remote_name: Option<&str>,
1335        timestamp: Option<i64>,
1336    ) -> Branch {
1337        let ref_name = match remote_name {
1338            Some(remote_name) => format!("refs/remotes/{remote_name}/{name}"),
1339            None => format!("refs/heads/{name}"),
1340        };
1341
1342        Branch {
1343            is_head,
1344            ref_name: ref_name.into(),
1345            upstream: None,
1346            most_recent_commit: timestamp.map(|ts| CommitSummary {
1347                sha: "abc123".into(),
1348                commit_timestamp: ts,
1349                author_name: "Test Author".into(),
1350                subject: "Test commit".into(),
1351                has_parent: true,
1352            }),
1353        }
1354    }
1355
1356    fn create_test_branches() -> Vec<Branch> {
1357        vec![
1358            create_test_branch("main", true, None, Some(1000)),
1359            create_test_branch("feature-auth", false, None, Some(900)),
1360            create_test_branch("feature-ui", false, None, Some(800)),
1361            create_test_branch("develop", false, None, Some(700)),
1362        ]
1363    }
1364
1365    async fn init_branch_list_test(
1366        repository: Option<Entity<Repository>>,
1367        branches: Vec<Branch>,
1368        cx: &mut TestAppContext,
1369    ) -> (Entity<BranchList>, VisualTestContext) {
1370        let fs = FakeFs::new(cx.executor());
1371        let project = Project::test(fs, [], cx).await;
1372
1373        let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
1374
1375        let branch_list = workspace
1376            .update(cx, |workspace, window, cx| {
1377                cx.new(|cx| {
1378                    let mut delegate = BranchListDelegate::new(
1379                        workspace.weak_handle(),
1380                        repository,
1381                        BranchListStyle::Modal,
1382                        cx,
1383                    );
1384                    delegate.all_branches = Some(branches);
1385                    let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
1386                    let picker_focus_handle = picker.focus_handle(cx);
1387                    picker.update(cx, |picker, _| {
1388                        picker.delegate.focus_handle = picker_focus_handle.clone();
1389                    });
1390
1391                    let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
1392                        cx.emit(DismissEvent);
1393                    });
1394
1395                    BranchList {
1396                        picker,
1397                        picker_focus_handle,
1398                        width: rems(34.),
1399                        _subscription: Some(_subscription),
1400                        embedded: false,
1401                    }
1402                })
1403            })
1404            .unwrap();
1405
1406        let cx = VisualTestContext::from_window(*workspace, cx);
1407
1408        (branch_list, cx)
1409    }
1410
1411    async fn init_fake_repository(cx: &mut TestAppContext) -> Entity<Repository> {
1412        let fs = FakeFs::new(cx.executor());
1413        fs.insert_tree(
1414            path!("/dir"),
1415            json!({
1416                ".git": {},
1417                "file.txt": "buffer_text".to_string()
1418            }),
1419        )
1420        .await;
1421        fs.set_head_for_repo(
1422            path!("/dir/.git").as_ref(),
1423            &[("file.txt", "test".to_string())],
1424            "deadbeef",
1425        );
1426        fs.set_index_for_repo(
1427            path!("/dir/.git").as_ref(),
1428            &[("file.txt", "index_text".to_string())],
1429        );
1430
1431        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1432        let repository = cx.read(|cx| project.read(cx).active_repository(cx));
1433
1434        repository.unwrap()
1435    }
1436
1437    #[gpui::test]
1438    async fn test_update_branch_matches_with_query(cx: &mut TestAppContext) {
1439        init_test(cx);
1440
1441        let branches = create_test_branches();
1442        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1443        let cx = &mut ctx;
1444
1445        branch_list
1446            .update_in(cx, |branch_list, window, cx| {
1447                let query = "feature".to_string();
1448                branch_list.picker.update(cx, |picker, cx| {
1449                    picker.delegate.update_matches(query, window, cx)
1450                })
1451            })
1452            .await;
1453        cx.run_until_parked();
1454
1455        branch_list.update(cx, |branch_list, cx| {
1456            branch_list.picker.update(cx, |picker, _cx| {
1457                // Should have 2 existing branches + 1 "create new branch" entry = 3 total
1458                assert_eq!(picker.delegate.matches.len(), 3);
1459                assert!(
1460                    picker
1461                        .delegate
1462                        .matches
1463                        .iter()
1464                        .any(|m| m.name() == "feature-auth")
1465                );
1466                assert!(
1467                    picker
1468                        .delegate
1469                        .matches
1470                        .iter()
1471                        .any(|m| m.name() == "feature-ui")
1472                );
1473                // Verify the last entry is the "create new branch" option
1474                let last_match = picker.delegate.matches.last().unwrap();
1475                assert!(last_match.is_new_branch());
1476            })
1477        });
1478    }
1479
1480    async fn update_branch_list_matches_with_empty_query(
1481        branch_list: &Entity<BranchList>,
1482        cx: &mut VisualTestContext,
1483    ) {
1484        branch_list
1485            .update_in(cx, |branch_list, window, cx| {
1486                branch_list.picker.update(cx, |picker, cx| {
1487                    picker.delegate.update_matches(String::new(), window, cx)
1488                })
1489            })
1490            .await;
1491        cx.run_until_parked();
1492    }
1493
1494    #[gpui::test]
1495    async fn test_delete_branch(cx: &mut TestAppContext) {
1496        init_test(cx);
1497        let repository = init_fake_repository(cx).await;
1498
1499        let branches = create_test_branches();
1500
1501        let branch_names = branches
1502            .iter()
1503            .map(|branch| branch.name().to_string())
1504            .collect::<Vec<String>>();
1505        let repo = repository.clone();
1506        cx.spawn(async move |mut cx| {
1507            for branch in branch_names {
1508                repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
1509                    .await
1510                    .unwrap()
1511                    .unwrap();
1512            }
1513        })
1514        .await;
1515        cx.run_until_parked();
1516
1517        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1518        let cx = &mut ctx;
1519
1520        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1521
1522        let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| {
1523            branch_list.picker.update(cx, |picker, cx| {
1524                assert_eq!(picker.delegate.matches.len(), 4);
1525                let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
1526                picker.delegate.delete_at(1, window, cx);
1527                branch_to_delete
1528            })
1529        });
1530        cx.run_until_parked();
1531
1532        branch_list.update(cx, move |branch_list, cx| {
1533            branch_list.picker.update(cx, move |picker, _cx| {
1534                assert_eq!(picker.delegate.matches.len(), 3);
1535                let branches = picker
1536                    .delegate
1537                    .matches
1538                    .iter()
1539                    .map(|be| be.name())
1540                    .collect::<HashSet<_>>();
1541                assert_eq!(
1542                    branches,
1543                    ["main", "feature-auth", "feature-ui", "develop"]
1544                        .into_iter()
1545                        .filter(|name| name != &branch_to_delete)
1546                        .collect::<HashSet<_>>()
1547                );
1548            })
1549        });
1550    }
1551
1552    #[gpui::test]
1553    async fn test_delete_remote(cx: &mut TestAppContext) {
1554        init_test(cx);
1555        let repository = init_fake_repository(cx).await;
1556        let branches = vec![
1557            create_test_branch("main", true, Some("origin"), Some(1000)),
1558            create_test_branch("feature-auth", false, Some("origin"), Some(900)),
1559            create_test_branch("feature-ui", false, Some("fork"), Some(800)),
1560            create_test_branch("develop", false, Some("private"), Some(700)),
1561        ];
1562
1563        let remote_names = branches
1564            .iter()
1565            .filter_map(|branch| branch.remote_name().map(|r| r.to_string()))
1566            .collect::<Vec<String>>();
1567        let repo = repository.clone();
1568        cx.spawn(async move |mut cx| {
1569            for branch in remote_names {
1570                repo.update(&mut cx, |repo, _| {
1571                    repo.create_remote(branch, String::from("test"))
1572                })
1573                .await
1574                .unwrap()
1575                .unwrap();
1576            }
1577        })
1578        .await;
1579        cx.run_until_parked();
1580
1581        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1582        let cx = &mut ctx;
1583        // Enable remote filter
1584        branch_list.update(cx, |branch_list, cx| {
1585            branch_list.picker.update(cx, |picker, _cx| {
1586                picker.delegate.branch_filter = BranchFilter::Remote;
1587            });
1588        });
1589        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1590
1591        // Check matches, it should match all existing branches and no option to create new branch
1592        let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| {
1593            branch_list.picker.update(cx, |picker, cx| {
1594                assert_eq!(picker.delegate.matches.len(), 4);
1595                let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
1596                picker.delegate.delete_at(1, window, cx);
1597                branch_to_delete
1598            })
1599        });
1600        cx.run_until_parked();
1601
1602        // Check matches, it should match one less branch than before
1603        branch_list.update(cx, move |branch_list, cx| {
1604            branch_list.picker.update(cx, move |picker, _cx| {
1605                assert_eq!(picker.delegate.matches.len(), 3);
1606                let branches = picker
1607                    .delegate
1608                    .matches
1609                    .iter()
1610                    .map(|be| be.name())
1611                    .collect::<HashSet<_>>();
1612                assert_eq!(
1613                    branches,
1614                    [
1615                        "origin/main",
1616                        "origin/feature-auth",
1617                        "fork/feature-ui",
1618                        "private/develop"
1619                    ]
1620                    .into_iter()
1621                    .filter(|name| name != &branch_to_delete)
1622                    .collect::<HashSet<_>>()
1623                );
1624            })
1625        });
1626    }
1627
1628    #[gpui::test]
1629    async fn test_branch_filter_shows_all_then_remotes_and_applies_query(cx: &mut TestAppContext) {
1630        init_test(cx);
1631
1632        let branches = vec![
1633            create_test_branch("main", true, Some("origin"), Some(1000)),
1634            create_test_branch("feature-auth", false, Some("fork"), Some(900)),
1635            create_test_branch("feature-ui", false, None, Some(800)),
1636            create_test_branch("develop", false, None, Some(700)),
1637        ];
1638
1639        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1640        let cx = &mut ctx;
1641
1642        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1643
1644        branch_list.update(cx, |branch_list, cx| {
1645            branch_list.picker.update(cx, |picker, _cx| {
1646                assert_eq!(picker.delegate.matches.len(), 4);
1647
1648                let branches = picker
1649                    .delegate
1650                    .matches
1651                    .iter()
1652                    .map(|be| be.name())
1653                    .collect::<HashSet<_>>();
1654                assert_eq!(
1655                    branches,
1656                    ["origin/main", "fork/feature-auth", "feature-ui", "develop"]
1657                        .into_iter()
1658                        .collect::<HashSet<_>>()
1659                );
1660
1661                // Locals should be listed before remotes.
1662                let ordered = picker
1663                    .delegate
1664                    .matches
1665                    .iter()
1666                    .map(|be| be.name())
1667                    .collect::<Vec<_>>();
1668                assert_eq!(
1669                    ordered,
1670                    vec!["feature-ui", "develop", "origin/main", "fork/feature-auth"]
1671                );
1672
1673                // Verify the last entry is NOT the "create new branch" option
1674                let last_match = picker.delegate.matches.last().unwrap();
1675                assert!(!last_match.is_new_branch());
1676                assert!(!last_match.is_new_url());
1677            })
1678        });
1679
1680        branch_list.update(cx, |branch_list, cx| {
1681            branch_list.picker.update(cx, |picker, _cx| {
1682                picker.delegate.branch_filter = BranchFilter::Remote;
1683            })
1684        });
1685
1686        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1687
1688        branch_list
1689            .update_in(cx, |branch_list, window, cx| {
1690                branch_list.picker.update(cx, |picker, cx| {
1691                    assert_eq!(picker.delegate.matches.len(), 2);
1692                    let branches = picker
1693                        .delegate
1694                        .matches
1695                        .iter()
1696                        .map(|be| be.name())
1697                        .collect::<HashSet<_>>();
1698                    assert_eq!(
1699                        branches,
1700                        ["origin/main", "fork/feature-auth"]
1701                            .into_iter()
1702                            .collect::<HashSet<_>>()
1703                    );
1704
1705                    // Verify the last entry is NOT the "create new branch" option
1706                    let last_match = picker.delegate.matches.last().unwrap();
1707                    assert!(!last_match.is_new_url());
1708                    picker.delegate.branch_filter = BranchFilter::Remote;
1709                    picker
1710                        .delegate
1711                        .update_matches(String::from("fork"), window, cx)
1712                })
1713            })
1714            .await;
1715        cx.run_until_parked();
1716
1717        branch_list.update(cx, |branch_list, cx| {
1718            branch_list.picker.update(cx, |picker, _cx| {
1719                // Should have 1 existing branch + 1 "create new branch" entry = 2 total
1720                assert_eq!(picker.delegate.matches.len(), 2);
1721                assert!(
1722                    picker
1723                        .delegate
1724                        .matches
1725                        .iter()
1726                        .any(|m| m.name() == "fork/feature-auth")
1727                );
1728                // Verify the last entry is the "create new branch" option
1729                let last_match = picker.delegate.matches.last().unwrap();
1730                assert!(last_match.is_new_branch());
1731            })
1732        });
1733    }
1734
1735    #[gpui::test]
1736    async fn test_new_branch_creation_with_query(test_cx: &mut TestAppContext) {
1737        const MAIN_BRANCH: &str = "main";
1738        const FEATURE_BRANCH: &str = "feature";
1739        const NEW_BRANCH: &str = "new-feature-branch";
1740
1741        init_test(test_cx);
1742        let repository = init_fake_repository(test_cx).await;
1743
1744        let branches = vec![
1745            create_test_branch(MAIN_BRANCH, true, None, Some(1000)),
1746            create_test_branch(FEATURE_BRANCH, false, None, Some(900)),
1747        ];
1748
1749        let (branch_list, mut ctx) =
1750            init_branch_list_test(repository.into(), branches, test_cx).await;
1751        let cx = &mut ctx;
1752
1753        branch_list
1754            .update_in(cx, |branch_list, window, cx| {
1755                branch_list.picker.update(cx, |picker, cx| {
1756                    picker
1757                        .delegate
1758                        .update_matches(NEW_BRANCH.to_string(), window, cx)
1759                })
1760            })
1761            .await;
1762
1763        cx.run_until_parked();
1764
1765        branch_list.update_in(cx, |branch_list, window, cx| {
1766            branch_list.picker.update(cx, |picker, cx| {
1767                let last_match = picker.delegate.matches.last().unwrap();
1768                assert!(last_match.is_new_branch());
1769                assert_eq!(last_match.name(), NEW_BRANCH);
1770                // State is NewBranch because no existing branches fuzzy-match the query
1771                assert!(matches!(picker.delegate.state, PickerState::NewBranch));
1772                picker.delegate.confirm(false, window, cx);
1773            })
1774        });
1775        cx.run_until_parked();
1776
1777        let branches = branch_list
1778            .update(cx, |branch_list, cx| {
1779                branch_list.picker.update(cx, |picker, cx| {
1780                    picker
1781                        .delegate
1782                        .repo
1783                        .as_ref()
1784                        .unwrap()
1785                        .update(cx, |repo, _cx| repo.branches())
1786                })
1787            })
1788            .await
1789            .unwrap()
1790            .unwrap();
1791
1792        let new_branch = branches
1793            .into_iter()
1794            .find(|branch| branch.name() == NEW_BRANCH)
1795            .expect("new-feature-branch should exist");
1796        assert_eq!(
1797            new_branch.ref_name.as_ref(),
1798            &format!("refs/heads/{NEW_BRANCH}"),
1799            "branch ref_name should not have duplicate refs/heads/ prefix"
1800        );
1801    }
1802
1803    #[gpui::test]
1804    async fn test_remote_url_detection_https(cx: &mut TestAppContext) {
1805        init_test(cx);
1806        let repository = init_fake_repository(cx).await;
1807        let branches = vec![create_test_branch("main", true, None, Some(1000))];
1808
1809        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1810        let cx = &mut ctx;
1811
1812        branch_list
1813            .update_in(cx, |branch_list, window, cx| {
1814                branch_list.picker.update(cx, |picker, cx| {
1815                    let query = "https://github.com/user/repo.git".to_string();
1816                    picker.delegate.update_matches(query, window, cx)
1817                })
1818            })
1819            .await;
1820
1821        cx.run_until_parked();
1822
1823        branch_list
1824            .update_in(cx, |branch_list, window, cx| {
1825                branch_list.picker.update(cx, |picker, cx| {
1826                    let last_match = picker.delegate.matches.last().unwrap();
1827                    assert!(last_match.is_new_url());
1828                    assert!(matches!(picker.delegate.state, PickerState::NewRemote));
1829                    picker.delegate.confirm(false, window, cx);
1830                    assert_eq!(picker.delegate.matches.len(), 0);
1831                    if let PickerState::CreateRemote(remote_url) = &picker.delegate.state
1832                        && remote_url.as_ref() == "https://github.com/user/repo.git"
1833                    {
1834                    } else {
1835                        panic!("wrong picker state");
1836                    }
1837                    picker
1838                        .delegate
1839                        .update_matches("my_new_remote".to_string(), window, cx)
1840                })
1841            })
1842            .await;
1843
1844        cx.run_until_parked();
1845
1846        branch_list.update_in(cx, |branch_list, window, cx| {
1847            branch_list.picker.update(cx, |picker, cx| {
1848                assert_eq!(picker.delegate.matches.len(), 1);
1849                assert!(matches!(
1850                    picker.delegate.matches.first(),
1851                    Some(Entry::NewRemoteName { name, url })
1852                        if name == "my_new_remote" && url.as_ref() == "https://github.com/user/repo.git"
1853                ));
1854                picker.delegate.confirm(false, window, cx);
1855            })
1856        });
1857        cx.run_until_parked();
1858
1859        // List remotes
1860        let remotes = branch_list
1861            .update(cx, |branch_list, cx| {
1862                branch_list.picker.update(cx, |picker, cx| {
1863                    picker
1864                        .delegate
1865                        .repo
1866                        .as_ref()
1867                        .unwrap()
1868                        .update(cx, |repo, _cx| repo.get_remotes(None, false))
1869                })
1870            })
1871            .await
1872            .unwrap()
1873            .unwrap();
1874        assert_eq!(
1875            remotes,
1876            vec![Remote {
1877                name: SharedString::from("my_new_remote".to_string())
1878            }]
1879        );
1880    }
1881
1882    #[gpui::test]
1883    async fn test_confirm_remote_url_transitions(cx: &mut TestAppContext) {
1884        init_test(cx);
1885
1886        let branches = vec![create_test_branch("main_branch", true, None, Some(1000))];
1887        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1888        let cx = &mut ctx;
1889
1890        branch_list
1891            .update_in(cx, |branch_list, window, cx| {
1892                branch_list.picker.update(cx, |picker, cx| {
1893                    let query = "https://github.com/user/repo.git".to_string();
1894                    picker.delegate.update_matches(query, window, cx)
1895                })
1896            })
1897            .await;
1898        cx.run_until_parked();
1899
1900        // Try to create a new remote but cancel in the middle of the process
1901        branch_list
1902            .update_in(cx, |branch_list, window, cx| {
1903                branch_list.picker.update(cx, |picker, cx| {
1904                    picker.delegate.selected_index = picker.delegate.matches.len() - 1;
1905                    picker.delegate.confirm(false, window, cx);
1906
1907                    assert!(matches!(
1908                        picker.delegate.state,
1909                        PickerState::CreateRemote(_)
1910                    ));
1911                    if let PickerState::CreateRemote(ref url) = picker.delegate.state {
1912                        assert_eq!(url.as_ref(), "https://github.com/user/repo.git");
1913                    }
1914                    assert_eq!(picker.delegate.matches.len(), 0);
1915                    picker.delegate.dismissed(window, cx);
1916                    assert!(matches!(picker.delegate.state, PickerState::List));
1917                    let query = "main".to_string();
1918                    picker.delegate.update_matches(query, window, cx)
1919                })
1920            })
1921            .await;
1922        cx.run_until_parked();
1923
1924        // Try to search a branch again to see if the state is restored properly
1925        branch_list.update(cx, |branch_list, cx| {
1926            branch_list.picker.update(cx, |picker, _cx| {
1927                // Should have 1 existing branch + 1 "create new branch" entry = 2 total
1928                assert_eq!(picker.delegate.matches.len(), 2);
1929                assert!(
1930                    picker
1931                        .delegate
1932                        .matches
1933                        .iter()
1934                        .any(|m| m.name() == "main_branch")
1935                );
1936                // Verify the last entry is the "create new branch" option
1937                let last_match = picker.delegate.matches.last().unwrap();
1938                assert!(last_match.is_new_branch());
1939            })
1940        });
1941    }
1942
1943    #[gpui::test]
1944    async fn test_confirm_remote_url_does_not_dismiss(cx: &mut TestAppContext) {
1945        const REMOTE_URL: &str = "https://github.com/user/repo.git";
1946
1947        init_test(cx);
1948        let branches = vec![create_test_branch("main", true, None, Some(1000))];
1949
1950        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1951        let cx = &mut ctx;
1952
1953        let subscription = cx.update(|_, cx| {
1954            cx.subscribe(&branch_list, |_, _: &DismissEvent, _| {
1955                panic!("DismissEvent should not be emitted when confirming a remote URL");
1956            })
1957        });
1958
1959        branch_list
1960            .update_in(cx, |branch_list, window, cx| {
1961                window.focus(&branch_list.picker_focus_handle, cx);
1962                assert!(
1963                    branch_list.picker_focus_handle.is_focused(window),
1964                    "Branch picker should be focused when selecting an entry"
1965                );
1966
1967                branch_list.picker.update(cx, |picker, cx| {
1968                    picker
1969                        .delegate
1970                        .update_matches(REMOTE_URL.to_string(), window, cx)
1971                })
1972            })
1973            .await;
1974
1975        cx.run_until_parked();
1976
1977        branch_list.update_in(cx, |branch_list, window, cx| {
1978            // Re-focus the picker since workspace initialization during run_until_parked
1979            window.focus(&branch_list.picker_focus_handle, cx);
1980
1981            branch_list.picker.update(cx, |picker, cx| {
1982                let last_match = picker.delegate.matches.last().unwrap();
1983                assert!(last_match.is_new_url());
1984                assert!(matches!(picker.delegate.state, PickerState::NewRemote));
1985
1986                picker.delegate.confirm(false, window, cx);
1987
1988                assert!(
1989                    matches!(picker.delegate.state, PickerState::CreateRemote(ref url) if url.as_ref() == REMOTE_URL),
1990                    "State should transition to CreateRemote with the URL"
1991                );
1992            });
1993
1994            assert!(
1995                branch_list.picker_focus_handle.is_focused(window),
1996                "Branch list picker should still be focused after confirming remote URL"
1997            );
1998        });
1999
2000        cx.run_until_parked();
2001
2002        drop(subscription);
2003    }
2004
2005    #[gpui::test(iterations = 10)]
2006    async fn test_empty_query_displays_all_branches(mut rng: StdRng, cx: &mut TestAppContext) {
2007        init_test(cx);
2008        let branch_count = rng.random_range(13..540);
2009
2010        let branches: Vec<Branch> = (0..branch_count)
2011            .map(|i| create_test_branch(&format!("branch-{:02}", i), i == 0, None, Some(i * 100)))
2012            .collect();
2013
2014        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
2015        let cx = &mut ctx;
2016
2017        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
2018
2019        branch_list.update(cx, |branch_list, cx| {
2020            branch_list.picker.update(cx, |picker, _cx| {
2021                assert_eq!(picker.delegate.matches.len(), branch_count as usize);
2022            })
2023        });
2024    }
2025}