branch_picker.rs

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