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