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