branch_picker.rs

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