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