branch_picker.rs

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