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