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