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.map(|this| {
1091                        if self.selected_index() == ix {
1092                            this.end_slot(deleted_branch_icon(ix))
1093                        } else {
1094                            this.end_hover_slot(deleted_branch_icon(ix))
1095                        }
1096                    })
1097                })
1098                .when_some(
1099                    if is_new_items {
1100                        create_from_default_button
1101                    } else {
1102                        None
1103                    },
1104                    |this, create_from_default_button| {
1105                        this.map(|this| {
1106                            if self.selected_index() == ix {
1107                                this.end_slot(create_from_default_button)
1108                            } else {
1109                                this.end_hover_slot(create_from_default_button)
1110                            }
1111                        })
1112                    },
1113                ),
1114        )
1115    }
1116
1117    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1118        if self.editor_position() == PickerEditorPosition::End {
1119            return None;
1120        }
1121        let focus_handle = self.focus_handle.clone();
1122
1123        let footer_container = || {
1124            h_flex()
1125                .w_full()
1126                .p_1p5()
1127                .border_t_1()
1128                .border_color(cx.theme().colors().border_variant)
1129        };
1130
1131        match self.state {
1132            PickerState::List => {
1133                let selected_entry = self.matches.get(self.selected_index);
1134
1135                let branch_from_default_button = self
1136                    .default_branch
1137                    .as_ref()
1138                    .filter(|_| matches!(selected_entry, Some(Entry::NewBranch { .. })))
1139                    .map(|default_branch| {
1140                        let button_label = format!("Create New From: {default_branch}");
1141
1142                        Button::new("branch-from-default", button_label)
1143                            .key_binding(
1144                                KeyBinding::for_action_in(
1145                                    &menu::SecondaryConfirm,
1146                                    &focus_handle,
1147                                    cx,
1148                                )
1149                                .map(|kb| kb.size(rems_from_px(12.))),
1150                            )
1151                            .on_click(cx.listener(|this, _, window, cx| {
1152                                this.delegate.confirm(true, window, cx);
1153                            }))
1154                    });
1155
1156                let delete_and_select_btns = h_flex()
1157                    .gap_1()
1158                    .when(
1159                        !selected_entry
1160                            .and_then(|entry| entry.as_branch())
1161                            .is_some_and(|branch| branch.is_head),
1162                        |this| {
1163                            this.child(
1164                                Button::new("delete-branch", "Delete")
1165                                    .key_binding(
1166                                        KeyBinding::for_action_in(
1167                                            &branch_picker::DeleteBranch,
1168                                            &focus_handle,
1169                                            cx,
1170                                        )
1171                                        .map(|kb| kb.size(rems_from_px(12.))),
1172                                    )
1173                                    .on_click(|_, window, cx| {
1174                                        window.dispatch_action(
1175                                            branch_picker::DeleteBranch.boxed_clone(),
1176                                            cx,
1177                                        );
1178                                    }),
1179                            )
1180                        },
1181                    )
1182                    .child(
1183                        Button::new("select_branch", "Select")
1184                            .key_binding(
1185                                KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1186                                    .map(|kb| kb.size(rems_from_px(12.))),
1187                            )
1188                            .on_click(cx.listener(|this, _, window, cx| {
1189                                this.delegate.confirm(false, window, cx);
1190                            })),
1191                    );
1192
1193                Some(
1194                    footer_container()
1195                        .map(|this| {
1196                            if branch_from_default_button.is_some() {
1197                                this.justify_end().when_some(
1198                                    branch_from_default_button,
1199                                    |this, button| {
1200                                        this.child(button).child(
1201                                            Button::new("create", "Create")
1202                                                .key_binding(
1203                                                    KeyBinding::for_action_in(
1204                                                        &menu::Confirm,
1205                                                        &focus_handle,
1206                                                        cx,
1207                                                    )
1208                                                    .map(|kb| kb.size(rems_from_px(12.))),
1209                                                )
1210                                                .on_click(cx.listener(|this, _, window, cx| {
1211                                                    this.delegate.confirm(false, window, cx);
1212                                                })),
1213                                        )
1214                                    },
1215                                )
1216                            } else {
1217                                this.justify_between()
1218                                    .child({
1219                                        let focus_handle = focus_handle.clone();
1220                                        let filter_label = match self.branch_filter {
1221                                            BranchFilter::All => "Filter Remote",
1222                                            BranchFilter::Remote => "Show All",
1223                                        };
1224                                        Button::new("filter-remotes", filter_label)
1225                                            .toggle_state(matches!(
1226                                                self.branch_filter,
1227                                                BranchFilter::Remote
1228                                            ))
1229                                            .key_binding(
1230                                                KeyBinding::for_action_in(
1231                                                    &branch_picker::FilterRemotes,
1232                                                    &focus_handle,
1233                                                    cx,
1234                                                )
1235                                                .map(|kb| kb.size(rems_from_px(12.))),
1236                                            )
1237                                            .on_click(|_click, window, cx| {
1238                                                window.dispatch_action(
1239                                                    branch_picker::FilterRemotes.boxed_clone(),
1240                                                    cx,
1241                                                );
1242                                            })
1243                                    })
1244                                    .child(delete_and_select_btns)
1245                            }
1246                        })
1247                        .into_any_element(),
1248                )
1249            }
1250            PickerState::NewBranch => {
1251                let branch_from_default_button =
1252                    self.default_branch.as_ref().map(|default_branch| {
1253                        let button_label = format!("Create New From: {default_branch}");
1254
1255                        Button::new("branch-from-default", button_label)
1256                            .key_binding(
1257                                KeyBinding::for_action_in(
1258                                    &menu::SecondaryConfirm,
1259                                    &focus_handle,
1260                                    cx,
1261                                )
1262                                .map(|kb| kb.size(rems_from_px(12.))),
1263                            )
1264                            .on_click(cx.listener(|this, _, window, cx| {
1265                                this.delegate.confirm(true, window, cx);
1266                            }))
1267                    });
1268
1269                Some(
1270                    footer_container()
1271                        .gap_1()
1272                        .justify_end()
1273                        .when_some(branch_from_default_button, |this, button| {
1274                            this.child(button)
1275                        })
1276                        .child(
1277                            Button::new("branch-from-default", "Create")
1278                                .key_binding(
1279                                    KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1280                                        .map(|kb| kb.size(rems_from_px(12.))),
1281                                )
1282                                .on_click(cx.listener(|this, _, window, cx| {
1283                                    this.delegate.confirm(false, window, cx);
1284                                })),
1285                        )
1286                        .into_any_element(),
1287                )
1288            }
1289            PickerState::CreateRemote(_) => Some(
1290                footer_container()
1291                    .justify_end()
1292                    .child(
1293                        Button::new("branch-from-default", "Confirm")
1294                            .key_binding(
1295                                KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1296                                    .map(|kb| kb.size(rems_from_px(12.))),
1297                            )
1298                            .on_click(cx.listener(|this, _, window, cx| {
1299                                this.delegate.confirm(false, window, cx);
1300                            }))
1301                            .disabled(self.last_query.is_empty()),
1302                    )
1303                    .into_any_element(),
1304            ),
1305            PickerState::NewRemote => None,
1306        }
1307    }
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312    use std::collections::HashSet;
1313
1314    use super::*;
1315    use git::repository::{CommitSummary, Remote};
1316    use gpui::{AppContext, TestAppContext, VisualTestContext};
1317    use project::{FakeFs, Project};
1318    use rand::{Rng, rngs::StdRng};
1319    use serde_json::json;
1320    use settings::SettingsStore;
1321    use util::path;
1322    use workspace::MultiWorkspace;
1323
1324    fn init_test(cx: &mut TestAppContext) {
1325        cx.update(|cx| {
1326            let settings_store = SettingsStore::test(cx);
1327            cx.set_global(settings_store);
1328            theme_settings::init(theme::LoadThemes::JustBase, cx);
1329            editor::init(cx);
1330        });
1331    }
1332
1333    fn create_test_branch(
1334        name: &str,
1335        is_head: bool,
1336        remote_name: Option<&str>,
1337        timestamp: Option<i64>,
1338    ) -> Branch {
1339        let ref_name = match remote_name {
1340            Some(remote_name) => format!("refs/remotes/{remote_name}/{name}"),
1341            None => format!("refs/heads/{name}"),
1342        };
1343
1344        Branch {
1345            is_head,
1346            ref_name: ref_name.into(),
1347            upstream: None,
1348            most_recent_commit: timestamp.map(|ts| CommitSummary {
1349                sha: "abc123".into(),
1350                commit_timestamp: ts,
1351                author_name: "Test Author".into(),
1352                subject: "Test commit".into(),
1353                has_parent: true,
1354            }),
1355        }
1356    }
1357
1358    fn create_test_branches() -> Vec<Branch> {
1359        vec![
1360            create_test_branch("main", true, None, Some(1000)),
1361            create_test_branch("feature-auth", false, None, Some(900)),
1362            create_test_branch("feature-ui", false, None, Some(800)),
1363            create_test_branch("develop", false, None, Some(700)),
1364        ]
1365    }
1366
1367    async fn init_branch_list_test(
1368        repository: Option<Entity<Repository>>,
1369        branches: Vec<Branch>,
1370        cx: &mut TestAppContext,
1371    ) -> (Entity<BranchList>, VisualTestContext) {
1372        let fs = FakeFs::new(cx.executor());
1373        let project = Project::test(fs, [], cx).await;
1374
1375        let window_handle =
1376            cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
1377        let workspace = window_handle
1378            .read_with(cx, |mw, _| mw.workspace().clone())
1379            .unwrap();
1380
1381        let branch_list = window_handle
1382            .update(cx, |_multi_workspace, window, cx| {
1383                cx.new(|cx| {
1384                    let mut delegate = BranchListDelegate::new(
1385                        workspace.downgrade(),
1386                        repository,
1387                        BranchListStyle::Modal,
1388                        cx,
1389                    );
1390                    delegate.all_branches = Some(branches);
1391                    let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
1392                    let picker_focus_handle = picker.focus_handle(cx);
1393                    picker.update(cx, |picker, _| {
1394                        picker.delegate.focus_handle = picker_focus_handle.clone();
1395                    });
1396
1397                    let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
1398                        cx.emit(DismissEvent);
1399                    });
1400
1401                    BranchList {
1402                        picker,
1403                        picker_focus_handle,
1404                        width: rems(34.),
1405                        _subscription: Some(_subscription),
1406                        embedded: false,
1407                    }
1408                })
1409            })
1410            .unwrap();
1411
1412        let cx = VisualTestContext::from_window(window_handle.into(), cx);
1413
1414        (branch_list, cx)
1415    }
1416
1417    async fn init_fake_repository(
1418        cx: &mut TestAppContext,
1419    ) -> (Entity<Project>, Entity<Repository>) {
1420        let fs = FakeFs::new(cx.executor());
1421        fs.insert_tree(
1422            path!("/dir"),
1423            json!({
1424                ".git": {},
1425                "file.txt": "buffer_text".to_string()
1426            }),
1427        )
1428        .await;
1429        fs.set_head_for_repo(
1430            path!("/dir/.git").as_ref(),
1431            &[("file.txt", "test".to_string())],
1432            "deadbeef",
1433        );
1434        fs.set_index_for_repo(
1435            path!("/dir/.git").as_ref(),
1436            &[("file.txt", "index_text".to_string())],
1437        );
1438
1439        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1440        let repository = cx.read(|cx| project.read(cx).active_repository(cx));
1441
1442        (project, repository.unwrap())
1443    }
1444
1445    #[gpui::test]
1446    async fn test_update_branch_matches_with_query(cx: &mut TestAppContext) {
1447        init_test(cx);
1448
1449        let branches = create_test_branches();
1450        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1451        let cx = &mut ctx;
1452
1453        branch_list
1454            .update_in(cx, |branch_list, window, cx| {
1455                let query = "feature".to_string();
1456                branch_list.picker.update(cx, |picker, cx| {
1457                    picker.delegate.update_matches(query, window, cx)
1458                })
1459            })
1460            .await;
1461        cx.run_until_parked();
1462
1463        branch_list.update(cx, |branch_list, cx| {
1464            branch_list.picker.update(cx, |picker, _cx| {
1465                // Should have 2 existing branches + 1 "create new branch" entry = 3 total
1466                assert_eq!(picker.delegate.matches.len(), 3);
1467                assert!(
1468                    picker
1469                        .delegate
1470                        .matches
1471                        .iter()
1472                        .any(|m| m.name() == "feature-auth")
1473                );
1474                assert!(
1475                    picker
1476                        .delegate
1477                        .matches
1478                        .iter()
1479                        .any(|m| m.name() == "feature-ui")
1480                );
1481                // Verify the last entry is the "create new branch" option
1482                let last_match = picker.delegate.matches.last().unwrap();
1483                assert!(last_match.is_new_branch());
1484            })
1485        });
1486    }
1487
1488    async fn update_branch_list_matches_with_empty_query(
1489        branch_list: &Entity<BranchList>,
1490        cx: &mut VisualTestContext,
1491    ) {
1492        branch_list
1493            .update_in(cx, |branch_list, window, cx| {
1494                branch_list.picker.update(cx, |picker, cx| {
1495                    picker.delegate.update_matches(String::new(), window, cx)
1496                })
1497            })
1498            .await;
1499        cx.run_until_parked();
1500    }
1501
1502    #[gpui::test]
1503    async fn test_delete_branch(cx: &mut TestAppContext) {
1504        init_test(cx);
1505        let (_project, repository) = init_fake_repository(cx).await;
1506
1507        let branches = create_test_branches();
1508
1509        let branch_names = branches
1510            .iter()
1511            .map(|branch| branch.name().to_string())
1512            .collect::<Vec<String>>();
1513        let repo = repository.clone();
1514        cx.spawn(async move |mut cx| {
1515            for branch in branch_names {
1516                repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
1517                    .await
1518                    .unwrap()
1519                    .unwrap();
1520            }
1521        })
1522        .await;
1523        cx.run_until_parked();
1524
1525        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1526        let cx = &mut ctx;
1527
1528        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1529
1530        let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| {
1531            branch_list.picker.update(cx, |picker, cx| {
1532                assert_eq!(picker.delegate.matches.len(), 4);
1533                let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
1534                picker.delegate.delete_at(1, window, cx);
1535                branch_to_delete
1536            })
1537        });
1538        cx.run_until_parked();
1539
1540        let expected_branches = ["main", "feature-auth", "feature-ui", "develop"]
1541            .into_iter()
1542            .filter(|name| name != &branch_to_delete)
1543            .collect::<HashSet<_>>();
1544        let repo_branches = branch_list
1545            .update(cx, |branch_list, cx| {
1546                branch_list.picker.update(cx, |picker, cx| {
1547                    picker
1548                        .delegate
1549                        .repo
1550                        .as_ref()
1551                        .unwrap()
1552                        .update(cx, |repo, _cx| repo.branches())
1553                })
1554            })
1555            .await
1556            .unwrap()
1557            .unwrap();
1558        let repo_branches = repo_branches
1559            .iter()
1560            .map(|b| b.name())
1561            .collect::<HashSet<_>>();
1562        assert_eq!(&repo_branches, &expected_branches);
1563
1564        branch_list.update(cx, move |branch_list, cx| {
1565            branch_list.picker.update(cx, move |picker, _cx| {
1566                assert_eq!(picker.delegate.matches.len(), 3);
1567                let branches = picker
1568                    .delegate
1569                    .matches
1570                    .iter()
1571                    .map(|be| be.name())
1572                    .collect::<HashSet<_>>();
1573                assert_eq!(branches, expected_branches);
1574            })
1575        });
1576    }
1577
1578    #[gpui::test]
1579    async fn test_delete_remote_branch(cx: &mut TestAppContext) {
1580        init_test(cx);
1581        let (_project, repository) = init_fake_repository(cx).await;
1582        let branches = vec![
1583            create_test_branch("main", true, Some("origin"), Some(1000)),
1584            create_test_branch("feature-auth", false, Some("origin"), Some(900)),
1585            create_test_branch("feature-ui", false, Some("fork"), Some(800)),
1586            create_test_branch("develop", false, Some("private"), Some(700)),
1587        ];
1588
1589        let branch_names = branches
1590            .iter()
1591            .map(|branch| branch.name().to_string())
1592            .collect::<Vec<String>>();
1593        let repo = repository.clone();
1594        cx.spawn(async move |mut cx| {
1595            for branch in branch_names {
1596                repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
1597                    .await
1598                    .unwrap()
1599                    .unwrap();
1600            }
1601        })
1602        .await;
1603        cx.run_until_parked();
1604
1605        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1606        let cx = &mut ctx;
1607        // Enable remote filter
1608        branch_list.update(cx, |branch_list, cx| {
1609            branch_list.picker.update(cx, |picker, _cx| {
1610                picker.delegate.branch_filter = BranchFilter::Remote;
1611            });
1612        });
1613        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1614
1615        // Check matches, it should match all existing branches and no option to create new branch
1616        let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| {
1617            branch_list.picker.update(cx, |picker, cx| {
1618                assert_eq!(picker.delegate.matches.len(), 4);
1619                let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
1620                picker.delegate.delete_at(1, window, cx);
1621                branch_to_delete
1622            })
1623        });
1624        cx.run_until_parked();
1625
1626        let expected_branches = [
1627            "origin/main",
1628            "origin/feature-auth",
1629            "fork/feature-ui",
1630            "private/develop",
1631        ]
1632        .into_iter()
1633        .filter(|name| name != &branch_to_delete)
1634        .collect::<HashSet<_>>();
1635        let repo_branches = branch_list
1636            .update(cx, |branch_list, cx| {
1637                branch_list.picker.update(cx, |picker, cx| {
1638                    picker
1639                        .delegate
1640                        .repo
1641                        .as_ref()
1642                        .unwrap()
1643                        .update(cx, |repo, _cx| repo.branches())
1644                })
1645            })
1646            .await
1647            .unwrap()
1648            .unwrap();
1649        let repo_branches = repo_branches
1650            .iter()
1651            .map(|b| b.name())
1652            .collect::<HashSet<_>>();
1653        assert_eq!(&repo_branches, &expected_branches);
1654
1655        // Check matches, it should match one less branch than before
1656        branch_list.update(cx, move |branch_list, cx| {
1657            branch_list.picker.update(cx, move |picker, _cx| {
1658                assert_eq!(picker.delegate.matches.len(), 3);
1659                let branches = picker
1660                    .delegate
1661                    .matches
1662                    .iter()
1663                    .map(|be| be.name())
1664                    .collect::<HashSet<_>>();
1665                assert_eq!(branches, expected_branches);
1666            })
1667        });
1668    }
1669
1670    #[gpui::test]
1671    async fn test_branch_filter_shows_all_then_remotes_and_applies_query(cx: &mut TestAppContext) {
1672        init_test(cx);
1673
1674        let branches = vec![
1675            create_test_branch("main", true, Some("origin"), Some(1000)),
1676            create_test_branch("feature-auth", false, Some("fork"), Some(900)),
1677            create_test_branch("feature-ui", false, None, Some(800)),
1678            create_test_branch("develop", false, None, Some(700)),
1679        ];
1680
1681        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1682        let cx = &mut ctx;
1683
1684        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1685
1686        branch_list.update(cx, |branch_list, cx| {
1687            branch_list.picker.update(cx, |picker, _cx| {
1688                assert_eq!(picker.delegate.matches.len(), 4);
1689
1690                let branches = picker
1691                    .delegate
1692                    .matches
1693                    .iter()
1694                    .map(|be| be.name())
1695                    .collect::<HashSet<_>>();
1696                assert_eq!(
1697                    branches,
1698                    ["origin/main", "fork/feature-auth", "feature-ui", "develop"]
1699                        .into_iter()
1700                        .collect::<HashSet<_>>()
1701                );
1702
1703                // Locals should be listed before remotes.
1704                let ordered = picker
1705                    .delegate
1706                    .matches
1707                    .iter()
1708                    .map(|be| be.name())
1709                    .collect::<Vec<_>>();
1710                assert_eq!(
1711                    ordered,
1712                    vec!["feature-ui", "develop", "origin/main", "fork/feature-auth"]
1713                );
1714
1715                // Verify the last entry is NOT the "create new branch" option
1716                let last_match = picker.delegate.matches.last().unwrap();
1717                assert!(!last_match.is_new_branch());
1718                assert!(!last_match.is_new_url());
1719            })
1720        });
1721
1722        branch_list.update(cx, |branch_list, cx| {
1723            branch_list.picker.update(cx, |picker, _cx| {
1724                picker.delegate.branch_filter = BranchFilter::Remote;
1725            })
1726        });
1727
1728        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1729
1730        branch_list
1731            .update_in(cx, |branch_list, window, cx| {
1732                branch_list.picker.update(cx, |picker, cx| {
1733                    assert_eq!(picker.delegate.matches.len(), 2);
1734                    let branches = picker
1735                        .delegate
1736                        .matches
1737                        .iter()
1738                        .map(|be| be.name())
1739                        .collect::<HashSet<_>>();
1740                    assert_eq!(
1741                        branches,
1742                        ["origin/main", "fork/feature-auth"]
1743                            .into_iter()
1744                            .collect::<HashSet<_>>()
1745                    );
1746
1747                    // Verify the last entry is NOT the "create new branch" option
1748                    let last_match = picker.delegate.matches.last().unwrap();
1749                    assert!(!last_match.is_new_url());
1750                    picker.delegate.branch_filter = BranchFilter::Remote;
1751                    picker
1752                        .delegate
1753                        .update_matches(String::from("fork"), window, cx)
1754                })
1755            })
1756            .await;
1757        cx.run_until_parked();
1758
1759        branch_list.update(cx, |branch_list, cx| {
1760            branch_list.picker.update(cx, |picker, _cx| {
1761                // Should have 1 existing branch + 1 "create new branch" entry = 2 total
1762                assert_eq!(picker.delegate.matches.len(), 2);
1763                assert!(
1764                    picker
1765                        .delegate
1766                        .matches
1767                        .iter()
1768                        .any(|m| m.name() == "fork/feature-auth")
1769                );
1770                // Verify the last entry is the "create new branch" option
1771                let last_match = picker.delegate.matches.last().unwrap();
1772                assert!(last_match.is_new_branch());
1773            })
1774        });
1775    }
1776
1777    #[gpui::test]
1778    async fn test_new_branch_creation_with_query(test_cx: &mut TestAppContext) {
1779        const MAIN_BRANCH: &str = "main";
1780        const FEATURE_BRANCH: &str = "feature";
1781        const NEW_BRANCH: &str = "new-feature-branch";
1782
1783        init_test(test_cx);
1784        let (_project, repository) = init_fake_repository(test_cx).await;
1785
1786        let branches = vec![
1787            create_test_branch(MAIN_BRANCH, true, None, Some(1000)),
1788            create_test_branch(FEATURE_BRANCH, false, None, Some(900)),
1789        ];
1790
1791        let (branch_list, mut ctx) =
1792            init_branch_list_test(repository.into(), branches, test_cx).await;
1793        let cx = &mut ctx;
1794
1795        branch_list
1796            .update_in(cx, |branch_list, window, cx| {
1797                branch_list.picker.update(cx, |picker, cx| {
1798                    picker
1799                        .delegate
1800                        .update_matches(NEW_BRANCH.to_string(), window, cx)
1801                })
1802            })
1803            .await;
1804
1805        cx.run_until_parked();
1806
1807        branch_list.update_in(cx, |branch_list, window, cx| {
1808            branch_list.picker.update(cx, |picker, cx| {
1809                let last_match = picker.delegate.matches.last().unwrap();
1810                assert!(last_match.is_new_branch());
1811                assert_eq!(last_match.name(), NEW_BRANCH);
1812                // State is NewBranch because no existing branches fuzzy-match the query
1813                assert!(matches!(picker.delegate.state, PickerState::NewBranch));
1814                picker.delegate.confirm(false, window, cx);
1815            })
1816        });
1817        cx.run_until_parked();
1818
1819        let branches = branch_list
1820            .update(cx, |branch_list, cx| {
1821                branch_list.picker.update(cx, |picker, cx| {
1822                    picker
1823                        .delegate
1824                        .repo
1825                        .as_ref()
1826                        .unwrap()
1827                        .update(cx, |repo, _cx| repo.branches())
1828                })
1829            })
1830            .await
1831            .unwrap()
1832            .unwrap();
1833
1834        let new_branch = branches
1835            .into_iter()
1836            .find(|branch| branch.name() == NEW_BRANCH)
1837            .expect("new-feature-branch should exist");
1838        assert_eq!(
1839            new_branch.ref_name.as_ref(),
1840            &format!("refs/heads/{NEW_BRANCH}"),
1841            "branch ref_name should not have duplicate refs/heads/ prefix"
1842        );
1843    }
1844
1845    #[gpui::test]
1846    async fn test_remote_url_detection_https(cx: &mut TestAppContext) {
1847        init_test(cx);
1848        let (_project, repository) = init_fake_repository(cx).await;
1849        let branches = vec![create_test_branch("main", true, None, Some(1000))];
1850
1851        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1852        let cx = &mut ctx;
1853
1854        branch_list
1855            .update_in(cx, |branch_list, window, cx| {
1856                branch_list.picker.update(cx, |picker, cx| {
1857                    let query = "https://github.com/user/repo.git".to_string();
1858                    picker.delegate.update_matches(query, window, cx)
1859                })
1860            })
1861            .await;
1862
1863        cx.run_until_parked();
1864
1865        branch_list
1866            .update_in(cx, |branch_list, window, cx| {
1867                branch_list.picker.update(cx, |picker, cx| {
1868                    let last_match = picker.delegate.matches.last().unwrap();
1869                    assert!(last_match.is_new_url());
1870                    assert!(matches!(picker.delegate.state, PickerState::NewRemote));
1871                    picker.delegate.confirm(false, window, cx);
1872                    assert_eq!(picker.delegate.matches.len(), 0);
1873                    if let PickerState::CreateRemote(remote_url) = &picker.delegate.state
1874                        && remote_url.as_ref() == "https://github.com/user/repo.git"
1875                    {
1876                    } else {
1877                        panic!("wrong picker state");
1878                    }
1879                    picker
1880                        .delegate
1881                        .update_matches("my_new_remote".to_string(), window, cx)
1882                })
1883            })
1884            .await;
1885
1886        cx.run_until_parked();
1887
1888        branch_list.update_in(cx, |branch_list, window, cx| {
1889            branch_list.picker.update(cx, |picker, cx| {
1890                assert_eq!(picker.delegate.matches.len(), 1);
1891                assert!(matches!(
1892                    picker.delegate.matches.first(),
1893                    Some(Entry::NewRemoteName { name, url })
1894                        if name == "my_new_remote" && url.as_ref() == "https://github.com/user/repo.git"
1895                ));
1896                picker.delegate.confirm(false, window, cx);
1897            })
1898        });
1899        cx.run_until_parked();
1900
1901        // List remotes
1902        let remotes = branch_list
1903            .update(cx, |branch_list, cx| {
1904                branch_list.picker.update(cx, |picker, cx| {
1905                    picker
1906                        .delegate
1907                        .repo
1908                        .as_ref()
1909                        .unwrap()
1910                        .update(cx, |repo, _cx| repo.get_remotes(None, false))
1911                })
1912            })
1913            .await
1914            .unwrap()
1915            .unwrap();
1916        assert_eq!(
1917            remotes,
1918            vec![Remote {
1919                name: SharedString::from("my_new_remote".to_string())
1920            }]
1921        );
1922    }
1923
1924    #[gpui::test]
1925    async fn test_confirm_remote_url_transitions(cx: &mut TestAppContext) {
1926        init_test(cx);
1927
1928        let branches = vec![create_test_branch("main_branch", true, None, Some(1000))];
1929        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1930        let cx = &mut ctx;
1931
1932        branch_list
1933            .update_in(cx, |branch_list, window, cx| {
1934                branch_list.picker.update(cx, |picker, cx| {
1935                    let query = "https://github.com/user/repo.git".to_string();
1936                    picker.delegate.update_matches(query, window, cx)
1937                })
1938            })
1939            .await;
1940        cx.run_until_parked();
1941
1942        // Try to create a new remote but cancel in the middle of the process
1943        branch_list
1944            .update_in(cx, |branch_list, window, cx| {
1945                branch_list.picker.update(cx, |picker, cx| {
1946                    picker.delegate.selected_index = picker.delegate.matches.len() - 1;
1947                    picker.delegate.confirm(false, window, cx);
1948
1949                    assert!(matches!(
1950                        picker.delegate.state,
1951                        PickerState::CreateRemote(_)
1952                    ));
1953                    if let PickerState::CreateRemote(ref url) = picker.delegate.state {
1954                        assert_eq!(url.as_ref(), "https://github.com/user/repo.git");
1955                    }
1956                    assert_eq!(picker.delegate.matches.len(), 0);
1957                    picker.delegate.dismissed(window, cx);
1958                    assert!(matches!(picker.delegate.state, PickerState::List));
1959                    let query = "main".to_string();
1960                    picker.delegate.update_matches(query, window, cx)
1961                })
1962            })
1963            .await;
1964        cx.run_until_parked();
1965
1966        // Try to search a branch again to see if the state is restored properly
1967        branch_list.update(cx, |branch_list, cx| {
1968            branch_list.picker.update(cx, |picker, _cx| {
1969                // Should have 1 existing branch + 1 "create new branch" entry = 2 total
1970                assert_eq!(picker.delegate.matches.len(), 2);
1971                assert!(
1972                    picker
1973                        .delegate
1974                        .matches
1975                        .iter()
1976                        .any(|m| m.name() == "main_branch")
1977                );
1978                // Verify the last entry is the "create new branch" option
1979                let last_match = picker.delegate.matches.last().unwrap();
1980                assert!(last_match.is_new_branch());
1981            })
1982        });
1983    }
1984
1985    #[gpui::test]
1986    async fn test_confirm_remote_url_does_not_dismiss(cx: &mut TestAppContext) {
1987        const REMOTE_URL: &str = "https://github.com/user/repo.git";
1988
1989        init_test(cx);
1990        let branches = vec![create_test_branch("main", true, None, Some(1000))];
1991
1992        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1993        let cx = &mut ctx;
1994
1995        let subscription = cx.update(|_, cx| {
1996            cx.subscribe(&branch_list, |_, _: &DismissEvent, _| {
1997                panic!("DismissEvent should not be emitted when confirming a remote URL");
1998            })
1999        });
2000
2001        branch_list
2002            .update_in(cx, |branch_list, window, cx| {
2003                window.focus(&branch_list.picker_focus_handle, cx);
2004                assert!(
2005                    branch_list.picker_focus_handle.is_focused(window),
2006                    "Branch picker should be focused when selecting an entry"
2007                );
2008
2009                branch_list.picker.update(cx, |picker, cx| {
2010                    picker
2011                        .delegate
2012                        .update_matches(REMOTE_URL.to_string(), window, cx)
2013                })
2014            })
2015            .await;
2016
2017        cx.run_until_parked();
2018
2019        branch_list.update_in(cx, |branch_list, window, cx| {
2020            // Re-focus the picker since workspace initialization during run_until_parked
2021            window.focus(&branch_list.picker_focus_handle, cx);
2022
2023            branch_list.picker.update(cx, |picker, cx| {
2024                let last_match = picker.delegate.matches.last().unwrap();
2025                assert!(last_match.is_new_url());
2026                assert!(matches!(picker.delegate.state, PickerState::NewRemote));
2027
2028                picker.delegate.confirm(false, window, cx);
2029
2030                assert!(
2031                    matches!(picker.delegate.state, PickerState::CreateRemote(ref url) if url.as_ref() == REMOTE_URL),
2032                    "State should transition to CreateRemote with the URL"
2033                );
2034            });
2035
2036            assert!(
2037                branch_list.picker_focus_handle.is_focused(window),
2038                "Branch list picker should still be focused after confirming remote URL"
2039            );
2040        });
2041
2042        cx.run_until_parked();
2043
2044        drop(subscription);
2045    }
2046
2047    #[gpui::test(iterations = 10)]
2048    async fn test_empty_query_displays_all_branches(mut rng: StdRng, cx: &mut TestAppContext) {
2049        init_test(cx);
2050        let branch_count = rng.random_range(13..540);
2051
2052        let branches: Vec<Branch> = (0..branch_count)
2053            .map(|i| create_test_branch(&format!("branch-{:02}", i), i == 0, None, Some(i * 100)))
2054            .collect();
2055
2056        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
2057        let cx = &mut ctx;
2058
2059        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
2060
2061        branch_list.update(cx, |branch_list, cx| {
2062            branch_list.picker.update(cx, |picker, _cx| {
2063                assert_eq!(picker.delegate.matches.len(), branch_count as usize);
2064            })
2065        });
2066    }
2067}