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