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