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