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 is_remote;
 490            let result = match &entry {
 491                Entry::Branch { branch, .. } => {
 492                    is_remote = branch.is_remote();
 493                    repo.update(cx, |repo, _| {
 494                        repo.delete_branch(is_remote, branch.name().to_string())
 495                    })
 496                    .await?
 497                }
 498                _ => {
 499                    log::error!("Failed to delete entry: wrong entry to delete");
 500                    return Ok(());
 501                }
 502            };
 503
 504            if let Err(e) = result {
 505                if is_remote {
 506                    log::error!("Failed to delete remote branch: {}", e);
 507                } else {
 508                    log::error!("Failed to delete branch: {}", e);
 509                }
 510
 511                if let Some(workspace) = workspace.upgrade() {
 512                    cx.update(|_window, cx| {
 513                        if is_remote {
 514                            show_error_toast(
 515                                workspace,
 516                                format!("branch -dr {}", entry.name()),
 517                                e,
 518                                cx,
 519                            )
 520                        } else {
 521                            show_error_toast(
 522                                workspace,
 523                                format!("branch -d {}", entry.name()),
 524                                e,
 525                                cx,
 526                            )
 527                        }
 528                    })?;
 529                }
 530
 531                return Ok(());
 532            }
 533
 534            picker.update_in(cx, |picker, _, cx| {
 535                picker.delegate.matches.retain(|e| e != &entry);
 536
 537                if let Entry::Branch { branch, .. } = &entry {
 538                    if let Some(all_branches) = &mut picker.delegate.all_branches {
 539                        all_branches.retain(|e| e.ref_name != branch.ref_name);
 540                    }
 541                }
 542
 543                if picker.delegate.matches.is_empty() {
 544                    picker.delegate.selected_index = 0;
 545                } else if picker.delegate.selected_index >= picker.delegate.matches.len() {
 546                    picker.delegate.selected_index = picker.delegate.matches.len() - 1;
 547                }
 548
 549                cx.notify();
 550            })?;
 551
 552            anyhow::Ok(())
 553        })
 554        .detach();
 555    }
 556}
 557
 558impl PickerDelegate for BranchListDelegate {
 559    type ListItem = ListItem;
 560
 561    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
 562        match self.state {
 563            PickerState::List | PickerState::NewRemote | PickerState::NewBranch => {
 564                match self.branch_filter {
 565                    BranchFilter::All => "Select branch or remote…",
 566                    BranchFilter::Remote => "Select remote…",
 567                }
 568            }
 569            PickerState::CreateRemote(_) => "Enter a name for this remote…",
 570        }
 571        .into()
 572    }
 573
 574    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
 575        match self.state {
 576            PickerState::CreateRemote(_) => {
 577                Some(SharedString::new_static("Remote name can't be empty"))
 578            }
 579            _ => None,
 580        }
 581    }
 582
 583    fn render_editor(
 584        &self,
 585        editor: &Arc<dyn ErasedEditor>,
 586        _window: &mut Window,
 587        _cx: &mut Context<Picker<Self>>,
 588    ) -> Div {
 589        let focus_handle = self.focus_handle.clone();
 590        let editor = editor.as_any().downcast_ref::<Entity<Editor>>().unwrap();
 591
 592        v_flex()
 593            .when(
 594                self.editor_position() == PickerEditorPosition::End,
 595                |this| this.child(Divider::horizontal()),
 596            )
 597            .child(
 598                h_flex()
 599                    .overflow_hidden()
 600                    .flex_none()
 601                    .h_9()
 602                    .px_2p5()
 603                    .child(editor.clone())
 604                    .when(
 605                        self.editor_position() == PickerEditorPosition::End,
 606                        |this| {
 607                            let tooltip_label = match self.branch_filter {
 608                                BranchFilter::All => "Filter Remote Branches",
 609                                BranchFilter::Remote => "Show All Branches",
 610                            };
 611
 612                            this.gap_1().justify_between().child({
 613                                IconButton::new("filter-remotes", IconName::Filter)
 614                                    .toggle_state(self.branch_filter == BranchFilter::Remote)
 615                                    .tooltip(move |_, cx| {
 616                                        Tooltip::for_action_in(
 617                                            tooltip_label,
 618                                            &branch_picker::FilterRemotes,
 619                                            &focus_handle,
 620                                            cx,
 621                                        )
 622                                    })
 623                                    .on_click(|_click, window, cx| {
 624                                        window.dispatch_action(
 625                                            branch_picker::FilterRemotes.boxed_clone(),
 626                                            cx,
 627                                        );
 628                                    })
 629                            })
 630                        },
 631                    ),
 632            )
 633            .when(
 634                self.editor_position() == PickerEditorPosition::Start,
 635                |this| this.child(Divider::horizontal()),
 636            )
 637    }
 638
 639    fn editor_position(&self) -> PickerEditorPosition {
 640        match self.style {
 641            BranchListStyle::Modal => PickerEditorPosition::Start,
 642            BranchListStyle::Popover => PickerEditorPosition::End,
 643        }
 644    }
 645
 646    fn match_count(&self) -> usize {
 647        self.matches.len()
 648    }
 649
 650    fn selected_index(&self) -> usize {
 651        self.selected_index
 652    }
 653
 654    fn set_selected_index(
 655        &mut self,
 656        ix: usize,
 657        _window: &mut Window,
 658        _: &mut Context<Picker<Self>>,
 659    ) {
 660        self.selected_index = ix;
 661    }
 662
 663    fn update_matches(
 664        &mut self,
 665        query: String,
 666        window: &mut Window,
 667        cx: &mut Context<Picker<Self>>,
 668    ) -> Task<()> {
 669        let Some(all_branches) = self.all_branches.clone() else {
 670            return Task::ready(());
 671        };
 672
 673        let branch_filter = self.branch_filter;
 674        cx.spawn_in(window, async move |picker, cx| {
 675            let branch_matches_filter = |branch: &Branch| match branch_filter {
 676                BranchFilter::All => true,
 677                BranchFilter::Remote => branch.is_remote(),
 678            };
 679
 680            let mut matches: Vec<Entry> = if query.is_empty() {
 681                let mut matches: Vec<Entry> = all_branches
 682                    .into_iter()
 683                    .filter(|branch| branch_matches_filter(branch))
 684                    .map(|branch| Entry::Branch {
 685                        branch,
 686                        positions: Vec::new(),
 687                    })
 688                    .collect();
 689
 690                // Keep the existing recency sort within each group, but show local branches first.
 691                matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote()));
 692
 693                matches
 694            } else {
 695                let branches = all_branches
 696                    .iter()
 697                    .filter(|branch| branch_matches_filter(branch))
 698                    .collect::<Vec<_>>();
 699                let candidates = branches
 700                    .iter()
 701                    .enumerate()
 702                    .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
 703                    .collect::<Vec<StringMatchCandidate>>();
 704                let mut matches: Vec<Entry> = fuzzy::match_strings(
 705                    &candidates,
 706                    &query,
 707                    true,
 708                    true,
 709                    10000,
 710                    &Default::default(),
 711                    cx.background_executor().clone(),
 712                )
 713                .await
 714                .into_iter()
 715                .map(|candidate| Entry::Branch {
 716                    branch: branches[candidate.candidate_id].clone(),
 717                    positions: candidate.positions,
 718                })
 719                .collect();
 720
 721                // Keep fuzzy-relevance ordering within local/remote groups, but show locals first.
 722                matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote()));
 723
 724                matches
 725            };
 726            picker
 727                .update(cx, |picker, _| {
 728                    if let PickerState::CreateRemote(url) = &picker.delegate.state {
 729                        let query = query.replace(' ', "-");
 730                        if !query.is_empty() {
 731                            picker.delegate.matches = vec![Entry::NewRemoteName {
 732                                name: query.clone(),
 733                                url: url.clone(),
 734                            }];
 735                            picker.delegate.selected_index = 0;
 736                        } else {
 737                            picker.delegate.matches = Vec::new();
 738                            picker.delegate.selected_index = 0;
 739                        }
 740                        picker.delegate.last_query = query;
 741                        return;
 742                    }
 743
 744                    if !query.is_empty()
 745                        && !matches.first().is_some_and(|entry| entry.name() == query)
 746                    {
 747                        let query = query.replace(' ', "-");
 748                        let is_url = query.trim_start_matches("git@").parse::<Url>().is_ok();
 749                        let entry = if is_url {
 750                            Entry::NewUrl { url: query }
 751                        } else {
 752                            Entry::NewBranch { name: query }
 753                        };
 754                        // Only transition to NewBranch/NewRemote states when we only show their list item
 755                        // Otherwise, stay in List state so footer buttons remain visible
 756                        picker.delegate.state = if matches.is_empty() {
 757                            if is_url {
 758                                PickerState::NewRemote
 759                            } else {
 760                                PickerState::NewBranch
 761                            }
 762                        } else {
 763                            PickerState::List
 764                        };
 765                        matches.push(entry);
 766                    } else {
 767                        picker.delegate.state = PickerState::List;
 768                    }
 769                    let delegate = &mut picker.delegate;
 770                    delegate.matches = matches;
 771                    if delegate.matches.is_empty() {
 772                        delegate.selected_index = 0;
 773                    } else {
 774                        delegate.selected_index =
 775                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
 776                    }
 777                    delegate.last_query = query;
 778                })
 779                .log_err();
 780        })
 781    }
 782
 783    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
 784        let Some(entry) = self.matches.get(self.selected_index()) else {
 785            return;
 786        };
 787
 788        match entry {
 789            Entry::Branch { branch, .. } => {
 790                let current_branch = self.repo.as_ref().map(|repo| {
 791                    repo.read_with(cx, |repo, _| {
 792                        repo.branch.as_ref().map(|branch| branch.ref_name.clone())
 793                    })
 794                });
 795
 796                if current_branch
 797                    .flatten()
 798                    .is_some_and(|current_branch| current_branch == branch.ref_name)
 799                {
 800                    cx.emit(DismissEvent);
 801                    return;
 802                }
 803
 804                let Some(repo) = self.repo.clone() else {
 805                    return;
 806                };
 807
 808                let branch = branch.clone();
 809                cx.spawn(async move |_, cx| {
 810                    repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))
 811                        .await??;
 812
 813                    anyhow::Ok(())
 814                })
 815                .detach_and_prompt_err(
 816                    "Failed to change branch",
 817                    window,
 818                    cx,
 819                    |_, _, _| None,
 820                );
 821            }
 822            Entry::NewUrl { url } => {
 823                self.state = PickerState::CreateRemote(url.clone().into());
 824                self.matches = Vec::new();
 825                self.selected_index = 0;
 826
 827                cx.defer_in(window, |picker, window, cx| {
 828                    picker.refresh_placeholder(window, cx);
 829                    picker.set_query("", window, cx);
 830                    cx.notify();
 831                });
 832
 833                // returning early to prevent dismissing the modal, so a user can enter
 834                // a remote name first.
 835                return;
 836            }
 837            Entry::NewRemoteName { name, url } => {
 838                self.create_remote(name.clone(), url.to_string(), window, cx);
 839            }
 840            Entry::NewBranch { name } => {
 841                let from_branch = if secondary {
 842                    self.default_branch.clone()
 843                } else {
 844                    None
 845                };
 846                self.create_branch(from_branch, name.into(), window, cx);
 847            }
 848        }
 849
 850        cx.emit(DismissEvent);
 851    }
 852
 853    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
 854        self.state = PickerState::List;
 855        cx.emit(DismissEvent);
 856    }
 857
 858    fn render_match(
 859        &self,
 860        ix: usize,
 861        selected: bool,
 862        _window: &mut Window,
 863        cx: &mut Context<Picker<Self>>,
 864    ) -> Option<Self::ListItem> {
 865        let entry = &self.matches.get(ix)?;
 866
 867        let (commit_time, author_name, subject) = entry
 868            .as_branch()
 869            .and_then(|branch| {
 870                branch.most_recent_commit.as_ref().map(|commit| {
 871                    let subject = commit.subject.clone();
 872                    let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
 873                        .unwrap_or_else(|_| OffsetDateTime::now_utc());
 874                    let local_offset =
 875                        time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
 876                    let formatted_time = time_format::format_localized_timestamp(
 877                        commit_time,
 878                        OffsetDateTime::now_utc(),
 879                        local_offset,
 880                        time_format::TimestampFormat::Relative,
 881                    );
 882                    let author = commit.author_name.clone();
 883                    (Some(formatted_time), Some(author), Some(subject))
 884                })
 885            })
 886            .unwrap_or_else(|| (None, None, None));
 887
 888        let entry_icon = match entry {
 889            Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => {
 890                Icon::new(IconName::Plus).color(Color::Muted)
 891            }
 892            Entry::Branch { branch, .. } => {
 893                if branch.is_remote() {
 894                    Icon::new(IconName::Screen).color(Color::Muted)
 895                } else {
 896                    Icon::new(IconName::GitBranchAlt).color(Color::Muted)
 897                }
 898            }
 899        };
 900
 901        let entry_title = match entry {
 902            Entry::NewUrl { .. } => Label::new("Create Remote Repository")
 903                .single_line()
 904                .truncate()
 905                .into_any_element(),
 906            Entry::NewBranch { name } => Label::new(format!("Create Branch: \"{name}\""))
 907                .single_line()
 908                .truncate()
 909                .into_any_element(),
 910            Entry::NewRemoteName { name, .. } => Label::new(format!("Create Remote: \"{name}\""))
 911                .single_line()
 912                .truncate()
 913                .into_any_element(),
 914            Entry::Branch { branch, positions } => {
 915                HighlightedLabel::new(branch.name().to_string(), positions.clone())
 916                    .single_line()
 917                    .truncate()
 918                    .into_any_element()
 919            }
 920        };
 921
 922        let focus_handle = self.focus_handle.clone();
 923        let is_new_items = matches!(
 924            entry,
 925            Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. }
 926        );
 927
 928        let deleted_branch_icon = |entry_ix: usize, is_head_branch: bool| {
 929            IconButton::new(("delete", entry_ix), IconName::Trash)
 930                .tooltip(move |_, cx| {
 931                    Tooltip::for_action_in(
 932                        "Delete Branch",
 933                        &branch_picker::DeleteBranch,
 934                        &focus_handle,
 935                        cx,
 936                    )
 937                })
 938                .disabled(is_head_branch)
 939                .on_click(cx.listener(move |this, _, window, cx| {
 940                    this.delegate.delete_at(entry_ix, window, cx);
 941                }))
 942        };
 943
 944        let create_from_default_button = self.default_branch.as_ref().map(|default_branch| {
 945            let tooltip_label: SharedString = format!("Create New From: {default_branch}").into();
 946            let focus_handle = self.focus_handle.clone();
 947
 948            IconButton::new("create_from_default", IconName::GitBranchPlus)
 949                .tooltip(move |_, cx| {
 950                    Tooltip::for_action_in(
 951                        tooltip_label.clone(),
 952                        &menu::SecondaryConfirm,
 953                        &focus_handle,
 954                        cx,
 955                    )
 956                })
 957                .on_click(cx.listener(|this, _, window, cx| {
 958                    this.delegate.confirm(true, window, cx);
 959                }))
 960                .into_any_element()
 961        });
 962
 963        Some(
 964            ListItem::new(format!("vcs-menu-{ix}"))
 965                .inset(true)
 966                .spacing(ListItemSpacing::Sparse)
 967                .toggle_state(selected)
 968                .child(
 969                    h_flex()
 970                        .w_full()
 971                        .gap_3()
 972                        .flex_grow()
 973                        .child(entry_icon)
 974                        .child(
 975                            v_flex()
 976                                .id("info_container")
 977                                .w_full()
 978                                .child(entry_title)
 979                                .child(
 980                                    h_flex()
 981                                        .w_full()
 982                                        .justify_between()
 983                                        .gap_1p5()
 984                                        .when(self.style == BranchListStyle::Modal, |el| {
 985                                            el.child(div().max_w_96().child({
 986                                                let message = match entry {
 987                                                    Entry::NewUrl { url } => {
 988                                                        format!("Based off {url}")
 989                                                    }
 990                                                    Entry::NewRemoteName { url, .. } => {
 991                                                        format!("Based off {url}")
 992                                                    }
 993                                                    Entry::NewBranch { .. } => {
 994                                                        if let Some(current_branch) =
 995                                                            self.repo.as_ref().and_then(|repo| {
 996                                                                repo.read(cx)
 997                                                                    .branch
 998                                                                    .as_ref()
 999                                                                    .map(|b| b.name())
1000                                                            })
1001                                                        {
1002                                                            format!("Based off {}", current_branch)
1003                                                        } else {
1004                                                            "Based off the current branch"
1005                                                                .to_string()
1006                                                        }
1007                                                    }
1008                                                    Entry::Branch { .. } => {
1009                                                        let show_author_name =
1010                                                            ProjectSettings::get_global(cx)
1011                                                                .git
1012                                                                .branch_picker
1013                                                                .show_author_name;
1014
1015                                                        subject.map_or(
1016                                                            "No commits found".into(),
1017                                                            |subject| {
1018                                                                if show_author_name
1019                                                                    && let Some(author) =
1020                                                                        author_name
1021                                                                {
1022                                                                    format!(
1023                                                                        "{}{}",
1024                                                                        author, subject
1025                                                                    )
1026                                                                } else {
1027                                                                    subject.to_string()
1028                                                                }
1029                                                            },
1030                                                        )
1031                                                    }
1032                                                };
1033
1034                                                Label::new(message)
1035                                                    .size(LabelSize::Small)
1036                                                    .color(Color::Muted)
1037                                                    .truncate()
1038                                            }))
1039                                        })
1040                                        .when_some(commit_time, |label, commit_time| {
1041                                            label.child(
1042                                                Label::new(commit_time)
1043                                                    .size(LabelSize::Small)
1044                                                    .color(Color::Muted),
1045                                            )
1046                                        }),
1047                                )
1048                                .when_some(
1049                                    entry.as_branch().map(|b| b.name().to_string()),
1050                                    |this, branch_name| this.tooltip(Tooltip::text(branch_name)),
1051                                ),
1052                        ),
1053                )
1054                .when(
1055                    self.editor_position() == PickerEditorPosition::End && !is_new_items,
1056                    |this| {
1057                        this.map(|this| {
1058                            let is_head_branch =
1059                                entry.as_branch().is_some_and(|branch| branch.is_head);
1060                            if self.selected_index() == ix {
1061                                this.end_slot(deleted_branch_icon(ix, is_head_branch))
1062                            } else {
1063                                this.end_hover_slot(deleted_branch_icon(ix, is_head_branch))
1064                            }
1065                        })
1066                    },
1067                )
1068                .when_some(
1069                    if self.editor_position() == PickerEditorPosition::End && is_new_items {
1070                        create_from_default_button
1071                    } else {
1072                        None
1073                    },
1074                    |this, create_from_default_button| {
1075                        this.map(|this| {
1076                            if self.selected_index() == ix {
1077                                this.end_slot(create_from_default_button)
1078                            } else {
1079                                this.end_hover_slot(create_from_default_button)
1080                            }
1081                        })
1082                    },
1083                ),
1084        )
1085    }
1086
1087    fn render_header(
1088        &self,
1089        _window: &mut Window,
1090        _cx: &mut Context<Picker<Self>>,
1091    ) -> Option<AnyElement> {
1092        matches!(self.state, PickerState::List).then(|| {
1093            let label = match self.branch_filter {
1094                BranchFilter::All => "Branches",
1095                BranchFilter::Remote => "Remotes",
1096            };
1097
1098            ListHeader::new(label).inset(true).into_any_element()
1099        })
1100    }
1101
1102    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1103        if self.editor_position() == PickerEditorPosition::End {
1104            return None;
1105        }
1106        let focus_handle = self.focus_handle.clone();
1107
1108        let footer_container = || {
1109            h_flex()
1110                .w_full()
1111                .p_1p5()
1112                .border_t_1()
1113                .border_color(cx.theme().colors().border_variant)
1114        };
1115
1116        match self.state {
1117            PickerState::List => {
1118                let selected_entry = self.matches.get(self.selected_index);
1119
1120                let branch_from_default_button = self
1121                    .default_branch
1122                    .as_ref()
1123                    .filter(|_| matches!(selected_entry, Some(Entry::NewBranch { .. })))
1124                    .map(|default_branch| {
1125                        let button_label = format!("Create New From: {default_branch}");
1126
1127                        Button::new("branch-from-default", button_label)
1128                            .key_binding(
1129                                KeyBinding::for_action_in(
1130                                    &menu::SecondaryConfirm,
1131                                    &focus_handle,
1132                                    cx,
1133                                )
1134                                .map(|kb| kb.size(rems_from_px(12.))),
1135                            )
1136                            .on_click(cx.listener(|this, _, window, cx| {
1137                                this.delegate.confirm(true, window, cx);
1138                            }))
1139                    });
1140
1141                let delete_and_select_btns = h_flex()
1142                    .gap_1()
1143                    .child(
1144                        Button::new("delete-branch", "Delete")
1145                            .key_binding(
1146                                KeyBinding::for_action_in(
1147                                    &branch_picker::DeleteBranch,
1148                                    &focus_handle,
1149                                    cx,
1150                                )
1151                                .map(|kb| kb.size(rems_from_px(12.))),
1152                            )
1153                            .on_click(|_, window, cx| {
1154                                window
1155                                    .dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx);
1156                            }),
1157                    )
1158                    .child(
1159                        Button::new("select_branch", "Select")
1160                            .key_binding(
1161                                KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1162                                    .map(|kb| kb.size(rems_from_px(12.))),
1163                            )
1164                            .on_click(cx.listener(|this, _, window, cx| {
1165                                this.delegate.confirm(false, window, cx);
1166                            })),
1167                    );
1168
1169                Some(
1170                    footer_container()
1171                        .map(|this| {
1172                            if branch_from_default_button.is_some() {
1173                                this.justify_end().when_some(
1174                                    branch_from_default_button,
1175                                    |this, button| {
1176                                        this.child(button).child(
1177                                            Button::new("create", "Create")
1178                                                .key_binding(
1179                                                    KeyBinding::for_action_in(
1180                                                        &menu::Confirm,
1181                                                        &focus_handle,
1182                                                        cx,
1183                                                    )
1184                                                    .map(|kb| kb.size(rems_from_px(12.))),
1185                                                )
1186                                                .on_click(cx.listener(|this, _, window, cx| {
1187                                                    this.delegate.confirm(false, window, cx);
1188                                                })),
1189                                        )
1190                                    },
1191                                )
1192                            } else {
1193                                this.justify_between()
1194                                    .child({
1195                                        let focus_handle = focus_handle.clone();
1196                                        Button::new("filter-remotes", "Filter Remotes")
1197                                            .toggle_state(matches!(
1198                                                self.branch_filter,
1199                                                BranchFilter::Remote
1200                                            ))
1201                                            .key_binding(
1202                                                KeyBinding::for_action_in(
1203                                                    &branch_picker::FilterRemotes,
1204                                                    &focus_handle,
1205                                                    cx,
1206                                                )
1207                                                .map(|kb| kb.size(rems_from_px(12.))),
1208                                            )
1209                                            .on_click(|_click, window, cx| {
1210                                                window.dispatch_action(
1211                                                    branch_picker::FilterRemotes.boxed_clone(),
1212                                                    cx,
1213                                                );
1214                                            })
1215                                    })
1216                                    .child(delete_and_select_btns)
1217                            }
1218                        })
1219                        .into_any_element(),
1220                )
1221            }
1222            PickerState::NewBranch => {
1223                let branch_from_default_button =
1224                    self.default_branch.as_ref().map(|default_branch| {
1225                        let button_label = format!("Create New From: {default_branch}");
1226
1227                        Button::new("branch-from-default", button_label)
1228                            .key_binding(
1229                                KeyBinding::for_action_in(
1230                                    &menu::SecondaryConfirm,
1231                                    &focus_handle,
1232                                    cx,
1233                                )
1234                                .map(|kb| kb.size(rems_from_px(12.))),
1235                            )
1236                            .on_click(cx.listener(|this, _, window, cx| {
1237                                this.delegate.confirm(true, window, cx);
1238                            }))
1239                    });
1240
1241                Some(
1242                    footer_container()
1243                        .gap_1()
1244                        .justify_end()
1245                        .when_some(branch_from_default_button, |this, button| {
1246                            this.child(button)
1247                        })
1248                        .child(
1249                            Button::new("branch-from-default", "Create")
1250                                .key_binding(
1251                                    KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1252                                        .map(|kb| kb.size(rems_from_px(12.))),
1253                                )
1254                                .on_click(cx.listener(|this, _, window, cx| {
1255                                    this.delegate.confirm(false, window, cx);
1256                                })),
1257                        )
1258                        .into_any_element(),
1259                )
1260            }
1261            PickerState::CreateRemote(_) => Some(
1262                footer_container()
1263                    .justify_end()
1264                    .child(
1265                        Button::new("branch-from-default", "Confirm")
1266                            .key_binding(
1267                                KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1268                                    .map(|kb| kb.size(rems_from_px(12.))),
1269                            )
1270                            .on_click(cx.listener(|this, _, window, cx| {
1271                                this.delegate.confirm(false, window, cx);
1272                            }))
1273                            .disabled(self.last_query.is_empty()),
1274                    )
1275                    .into_any_element(),
1276            ),
1277            PickerState::NewRemote => None,
1278        }
1279    }
1280}
1281
1282#[cfg(test)]
1283mod tests {
1284    use std::collections::HashSet;
1285
1286    use super::*;
1287    use git::repository::{CommitSummary, Remote};
1288    use gpui::{AppContext, TestAppContext, VisualTestContext};
1289    use project::{FakeFs, Project};
1290    use rand::{Rng, rngs::StdRng};
1291    use serde_json::json;
1292    use settings::SettingsStore;
1293    use util::path;
1294    use workspace::MultiWorkspace;
1295
1296    fn init_test(cx: &mut TestAppContext) {
1297        cx.update(|cx| {
1298            let settings_store = SettingsStore::test(cx);
1299            cx.set_global(settings_store);
1300            theme::init(theme::LoadThemes::JustBase, cx);
1301            editor::init(cx);
1302        });
1303    }
1304
1305    fn create_test_branch(
1306        name: &str,
1307        is_head: bool,
1308        remote_name: Option<&str>,
1309        timestamp: Option<i64>,
1310    ) -> Branch {
1311        let ref_name = match remote_name {
1312            Some(remote_name) => format!("refs/remotes/{remote_name}/{name}"),
1313            None => format!("refs/heads/{name}"),
1314        };
1315
1316        Branch {
1317            is_head,
1318            ref_name: ref_name.into(),
1319            upstream: None,
1320            most_recent_commit: timestamp.map(|ts| CommitSummary {
1321                sha: "abc123".into(),
1322                commit_timestamp: ts,
1323                author_name: "Test Author".into(),
1324                subject: "Test commit".into(),
1325                has_parent: true,
1326            }),
1327        }
1328    }
1329
1330    fn create_test_branches() -> Vec<Branch> {
1331        vec![
1332            create_test_branch("main", true, None, Some(1000)),
1333            create_test_branch("feature-auth", false, None, Some(900)),
1334            create_test_branch("feature-ui", false, None, Some(800)),
1335            create_test_branch("develop", false, None, Some(700)),
1336        ]
1337    }
1338
1339    async fn init_branch_list_test(
1340        repository: Option<Entity<Repository>>,
1341        branches: Vec<Branch>,
1342        cx: &mut TestAppContext,
1343    ) -> (Entity<BranchList>, VisualTestContext) {
1344        let fs = FakeFs::new(cx.executor());
1345        let project = Project::test(fs, [], cx).await;
1346
1347        let window_handle =
1348            cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
1349        let workspace = window_handle
1350            .read_with(cx, |mw, _| mw.workspace().clone())
1351            .unwrap();
1352
1353        let branch_list = window_handle
1354            .update(cx, |_multi_workspace, window, cx| {
1355                cx.new(|cx| {
1356                    let mut delegate = BranchListDelegate::new(
1357                        workspace.downgrade(),
1358                        repository,
1359                        BranchListStyle::Modal,
1360                        cx,
1361                    );
1362                    delegate.all_branches = Some(branches);
1363                    let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
1364                    let picker_focus_handle = picker.focus_handle(cx);
1365                    picker.update(cx, |picker, _| {
1366                        picker.delegate.focus_handle = picker_focus_handle.clone();
1367                    });
1368
1369                    let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
1370                        cx.emit(DismissEvent);
1371                    });
1372
1373                    BranchList {
1374                        picker,
1375                        picker_focus_handle,
1376                        width: rems(34.),
1377                        _subscription: Some(_subscription),
1378                        embedded: false,
1379                    }
1380                })
1381            })
1382            .unwrap();
1383
1384        let cx = VisualTestContext::from_window(window_handle.into(), cx);
1385
1386        (branch_list, cx)
1387    }
1388
1389    async fn init_fake_repository(
1390        cx: &mut TestAppContext,
1391    ) -> (Entity<Project>, Entity<Repository>) {
1392        let fs = FakeFs::new(cx.executor());
1393        fs.insert_tree(
1394            path!("/dir"),
1395            json!({
1396                ".git": {},
1397                "file.txt": "buffer_text".to_string()
1398            }),
1399        )
1400        .await;
1401        fs.set_head_for_repo(
1402            path!("/dir/.git").as_ref(),
1403            &[("file.txt", "test".to_string())],
1404            "deadbeef",
1405        );
1406        fs.set_index_for_repo(
1407            path!("/dir/.git").as_ref(),
1408            &[("file.txt", "index_text".to_string())],
1409        );
1410
1411        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1412        let repository = cx.read(|cx| project.read(cx).active_repository(cx));
1413
1414        (project, repository.unwrap())
1415    }
1416
1417    #[gpui::test]
1418    async fn test_update_branch_matches_with_query(cx: &mut TestAppContext) {
1419        init_test(cx);
1420
1421        let branches = create_test_branches();
1422        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1423        let cx = &mut ctx;
1424
1425        branch_list
1426            .update_in(cx, |branch_list, window, cx| {
1427                let query = "feature".to_string();
1428                branch_list.picker.update(cx, |picker, cx| {
1429                    picker.delegate.update_matches(query, window, cx)
1430                })
1431            })
1432            .await;
1433        cx.run_until_parked();
1434
1435        branch_list.update(cx, |branch_list, cx| {
1436            branch_list.picker.update(cx, |picker, _cx| {
1437                // Should have 2 existing branches + 1 "create new branch" entry = 3 total
1438                assert_eq!(picker.delegate.matches.len(), 3);
1439                assert!(
1440                    picker
1441                        .delegate
1442                        .matches
1443                        .iter()
1444                        .any(|m| m.name() == "feature-auth")
1445                );
1446                assert!(
1447                    picker
1448                        .delegate
1449                        .matches
1450                        .iter()
1451                        .any(|m| m.name() == "feature-ui")
1452                );
1453                // Verify the last entry is the "create new branch" option
1454                let last_match = picker.delegate.matches.last().unwrap();
1455                assert!(last_match.is_new_branch());
1456            })
1457        });
1458    }
1459
1460    async fn update_branch_list_matches_with_empty_query(
1461        branch_list: &Entity<BranchList>,
1462        cx: &mut VisualTestContext,
1463    ) {
1464        branch_list
1465            .update_in(cx, |branch_list, window, cx| {
1466                branch_list.picker.update(cx, |picker, cx| {
1467                    picker.delegate.update_matches(String::new(), window, cx)
1468                })
1469            })
1470            .await;
1471        cx.run_until_parked();
1472    }
1473
1474    #[gpui::test]
1475    async fn test_delete_branch(cx: &mut TestAppContext) {
1476        init_test(cx);
1477        let (_project, repository) = init_fake_repository(cx).await;
1478
1479        let branches = create_test_branches();
1480
1481        let branch_names = branches
1482            .iter()
1483            .map(|branch| branch.name().to_string())
1484            .collect::<Vec<String>>();
1485        let repo = repository.clone();
1486        cx.spawn(async move |mut cx| {
1487            for branch in branch_names {
1488                repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
1489                    .await
1490                    .unwrap()
1491                    .unwrap();
1492            }
1493        })
1494        .await;
1495        cx.run_until_parked();
1496
1497        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1498        let cx = &mut ctx;
1499
1500        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1501
1502        let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| {
1503            branch_list.picker.update(cx, |picker, cx| {
1504                assert_eq!(picker.delegate.matches.len(), 4);
1505                let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
1506                picker.delegate.delete_at(1, window, cx);
1507                branch_to_delete
1508            })
1509        });
1510        cx.run_until_parked();
1511
1512        let expected_branches = ["main", "feature-auth", "feature-ui", "develop"]
1513            .into_iter()
1514            .filter(|name| name != &branch_to_delete)
1515            .collect::<HashSet<_>>();
1516        let repo_branches = branch_list
1517            .update(cx, |branch_list, cx| {
1518                branch_list.picker.update(cx, |picker, cx| {
1519                    picker
1520                        .delegate
1521                        .repo
1522                        .as_ref()
1523                        .unwrap()
1524                        .update(cx, |repo, _cx| repo.branches())
1525                })
1526            })
1527            .await
1528            .unwrap()
1529            .unwrap();
1530        let repo_branches = repo_branches
1531            .iter()
1532            .map(|b| b.name())
1533            .collect::<HashSet<_>>();
1534        assert_eq!(&repo_branches, &expected_branches);
1535
1536        branch_list.update(cx, move |branch_list, cx| {
1537            branch_list.picker.update(cx, move |picker, _cx| {
1538                assert_eq!(picker.delegate.matches.len(), 3);
1539                let branches = picker
1540                    .delegate
1541                    .matches
1542                    .iter()
1543                    .map(|be| be.name())
1544                    .collect::<HashSet<_>>();
1545                assert_eq!(branches, expected_branches);
1546            })
1547        });
1548    }
1549
1550    #[gpui::test]
1551    async fn test_delete_remote_branch(cx: &mut TestAppContext) {
1552        init_test(cx);
1553        let (_project, repository) = init_fake_repository(cx).await;
1554        let branches = vec![
1555            create_test_branch("main", true, Some("origin"), Some(1000)),
1556            create_test_branch("feature-auth", false, Some("origin"), Some(900)),
1557            create_test_branch("feature-ui", false, Some("fork"), Some(800)),
1558            create_test_branch("develop", false, Some("private"), Some(700)),
1559        ];
1560
1561        let branch_names = branches
1562            .iter()
1563            .map(|branch| branch.name().to_string())
1564            .collect::<Vec<String>>();
1565        let repo = repository.clone();
1566        cx.spawn(async move |mut cx| {
1567            for branch in branch_names {
1568                repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
1569                    .await
1570                    .unwrap()
1571                    .unwrap();
1572            }
1573        })
1574        .await;
1575        cx.run_until_parked();
1576
1577        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1578        let cx = &mut ctx;
1579        // Enable remote filter
1580        branch_list.update(cx, |branch_list, cx| {
1581            branch_list.picker.update(cx, |picker, _cx| {
1582                picker.delegate.branch_filter = BranchFilter::Remote;
1583            });
1584        });
1585        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1586
1587        // Check matches, it should match all existing branches and no option to create new branch
1588        let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| {
1589            branch_list.picker.update(cx, |picker, cx| {
1590                assert_eq!(picker.delegate.matches.len(), 4);
1591                let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
1592                picker.delegate.delete_at(1, window, cx);
1593                branch_to_delete
1594            })
1595        });
1596        cx.run_until_parked();
1597
1598        let expected_branches = [
1599            "origin/main",
1600            "origin/feature-auth",
1601            "fork/feature-ui",
1602            "private/develop",
1603        ]
1604        .into_iter()
1605        .filter(|name| name != &branch_to_delete)
1606        .collect::<HashSet<_>>();
1607        let repo_branches = branch_list
1608            .update(cx, |branch_list, cx| {
1609                branch_list.picker.update(cx, |picker, cx| {
1610                    picker
1611                        .delegate
1612                        .repo
1613                        .as_ref()
1614                        .unwrap()
1615                        .update(cx, |repo, _cx| repo.branches())
1616                })
1617            })
1618            .await
1619            .unwrap()
1620            .unwrap();
1621        let repo_branches = repo_branches
1622            .iter()
1623            .map(|b| b.name())
1624            .collect::<HashSet<_>>();
1625        assert_eq!(&repo_branches, &expected_branches);
1626
1627        // Check matches, it should match one less branch than before
1628        branch_list.update(cx, move |branch_list, cx| {
1629            branch_list.picker.update(cx, move |picker, _cx| {
1630                assert_eq!(picker.delegate.matches.len(), 3);
1631                let branches = picker
1632                    .delegate
1633                    .matches
1634                    .iter()
1635                    .map(|be| be.name())
1636                    .collect::<HashSet<_>>();
1637                assert_eq!(branches, expected_branches);
1638            })
1639        });
1640    }
1641
1642    #[gpui::test]
1643    async fn test_branch_filter_shows_all_then_remotes_and_applies_query(cx: &mut TestAppContext) {
1644        init_test(cx);
1645
1646        let branches = vec![
1647            create_test_branch("main", true, Some("origin"), Some(1000)),
1648            create_test_branch("feature-auth", false, Some("fork"), Some(900)),
1649            create_test_branch("feature-ui", false, None, Some(800)),
1650            create_test_branch("develop", false, None, Some(700)),
1651        ];
1652
1653        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1654        let cx = &mut ctx;
1655
1656        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1657
1658        branch_list.update(cx, |branch_list, cx| {
1659            branch_list.picker.update(cx, |picker, _cx| {
1660                assert_eq!(picker.delegate.matches.len(), 4);
1661
1662                let branches = picker
1663                    .delegate
1664                    .matches
1665                    .iter()
1666                    .map(|be| be.name())
1667                    .collect::<HashSet<_>>();
1668                assert_eq!(
1669                    branches,
1670                    ["origin/main", "fork/feature-auth", "feature-ui", "develop"]
1671                        .into_iter()
1672                        .collect::<HashSet<_>>()
1673                );
1674
1675                // Locals should be listed before remotes.
1676                let ordered = picker
1677                    .delegate
1678                    .matches
1679                    .iter()
1680                    .map(|be| be.name())
1681                    .collect::<Vec<_>>();
1682                assert_eq!(
1683                    ordered,
1684                    vec!["feature-ui", "develop", "origin/main", "fork/feature-auth"]
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_branch());
1690                assert!(!last_match.is_new_url());
1691            })
1692        });
1693
1694        branch_list.update(cx, |branch_list, cx| {
1695            branch_list.picker.update(cx, |picker, _cx| {
1696                picker.delegate.branch_filter = BranchFilter::Remote;
1697            })
1698        });
1699
1700        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1701
1702        branch_list
1703            .update_in(cx, |branch_list, window, cx| {
1704                branch_list.picker.update(cx, |picker, cx| {
1705                    assert_eq!(picker.delegate.matches.len(), 2);
1706                    let branches = picker
1707                        .delegate
1708                        .matches
1709                        .iter()
1710                        .map(|be| be.name())
1711                        .collect::<HashSet<_>>();
1712                    assert_eq!(
1713                        branches,
1714                        ["origin/main", "fork/feature-auth"]
1715                            .into_iter()
1716                            .collect::<HashSet<_>>()
1717                    );
1718
1719                    // Verify the last entry is NOT the "create new branch" option
1720                    let last_match = picker.delegate.matches.last().unwrap();
1721                    assert!(!last_match.is_new_url());
1722                    picker.delegate.branch_filter = BranchFilter::Remote;
1723                    picker
1724                        .delegate
1725                        .update_matches(String::from("fork"), window, cx)
1726                })
1727            })
1728            .await;
1729        cx.run_until_parked();
1730
1731        branch_list.update(cx, |branch_list, cx| {
1732            branch_list.picker.update(cx, |picker, _cx| {
1733                // Should have 1 existing branch + 1 "create new branch" entry = 2 total
1734                assert_eq!(picker.delegate.matches.len(), 2);
1735                assert!(
1736                    picker
1737                        .delegate
1738                        .matches
1739                        .iter()
1740                        .any(|m| m.name() == "fork/feature-auth")
1741                );
1742                // Verify the last entry is the "create new branch" option
1743                let last_match = picker.delegate.matches.last().unwrap();
1744                assert!(last_match.is_new_branch());
1745            })
1746        });
1747    }
1748
1749    #[gpui::test]
1750    async fn test_new_branch_creation_with_query(test_cx: &mut TestAppContext) {
1751        const MAIN_BRANCH: &str = "main";
1752        const FEATURE_BRANCH: &str = "feature";
1753        const NEW_BRANCH: &str = "new-feature-branch";
1754
1755        init_test(test_cx);
1756        let (_project, repository) = init_fake_repository(test_cx).await;
1757
1758        let branches = vec![
1759            create_test_branch(MAIN_BRANCH, true, None, Some(1000)),
1760            create_test_branch(FEATURE_BRANCH, false, None, Some(900)),
1761        ];
1762
1763        let (branch_list, mut ctx) =
1764            init_branch_list_test(repository.into(), branches, test_cx).await;
1765        let cx = &mut ctx;
1766
1767        branch_list
1768            .update_in(cx, |branch_list, window, cx| {
1769                branch_list.picker.update(cx, |picker, cx| {
1770                    picker
1771                        .delegate
1772                        .update_matches(NEW_BRANCH.to_string(), window, cx)
1773                })
1774            })
1775            .await;
1776
1777        cx.run_until_parked();
1778
1779        branch_list.update_in(cx, |branch_list, window, cx| {
1780            branch_list.picker.update(cx, |picker, cx| {
1781                let last_match = picker.delegate.matches.last().unwrap();
1782                assert!(last_match.is_new_branch());
1783                assert_eq!(last_match.name(), NEW_BRANCH);
1784                // State is NewBranch because no existing branches fuzzy-match the query
1785                assert!(matches!(picker.delegate.state, PickerState::NewBranch));
1786                picker.delegate.confirm(false, window, cx);
1787            })
1788        });
1789        cx.run_until_parked();
1790
1791        let branches = branch_list
1792            .update(cx, |branch_list, cx| {
1793                branch_list.picker.update(cx, |picker, cx| {
1794                    picker
1795                        .delegate
1796                        .repo
1797                        .as_ref()
1798                        .unwrap()
1799                        .update(cx, |repo, _cx| repo.branches())
1800                })
1801            })
1802            .await
1803            .unwrap()
1804            .unwrap();
1805
1806        let new_branch = branches
1807            .into_iter()
1808            .find(|branch| branch.name() == NEW_BRANCH)
1809            .expect("new-feature-branch should exist");
1810        assert_eq!(
1811            new_branch.ref_name.as_ref(),
1812            &format!("refs/heads/{NEW_BRANCH}"),
1813            "branch ref_name should not have duplicate refs/heads/ prefix"
1814        );
1815    }
1816
1817    #[gpui::test]
1818    async fn test_remote_url_detection_https(cx: &mut TestAppContext) {
1819        init_test(cx);
1820        let (_project, repository) = init_fake_repository(cx).await;
1821        let branches = vec![create_test_branch("main", true, None, Some(1000))];
1822
1823        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1824        let cx = &mut ctx;
1825
1826        branch_list
1827            .update_in(cx, |branch_list, window, cx| {
1828                branch_list.picker.update(cx, |picker, cx| {
1829                    let query = "https://github.com/user/repo.git".to_string();
1830                    picker.delegate.update_matches(query, window, cx)
1831                })
1832            })
1833            .await;
1834
1835        cx.run_until_parked();
1836
1837        branch_list
1838            .update_in(cx, |branch_list, window, cx| {
1839                branch_list.picker.update(cx, |picker, cx| {
1840                    let last_match = picker.delegate.matches.last().unwrap();
1841                    assert!(last_match.is_new_url());
1842                    assert!(matches!(picker.delegate.state, PickerState::NewRemote));
1843                    picker.delegate.confirm(false, window, cx);
1844                    assert_eq!(picker.delegate.matches.len(), 0);
1845                    if let PickerState::CreateRemote(remote_url) = &picker.delegate.state
1846                        && remote_url.as_ref() == "https://github.com/user/repo.git"
1847                    {
1848                    } else {
1849                        panic!("wrong picker state");
1850                    }
1851                    picker
1852                        .delegate
1853                        .update_matches("my_new_remote".to_string(), window, cx)
1854                })
1855            })
1856            .await;
1857
1858        cx.run_until_parked();
1859
1860        branch_list.update_in(cx, |branch_list, window, cx| {
1861            branch_list.picker.update(cx, |picker, cx| {
1862                assert_eq!(picker.delegate.matches.len(), 1);
1863                assert!(matches!(
1864                    picker.delegate.matches.first(),
1865                    Some(Entry::NewRemoteName { name, url })
1866                        if name == "my_new_remote" && url.as_ref() == "https://github.com/user/repo.git"
1867                ));
1868                picker.delegate.confirm(false, window, cx);
1869            })
1870        });
1871        cx.run_until_parked();
1872
1873        // List remotes
1874        let remotes = branch_list
1875            .update(cx, |branch_list, cx| {
1876                branch_list.picker.update(cx, |picker, cx| {
1877                    picker
1878                        .delegate
1879                        .repo
1880                        .as_ref()
1881                        .unwrap()
1882                        .update(cx, |repo, _cx| repo.get_remotes(None, false))
1883                })
1884            })
1885            .await
1886            .unwrap()
1887            .unwrap();
1888        assert_eq!(
1889            remotes,
1890            vec![Remote {
1891                name: SharedString::from("my_new_remote".to_string())
1892            }]
1893        );
1894    }
1895
1896    #[gpui::test]
1897    async fn test_confirm_remote_url_transitions(cx: &mut TestAppContext) {
1898        init_test(cx);
1899
1900        let branches = vec![create_test_branch("main_branch", true, None, Some(1000))];
1901        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1902        let cx = &mut ctx;
1903
1904        branch_list
1905            .update_in(cx, |branch_list, window, cx| {
1906                branch_list.picker.update(cx, |picker, cx| {
1907                    let query = "https://github.com/user/repo.git".to_string();
1908                    picker.delegate.update_matches(query, window, cx)
1909                })
1910            })
1911            .await;
1912        cx.run_until_parked();
1913
1914        // Try to create a new remote but cancel in the middle of the process
1915        branch_list
1916            .update_in(cx, |branch_list, window, cx| {
1917                branch_list.picker.update(cx, |picker, cx| {
1918                    picker.delegate.selected_index = picker.delegate.matches.len() - 1;
1919                    picker.delegate.confirm(false, window, cx);
1920
1921                    assert!(matches!(
1922                        picker.delegate.state,
1923                        PickerState::CreateRemote(_)
1924                    ));
1925                    if let PickerState::CreateRemote(ref url) = picker.delegate.state {
1926                        assert_eq!(url.as_ref(), "https://github.com/user/repo.git");
1927                    }
1928                    assert_eq!(picker.delegate.matches.len(), 0);
1929                    picker.delegate.dismissed(window, cx);
1930                    assert!(matches!(picker.delegate.state, PickerState::List));
1931                    let query = "main".to_string();
1932                    picker.delegate.update_matches(query, window, cx)
1933                })
1934            })
1935            .await;
1936        cx.run_until_parked();
1937
1938        // Try to search a branch again to see if the state is restored properly
1939        branch_list.update(cx, |branch_list, cx| {
1940            branch_list.picker.update(cx, |picker, _cx| {
1941                // Should have 1 existing branch + 1 "create new branch" entry = 2 total
1942                assert_eq!(picker.delegate.matches.len(), 2);
1943                assert!(
1944                    picker
1945                        .delegate
1946                        .matches
1947                        .iter()
1948                        .any(|m| m.name() == "main_branch")
1949                );
1950                // Verify the last entry is the "create new branch" option
1951                let last_match = picker.delegate.matches.last().unwrap();
1952                assert!(last_match.is_new_branch());
1953            })
1954        });
1955    }
1956
1957    #[gpui::test]
1958    async fn test_confirm_remote_url_does_not_dismiss(cx: &mut TestAppContext) {
1959        const REMOTE_URL: &str = "https://github.com/user/repo.git";
1960
1961        init_test(cx);
1962        let branches = vec![create_test_branch("main", true, None, Some(1000))];
1963
1964        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1965        let cx = &mut ctx;
1966
1967        let subscription = cx.update(|_, cx| {
1968            cx.subscribe(&branch_list, |_, _: &DismissEvent, _| {
1969                panic!("DismissEvent should not be emitted when confirming a remote URL");
1970            })
1971        });
1972
1973        branch_list
1974            .update_in(cx, |branch_list, window, cx| {
1975                window.focus(&branch_list.picker_focus_handle, cx);
1976                assert!(
1977                    branch_list.picker_focus_handle.is_focused(window),
1978                    "Branch picker should be focused when selecting an entry"
1979                );
1980
1981                branch_list.picker.update(cx, |picker, cx| {
1982                    picker
1983                        .delegate
1984                        .update_matches(REMOTE_URL.to_string(), window, cx)
1985                })
1986            })
1987            .await;
1988
1989        cx.run_until_parked();
1990
1991        branch_list.update_in(cx, |branch_list, window, cx| {
1992            // Re-focus the picker since workspace initialization during run_until_parked
1993            window.focus(&branch_list.picker_focus_handle, cx);
1994
1995            branch_list.picker.update(cx, |picker, cx| {
1996                let last_match = picker.delegate.matches.last().unwrap();
1997                assert!(last_match.is_new_url());
1998                assert!(matches!(picker.delegate.state, PickerState::NewRemote));
1999
2000                picker.delegate.confirm(false, window, cx);
2001
2002                assert!(
2003                    matches!(picker.delegate.state, PickerState::CreateRemote(ref url) if url.as_ref() == REMOTE_URL),
2004                    "State should transition to CreateRemote with the URL"
2005                );
2006            });
2007
2008            assert!(
2009                branch_list.picker_focus_handle.is_focused(window),
2010                "Branch list picker should still be focused after confirming remote URL"
2011            );
2012        });
2013
2014        cx.run_until_parked();
2015
2016        drop(subscription);
2017    }
2018
2019    #[gpui::test(iterations = 10)]
2020    async fn test_empty_query_displays_all_branches(mut rng: StdRng, cx: &mut TestAppContext) {
2021        init_test(cx);
2022        let branch_count = rng.random_range(13..540);
2023
2024        let branches: Vec<Branch> = (0..branch_count)
2025            .map(|i| create_test_branch(&format!("branch-{:02}", i), i == 0, None, Some(i * 100)))
2026            .collect();
2027
2028        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
2029        let cx = &mut ctx;
2030
2031        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
2032
2033        branch_list.update(cx, |branch_list, cx| {
2034            branch_list.picker.update(cx, |picker, _cx| {
2035                assert_eq!(picker.delegate.matches.len(), branch_count as usize);
2036            })
2037        });
2038    }
2039}