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