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