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