use anyhow::Context as _; use editor::Editor; use fuzzy_nucleo::StringMatchCandidate; use collections::{HashMap, HashSet}; use git::repository::{Branch, delete_branch_flag}; use git::{GitHostingProviderRegistry, parse_git_remote_url}; use gpui::http_client::Url; use gpui::{ Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Global, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, PromptLevel, Render, SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems, }; use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::git_store::{Repository, RepositoryEvent}; use project::project_settings::ProjectSettings; use settings::Settings; use std::sync::Arc; use time::OffsetDateTime; use ui::{ Banner, ContextMenu, Divider, HighlightedLabel, Indicator, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, PopoverMenu, PopoverMenuHandle, Severity, Tooltip, prelude::*, }; use ui_input::ErasedEditor; use util::ResultExt; use workspace::notifications::DetachAndPromptErr; use workspace::{ModalView, Workspace}; use crate::{branch_picker, git_panel::show_error_toast}; actions!( branch_picker, [ /// Deletes the selected git branch or remote. DeleteBranch, /// Force deletes the selected git branch or remote. ForceDeleteBranch, /// Show all branches. ShowAllBranches, /// Show only local branches. ShowLocalBranches, /// Show only remote branches. ShowRemoteBranches, /// Cycle through branch filters. CycleBranchFilter, /// Toggles the branch filter menu. ToggleFilterMenu ] ); pub fn checkout_branch( workspace: &mut Workspace, _: &zed_actions::git::CheckoutBranch, window: &mut Window, cx: &mut Context, ) { open(workspace, &zed_actions::git::Branch, window, cx); } pub fn switch( workspace: &mut Workspace, _: &zed_actions::git::Switch, window: &mut Window, cx: &mut Context, ) { open(workspace, &zed_actions::git::Branch, window, cx); } pub fn open( workspace: &mut Workspace, _: &zed_actions::git::Branch, window: &mut Window, cx: &mut Context, ) { let workspace_handle = workspace.weak_handle(); let repository = workspace.project().read(cx).active_repository(cx); workspace.toggle_modal(window, cx, |window, cx| { BranchList::new( workspace_handle, repository, BranchListStyle::Modal, rems(34.), window, cx, ) }) } pub fn popover( workspace: WeakEntity, modal_style: bool, repository: Option>, window: &mut Window, cx: &mut App, ) -> Entity { let (style, width) = if modal_style { (BranchListStyle::Modal, rems(34.)) } else { (BranchListStyle::Popover, rems(20.)) }; cx.new(|cx| { let list = BranchList::new(workspace, repository, style, width, window, cx); list.focus_handle(cx).focus(window, cx); list }) } pub fn select_popover( workspace: WeakEntity, repository: Option>, selected_branch: Option, on_select: SelectBranchCallback, window: &mut Window, cx: &mut App, ) -> Entity { cx.new(|cx| { let list = BranchList::new_select( workspace, repository, BranchListStyle::Popover, rems(20.), selected_branch, on_select, window, cx, ); list.focus_handle(cx).focus(window, cx); list }) } pub fn select_modal( workspace: WeakEntity, repository: Option>, selected_branch: Option, on_select: SelectBranchCallback, window: &mut Window, cx: &mut Context, ) -> BranchList { let list = BranchList::new_select( workspace, repository, BranchListStyle::Modal, rems(34.), selected_branch, on_select, window, cx, ); list.focus_handle(cx).focus(window, cx); list } pub type SelectBranchCallback = Arc; pub fn create_embedded( workspace: WeakEntity, repository: Option>, width: Rems, show_footer: bool, window: &mut Window, cx: &mut Context, ) -> BranchList { BranchList::new_embedded(workspace, repository, width, show_footer, window, cx) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum BranchListStyle { Modal, Popover, } pub struct BranchList { pub picker: Entity>, picker_focus_handle: FocusHandle, _subscriptions: Vec, embedded: bool, } impl BranchList { fn new( workspace: WeakEntity, repository: Option>, style: BranchListStyle, width: Rems, window: &mut Window, cx: &mut Context, ) -> Self { let mut this = Self::new_inner(workspace, repository, style, width, false, window, cx); this._subscriptions .push(cx.subscribe(&this.picker, |this, _, _, cx| { if !this.branch_filter_menu_open(cx) { cx.emit(DismissEvent); } })); this } fn new_inner( workspace: WeakEntity, repository: Option>, style: BranchListStyle, width: Rems, embedded: bool, window: &mut Window, cx: &mut Context, ) -> Self { Self::new_inner_with_behavior( workspace, repository, style, width, embedded, BranchSelectionBehavior::Checkout, window, cx, ) } fn new_select( workspace: WeakEntity, repository: Option>, style: BranchListStyle, width: Rems, selected_branch: Option, on_select: SelectBranchCallback, window: &mut Window, cx: &mut Context, ) -> Self { let mut this = Self::new_inner_with_behavior( workspace, repository, style, width, false, BranchSelectionBehavior::Select { selected_branch, on_select, }, window, cx, ); this._subscriptions .push(cx.subscribe(&this.picker, |this, _, _, cx| { if !this.branch_filter_menu_open(cx) { cx.emit(DismissEvent); } })); this } fn new_inner_with_behavior( workspace: WeakEntity, repository: Option>, style: BranchListStyle, width: Rems, embedded: bool, branch_selection_behavior: BranchSelectionBehavior, window: &mut Window, cx: &mut Context, ) -> Self { let all_branches = repository .as_ref() .map(|repo| { process_branches( &repo.read(cx).branch_list, !branch_selection_behavior.is_select_only(), ) }) .unwrap_or_default(); let branch_list_error = repository .as_ref() .and_then(|repo| repo.read(cx).branch_list_error.clone()); let default_branch_request = repository.clone().map(|repository| { repository.update(cx, |repository, _| repository.default_branch(false)) }); let remote_urls_request = repository .clone() .map(|repository| repository.update(cx, |repository, _| repository.remote_urls())); let mut delegate = BranchListDelegate::new( workspace, repository.clone(), style, branch_selection_behavior, cx, ); delegate.all_branches = all_branches; delegate.branch_list_error = branch_list_error; let picker = cx.new(|cx| { Picker::list(delegate, window, cx) .initial_width(width) .show_scrollbar(true) .when(embedded, |picker| picker.embedded()) }); let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, _| { picker.delegate.focus_handle = picker_focus_handle.clone(); picker.delegate.show_footer = !embedded && !picker.delegate.is_select_only(); }); let mut subscriptions = Vec::new(); if let Some(repo) = &repository { subscriptions.push(cx.subscribe_in( repo, window, move |this, repo, event, window, cx| { if matches!(event, RepositoryEvent::BranchListChanged) { let snapshot = repo.read(cx); let branch_list = snapshot.branch_list.clone(); let branch_list_error = snapshot.branch_list_error.clone(); this.picker.update(cx, |picker, cx| { picker.delegate.restore_selected_branch = picker .delegate .matches .get(picker.delegate.selected_index) .and_then(|entry| entry.as_branch().map(|b| b.ref_name.clone())); picker.delegate.all_branches = process_branches( &branch_list, !picker.delegate.branch_selection_behavior.is_select_only(), ); picker.delegate.branch_list_error = branch_list_error; picker.refresh(window, cx); }); } }, )); } // Fetch default branch asynchronously since it requires a git operation cx.spawn_in(window, async move |this, cx| { let default_branch = default_branch_request .context("No active repository")? .await .map(Result::ok) .ok() .flatten() .flatten(); let _ = this.update_in(cx, |this, _window, cx| { this.picker.update(cx, |picker, _cx| { picker.delegate.default_branch = default_branch; }); }); anyhow::Ok(()) }) .detach_and_log_err(cx); cx.spawn(async move |this, cx| { let remote_urls = remote_urls_request .context("No active repository")? .await??; let remote_provider_icons = cx.update(|cx| remote_provider_icons(&remote_urls, cx)); this.update(cx, |this, cx| { this.picker.update(cx, |picker, cx| { picker.delegate.remote_provider_icons = remote_provider_icons; cx.notify(); }); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); Self { picker, picker_focus_handle, _subscriptions: subscriptions, embedded, } } fn new_embedded( workspace: WeakEntity, repository: Option>, width: Rems, show_footer: bool, window: &mut Window, cx: &mut Context, ) -> Self { let mut this = Self::new_inner( workspace, repository, BranchListStyle::Modal, width, true, window, cx, ); this.picker.update(cx, |picker, _| { picker.delegate.show_footer = show_footer; }); this._subscriptions .push(cx.subscribe(&this.picker, |_, _, _, cx| { cx.emit(DismissEvent); })); this } pub fn handle_modifiers_changed( &mut self, ev: &ModifiersChangedEvent, _: &mut Window, cx: &mut Context, ) { self.picker.update(cx, |picker, cx| { picker.delegate.modifiers = ev.modifiers; cx.notify(); }) } pub fn handle_delete( &mut self, _: &branch_picker::DeleteBranch, window: &mut Window, cx: &mut Context, ) { self.picker.update(cx, |picker, cx| { if picker.delegate.is_select_only() { return; } picker .delegate .delete_at(picker.delegate.selected_index, false, window, cx) }) } pub fn handle_force_delete( &mut self, _: &branch_picker::ForceDeleteBranch, window: &mut Window, cx: &mut Context, ) { self.picker.update(cx, |picker, cx| { if picker.delegate.is_select_only() { return; } picker .delegate .delete_at(picker.delegate.selected_index, true, window, cx) }) } pub(crate) fn set_branch_filter( &mut self, branch_filter: BranchFilter, window: &mut Window, cx: &mut Context, ) { cx.set_global(GlobalBranchFilter(branch_filter)); self.picker.update(cx, |picker, cx| { if picker.delegate.branch_filter == branch_filter { return; } picker.delegate.branch_filter = branch_filter; picker.update_matches(picker.query(cx), window, cx); picker.refresh_placeholder(window, cx); cx.notify(); }); } pub(crate) fn branch_filter_menu_open(&self, cx: &App) -> bool { self.picker .read(cx) .delegate .branch_filter_menu_handle .is_deployed() } pub(crate) fn cycle_branch_filter(&mut self, window: &mut Window, cx: &mut Context) { let branch_filter = self.picker.read(cx).delegate.branch_filter.next(); self.set_branch_filter(branch_filter, window, cx); } } impl ModalView for BranchList {} impl EventEmitter for BranchList {} impl Focusable for BranchList { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.picker_focus_handle.clone() } } impl Render for BranchList { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .key_context("GitBranchSelector") .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .on_action(cx.listener(Self::handle_delete)) .on_action(cx.listener(Self::handle_force_delete)) .on_action( cx.listener(|this, _: &branch_picker::ShowAllBranches, window, cx| { this.set_branch_filter(BranchFilter::All, window, cx); }), ) .on_action( cx.listener(|this, _: &branch_picker::ShowLocalBranches, window, cx| { this.set_branch_filter(BranchFilter::Local, window, cx); }), ) .on_action( cx.listener(|this, _: &branch_picker::ShowRemoteBranches, window, cx| { this.set_branch_filter(BranchFilter::Remote, window, cx); }), ) .on_action( cx.listener(|this, _: &branch_picker::CycleBranchFilter, window, cx| { this.cycle_branch_filter(window, cx); }), ) .on_action( cx.listener(|this, _: &branch_picker::ToggleFilterMenu, window, cx| { let menu_handle = this .picker .read(cx) .delegate .branch_filter_menu_handle .clone(); menu_handle.toggle(window, cx); }), ) .child(self.picker.clone()) .when(!self.embedded, |this| { this.on_mouse_down_out({ cx.listener(move |this, _, window, cx| { // The filter menu is a deferred popover, so clicks within it are outside // the branch picker's bounds even though it is part of this interaction. if this.branch_filter_menu_open(cx) { return; } this.picker.update(cx, |this, cx| { this.cancel(&Default::default(), window, cx); }) }) }) }) } } #[derive(Debug, Clone, PartialEq)] enum Entry { Branch { branch: Branch, positions: Vec, }, NewUrl { url: String, }, NewBranch { name: String, }, NewRemoteName { name: String, url: SharedString, }, } impl Entry { fn as_branch(&self) -> Option<&Branch> { match self { Entry::Branch { branch, .. } => Some(branch), _ => None, } } fn name(&self) -> &str { match self { Entry::Branch { branch, .. } => branch.name(), Entry::NewUrl { url, .. } => url.as_str(), Entry::NewBranch { name, .. } => name.as_str(), Entry::NewRemoteName { name, .. } => name.as_str(), } } #[cfg(test)] fn is_new_url(&self) -> bool { matches!(self, Self::NewUrl { .. }) } #[cfg(test)] fn is_new_branch(&self) -> bool { matches!(self, Self::NewBranch { .. }) } } #[derive(Clone, Copy, PartialEq)] pub(crate) enum BranchFilter { /// Show both local and remote branches. All, /// Only show local branches. Local, /// Only show remote branches. Remote, } impl BranchFilter { fn next(self) -> Self { match self { Self::All => Self::Local, Self::Local => Self::Remote, Self::Remote => Self::All, } } fn label(self) -> &'static str { match self { Self::All => "All Branches", Self::Local => "Local Branches", Self::Remote => "Remote Branches", } } } struct GlobalBranchFilter(BranchFilter); impl Global for GlobalBranchFilter {} fn branch_filter_menu( branch_filter: BranchFilter, focus_handle: FocusHandle, window: &mut Window, cx: &mut App, ) -> Entity { ContextMenu::build(window, cx, |mut menu, _, _| { menu = menu.context(focus_handle.clone()); let filter_actions: [(BranchFilter, Box); 3] = [ (BranchFilter::All, ShowAllBranches.boxed_clone()), (BranchFilter::Local, ShowLocalBranches.boxed_clone()), (BranchFilter::Remote, ShowRemoteBranches.boxed_clone()), ]; for (filter, action) in filter_actions { let handler_focus = focus_handle.clone(); let dispatched = action.boxed_clone(); menu = menu.toggleable_entry( filter.label(), filter == branch_filter, IconPosition::End, Some(action), move |window, cx| { window.focus(&handler_focus, cx); window.dispatch_action(dispatched.boxed_clone(), cx); }, ); } menu }) } pub struct BranchListDelegate { workspace: WeakEntity, matches: Vec, all_branches: Vec, branch_list_error: Option, default_branch: Option, repo: Option>, style: BranchListStyle, selected_index: usize, last_query: String, modifiers: Modifiers, branch_filter: BranchFilter, branch_filter_menu_handle: PopoverMenuHandle, state: PickerState, branch_selection_behavior: BranchSelectionBehavior, focus_handle: FocusHandle, restore_selected_branch: Option, show_footer: bool, hovered_delete_index: Option, remote_provider_icons: HashMap, } enum BranchSelectionBehavior { Checkout, Select { selected_branch: Option, on_select: SelectBranchCallback, }, } impl BranchSelectionBehavior { fn selected_branch(&self) -> Option<&SharedString> { match self { Self::Checkout => None, Self::Select { selected_branch, .. } => selected_branch.as_ref(), } } fn is_select_only(&self) -> bool { matches!(self, Self::Select { .. }) } } #[derive(Clone)] struct BranchSelectionContext { selected_branch: Option, active_branch_ref_name: Option, active_branch_upstream_ref_name: Option, active_branch_remote_name: Option, } impl BranchSelectionContext { fn new( selected_branch: Option, repo: Option<&Entity>, cx: &App, ) -> Self { let active_branch = repo.and_then(|repo| repo.read(cx).branch.clone()); let active_branch_ref_name = active_branch.as_ref().map(|branch| branch.ref_name.clone()); let active_branch_upstream_ref_name = active_branch.as_ref().and_then(|branch| { branch .upstream .as_ref() .map(|upstream| upstream.ref_name.clone()) }); let active_branch_remote_name = active_branch.as_ref().and_then(|branch| { branch .upstream .as_ref() .and_then(|upstream| upstream.remote_name()) .or_else(|| branch.remote_name()) .map(SharedString::from) }); Self { selected_branch, active_branch_ref_name, active_branch_upstream_ref_name, active_branch_remote_name, } } fn priority(&self, branch: &Branch) -> usize { if self .selected_branch .as_ref() .is_some_and(|selected_branch| branch_matches_ref(branch, selected_branch)) { 0 } else if self.is_on_active_branch_remote(branch) { 1 } else if self.is_active_branch(branch) || self.is_active_upstream(branch) { 3 } else { 2 } } fn is_active_branch(&self, branch: &Branch) -> bool { self.active_branch_ref_name .as_ref() .is_some_and(|ref_name| branch.ref_name.as_ref() == ref_name.as_ref()) } fn is_active_upstream(&self, branch: &Branch) -> bool { self.active_branch_upstream_ref_name .as_ref() .is_some_and(|ref_name| branch.ref_name.as_ref() == ref_name.as_ref()) } fn is_on_active_branch_remote(&self, branch: &Branch) -> bool { if self.is_active_branch(branch) || self.is_active_upstream(branch) { return false; } let Some(active_branch_remote_name) = &self.active_branch_remote_name else { return false; }; branch_remote_name(branch) .is_some_and(|remote_name| remote_name == active_branch_remote_name.as_ref()) } } #[derive(Debug)] enum PickerState { /// When we display list of branches/remotes List, /// When we set an url to create a new remote NewRemote, /// When we confirm the new remote url (after NewRemote) CreateRemote(SharedString), /// When we set a new branch to create NewBranch, } fn delete_branch_command(is_remote: bool, branch_name: &str, force: bool) -> String { format!( "branch {} {branch_name}", delete_branch_flag(is_remote, force) ) } struct BranchDeleteForceDeletePrompt { required_error_substrings: &'static [&'static str], message: fn(&str) -> String, } impl BranchDeleteForceDeletePrompt { fn matches(&self, normalized_error_message: &str) -> bool { self.required_error_substrings .iter() .all(|substring| normalized_error_message.contains(substring)) } } const BRANCH_DELETE_FORCE_DELETE_PROMPTS: &[BranchDeleteForceDeletePrompt] = &[BranchDeleteForceDeletePrompt { required_error_substrings: &["not fully merged"], message: unmerged_branch_force_delete_prompt, }]; fn unmerged_branch_force_delete_prompt(branch_name: &str) -> String { format!("Branch \"{branch_name}\" is not fully merged. Force delete it?") } // Git only reports these cases via localized stderr, so this best-effort check // may miss some locales and fall back to the raw error toast. fn force_delete_prompt_for_branch_delete_error( error: &anyhow::Error, branch_name: &str, ) -> Option { let normalized_error_message = error.to_string().to_lowercase(); BRANCH_DELETE_FORCE_DELETE_PROMPTS .iter() .find(|prompt| prompt.matches(&normalized_error_message)) .map(|prompt| (prompt.message)(branch_name)) } struct DeleteBranchTooltip { picker: WeakEntity>, focus_handle: FocusHandle, delete_index: usize, _subscription: Subscription, } impl DeleteBranchTooltip { fn new( picker: Entity>, focus_handle: FocusHandle, delete_index: usize, cx: &mut Context, ) -> Self { let subscription = cx.observe(&picker, |_, _, cx| cx.notify()); Self { picker: picker.downgrade(), focus_handle, delete_index, _subscription: subscription, } } } impl Render for DeleteBranchTooltip { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let force_delete = self .picker .read_with(cx, |picker, _| { picker .delegate .is_force_delete_hovering_index(self.delete_index) }) .unwrap_or(false); if force_delete { Tooltip::for_action_in( "Force Delete Branch", &branch_picker::ForceDeleteBranch, &self.focus_handle, cx, ) .into_any_element() } else { Tooltip::with_meta_in( "Delete Branch", Some(&branch_picker::DeleteBranch), "Hold alt to force delete", &self.focus_handle, cx, ) .into_any_element() } } } fn branch_matches_ref(branch: &Branch, branch_ref: &SharedString) -> bool { branch.ref_name.as_ref() == branch_ref.as_ref() || branch.name() == branch_ref.as_ref() } // Git branch names can't contain whitespace, so we replace spaces with dashes, // but we need to first trim because a branch name can't start or end with a // dash. fn normalize_branch_name(query: &str) -> String { query.trim().replace(' ', "-") } fn branch_remote_name(branch: &Branch) -> Option<&str> { branch.remote_name().or_else(|| { branch .upstream .as_ref() .and_then(|upstream| upstream.remote_name()) }) } fn sort_branch_entries( matches: &mut [Entry], branch_selection_context: Option<&BranchSelectionContext>, ) { let selected_branch_is_remote = branch_selection_context .and_then(|context| context.selected_branch.as_ref()) .and_then(|selected_branch| { matches.iter().find_map(|entry| { let branch = entry.as_branch()?; branch_matches_ref(branch, selected_branch).then(|| branch.is_remote()) }) }) .unwrap_or(false); matches.sort_by_key(|entry| { let Some(branch) = entry.as_branch() else { return (true, 4); }; let priority = branch_selection_context .map(|context| context.priority(branch)) .unwrap_or(0); (branch.is_remote() != selected_branch_is_remote, priority) }); } // Tracked remote branches are: // - collapsed when checking out to avoid detaching HEAD. // - kept when selecting a diff base because they may point to a different commit. fn process_branches(branches: &Arc<[Branch]>, collapse_tracked_remotes: bool) -> Vec { let mut result: Vec = if collapse_tracked_remotes { let remote_upstreams: HashSet<_> = branches .iter() .filter_map(|branch| { branch .upstream .as_ref() .filter(|upstream| upstream.is_remote()) .map(|upstream| upstream.ref_name.clone()) }) .collect(); branches .iter() .filter(|branch| !remote_upstreams.contains(&branch.ref_name)) .cloned() .collect() } else { branches.to_vec() }; result.sort_by_key(|branch| { ( !branch.is_head, branch .most_recent_commit .as_ref() .map(|commit| 0 - commit.commit_timestamp), ) }); result } impl BranchListDelegate { fn new( workspace: WeakEntity, repo: Option>, style: BranchListStyle, branch_selection_behavior: BranchSelectionBehavior, cx: &mut Context, ) -> Self { let restore_selected_branch = match &branch_selection_behavior { BranchSelectionBehavior::Checkout => None, BranchSelectionBehavior::Select { selected_branch, .. } => selected_branch.clone(), }; let branch_filter = cx .try_global::() .map_or(BranchFilter::All, |filter| filter.0); Self { workspace, matches: vec![], repo, style, all_branches: Vec::new(), branch_list_error: None, default_branch: None, selected_index: 0, last_query: Default::default(), modifiers: Default::default(), branch_filter, branch_filter_menu_handle: PopoverMenuHandle::default(), state: PickerState::List, branch_selection_behavior, focus_handle: cx.focus_handle(), restore_selected_branch, show_footer: false, hovered_delete_index: None, remote_provider_icons: HashMap::default(), } } fn is_select_only(&self) -> bool { self.branch_selection_behavior.is_select_only() } fn branch_filter_trigger(&self) -> IconButton { IconButton::new("branch-filter", IconName::Filter) .icon_size(IconSize::Small) .toggle_state(self.branch_filter != BranchFilter::All) .when(self.branch_filter != BranchFilter::All, |this| { this.indicator(Indicator::dot().color(Color::Info)) }) } fn branch_filter_tooltip(&self) -> impl Fn(&mut Window, &mut App) -> gpui::AnyView + 'static { let focus_handle = self.focus_handle.clone(); move |_, cx| { Tooltip::for_action_in( "Filter Branches", &branch_picker::ToggleFilterMenu, &focus_handle, cx, ) } } fn is_force_delete_hovering_index(&self, index: usize) -> bool { self.modifiers.alt && self.hovered_delete_index == Some(index) } fn create_branch( &self, from_branch: Option, new_branch_name: SharedString, window: &mut Window, cx: &mut Context>, ) { let Some(repo) = self.repo.clone() else { return; }; let new_branch_name = normalize_branch_name(&new_branch_name); let base_branch = from_branch.map(|b| b.to_string()); cx.spawn(async move |_, cx| { repo.update(cx, |repo, _| { repo.create_branch(new_branch_name, base_branch) }) .await??; Ok(()) }) .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| { Some(e.to_string()) }); cx.emit(DismissEvent); } fn create_remote( &self, remote_name: String, remote_url: String, window: &mut Window, cx: &mut Context>, ) { let Some(repo) = self.repo.clone() else { return; }; let receiver = repo.update(cx, |repo, _| repo.create_remote(remote_name, remote_url)); cx.background_spawn(async move { receiver.await? }) .detach_and_prompt_err("Failed to create remote", window, cx, |e, _, _cx| { Some(e.to_string()) }); cx.emit(DismissEvent); } fn delete_at( &self, idx: usize, force: bool, window: &mut Window, cx: &mut Context>, ) { let Some(entry) = self.matches.get(idx).cloned() else { return; }; let Some(repo) = self.repo.clone() else { return; }; let workspace = self.workspace.clone(); cx.spawn_in(window, async move |picker, cx| { let Entry::Branch { branch, .. } = &entry else { log::error!("Failed to delete entry: wrong entry to delete"); return Ok(()); }; if branch.is_head { return Ok(()); } let is_remote = branch.is_remote(); let branch_name = branch.name().to_string(); let initial_result = repo .update(cx, |repo, _| { repo.delete_branch(is_remote, branch_name.clone(), force) }) .await?; let (result, attempted_force) = match initial_result { Ok(()) => (Ok(()), force), Err(error) => { if is_remote { log::error!("Failed to delete remote branch: {error}"); } else { log::error!("Failed to delete branch: {error}"); } let force_delete_prompt = (!force) .then(|| force_delete_prompt_for_branch_delete_error(&error, entry.name())) .flatten(); if let Some(prompt_message) = force_delete_prompt { let answer = cx.update(|window, cx| { window.prompt( PromptLevel::Warning, &prompt_message, None, &["Force Delete", "Cancel"], cx, ) })?; if answer.await != Ok(0) { return Ok(()); } let retry = repo .update(cx, |repo, _| { repo.delete_branch(is_remote, branch_name, true) }) .await?; if let Err(error) = &retry { log::error!("Failed to force delete branch: {error}"); } (retry, true) } else { (Err(error), force) } } }; if let Err(error) = result { if let Some(workspace) = workspace.upgrade() { cx.update(|_window, cx| { show_error_toast( workspace, delete_branch_command(is_remote, entry.name(), attempted_force), error, cx, ) })?; } return Ok(()); } picker.update_in(cx, |picker, _, cx| { picker.delegate.matches.retain(|e| e != &entry); if let Entry::Branch { branch, .. } = &entry { picker .delegate .all_branches .retain(|e| e.ref_name != branch.ref_name); } if picker.delegate.matches.is_empty() { picker.delegate.selected_index = 0; } else if picker.delegate.selected_index >= picker.delegate.matches.len() { picker.delegate.selected_index = picker.delegate.matches.len() - 1; } picker.delegate.hovered_delete_index = None; cx.notify(); })?; anyhow::Ok(()) }) .detach(); } } fn remote_provider_icons( remote_urls: &HashMap, cx: &App, ) -> HashMap { let Some(provider_registry) = GitHostingProviderRegistry::try_global(cx) else { return HashMap::default(); }; remote_urls .iter() .filter_map(|(remote_name, remote_url)| { let (provider, _) = parse_git_remote_url(provider_registry.clone(), remote_url)?; Some(( remote_name.clone(), crate::get_provider_icon(&provider.name()), )) }) .collect() } impl PickerDelegate for BranchListDelegate { type ListItem = AnyElement; fn name() -> &'static str { "branch picker" } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { match self.state { PickerState::List | PickerState::NewRemote | PickerState::NewBranch => { "Switch or type to create a branch…" } PickerState::CreateRemote(_) => "Enter a name for this remote…", } .into() } fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { match self.state { PickerState::CreateRemote(_) => { Some(SharedString::new_static("Remote name can't be empty")) } _ => None, } } fn render_editor( &self, editor: &Arc, _window: &mut Window, _cx: &mut Context>, ) -> Option
{ let editor = editor.as_any().downcast_ref::>().unwrap(); let editor_start = matches!(self.editor_position(), PickerEditorPosition::Start); let editor_bottom = matches!(self.editor_position(), PickerEditorPosition::End); let warning_banner = || { self.branch_list_error.as_deref().map(|error| { let message = format!("Some branches could not be loaded: {error}"); div().p_1p5().child( Banner::new() .severity(Severity::Warning) .child(div().min_w_0().flex_1().child(Label::new(message))), ) }) }; Some( v_flex() .w_full() .min_w_0() .when(editor_bottom, |this| { this.child(Divider::horizontal()) .when_some(warning_banner(), |this, banner| this.child(banner)) }) .child( h_flex() .h_9() .px_2p5() .flex_none() .overflow_hidden() .child(editor.clone()) .map(|this| { let branch_filter = self.branch_filter; let focus_handle = self.focus_handle.clone(); this.gap_1().justify_between().child( PopoverMenu::new("branch-filter-menu") .with_handle(self.branch_filter_menu_handle.clone()) .trigger_with_tooltip( self.branch_filter_trigger(), self.branch_filter_tooltip(), ) .menu(move |window, cx| { Some(branch_filter_menu( branch_filter, focus_handle.clone(), window, cx, )) }) .map(|this| { if editor_bottom { this.anchor(gpui::Anchor::BottomRight) .attach(gpui::Anchor::TopRight) .offset(gpui::Point { x: px(0.0), y: px(-1.0), }) } else { this.anchor(gpui::Anchor::TopRight) .attach(gpui::Anchor::BottomRight) .offset(gpui::Point { x: px(1.0), y: px(1.0), }) } }), ) }), ) .when(editor_start, |this| { this.child(Divider::horizontal()) .when_some(warning_banner(), |this, banner| this.child(banner)) }), ) } fn editor_position(&self) -> PickerEditorPosition { if self.is_select_only() { return PickerEditorPosition::Start; } match self.style { BranchListStyle::Modal => PickerEditorPosition::Start, BranchListStyle::Popover => PickerEditorPosition::End, } } fn render_header( &self, _window: &mut Window, _cx: &mut Context>, ) -> Option { if self.branch_filter == BranchFilter::All || !self .matches .first() .is_some_and(|entry| entry.as_branch().is_some()) { return None; } Some( div() .pt_1p5() .mb_neg_0p5() .child(ListSubHeader::new(self.branch_filter.label()).inset(true)) .into_any_element(), ) } fn has_another_open_menu(&self, window: &Window, cx: &App) -> bool { self.branch_filter_menu_handle.is_deployed() || self.branch_filter_menu_handle.is_focused(window, cx) } fn match_count(&self) -> usize { self.matches.len() } fn selected_index(&self) -> usize { self.selected_index } fn set_selected_index( &mut self, ix: usize, _window: &mut Window, _: &mut Context>, ) { self.selected_index = ix; } fn update_matches( &mut self, query: String, window: &mut Window, cx: &mut Context>, ) -> Task<()> { let all_branches = self.all_branches.clone(); let branch_selection_context = self.is_select_only().then(|| { BranchSelectionContext::new( self.branch_selection_behavior.selected_branch().cloned(), self.repo.as_ref(), cx, ) }); let branch_filter = self.branch_filter; cx.spawn_in(window, async move |picker, cx| { let branch_matches_filter = |branch: &Branch| match branch_filter { BranchFilter::All => true, BranchFilter::Local => !branch.is_remote(), BranchFilter::Remote => branch.is_remote(), }; let mut matches: Vec = if query.is_empty() { let mut matches: Vec = all_branches .iter() .filter(|branch| branch_matches_filter(branch)) .map(|branch| Entry::Branch { branch: branch.clone(), positions: Vec::new(), }) .collect(); sort_branch_entries(&mut matches, branch_selection_context.as_ref()); matches } else { let branches = all_branches .iter() .filter(|branch| branch_matches_filter(branch)) .collect::>(); let candidates = branches .iter() .enumerate() .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name())) .collect::>(); let mut matches: Vec = fuzzy_nucleo::match_strings_async( &candidates, &query, fuzzy_nucleo::Case::Smart, fuzzy_nucleo::LengthPenalty::On, 10000, &Default::default(), cx.background_executor().clone(), ) .await .into_iter() .map(|candidate| Entry::Branch { branch: branches[candidate.candidate_id].clone(), positions: candidate.positions, }) .collect(); sort_branch_entries(&mut matches, branch_selection_context.as_ref()); matches }; picker .update(cx, |picker, _| { if let PickerState::CreateRemote(url) = &picker.delegate.state { let query = normalize_branch_name(&query); if !query.is_empty() { picker.delegate.matches = vec![Entry::NewRemoteName { name: query.clone(), url: url.clone(), }]; picker.delegate.selected_index = 0; } else { picker.delegate.matches = Vec::new(); picker.delegate.selected_index = 0; } picker.delegate.last_query = query; return; } if !picker.delegate.is_select_only() && !query.is_empty() && !all_branches.iter().any(|branch| branch.name() == query) { let query = normalize_branch_name(&query); let is_url = query.trim_start_matches("git@").parse::().is_ok(); let entry = if is_url { Entry::NewUrl { url: query } } else { Entry::NewBranch { name: query } }; // Only transition to NewBranch/NewRemote states when we only show their list item // Otherwise, stay in List state so footer buttons remain visible picker.delegate.state = if matches.is_empty() { if is_url { PickerState::NewRemote } else { PickerState::NewBranch } } else { PickerState::List }; matches.push(entry); } else { picker.delegate.state = PickerState::List; } let delegate = &mut picker.delegate; delegate.matches = matches; if delegate.matches.is_empty() { delegate.selected_index = 0; } else if let Some(ref_name) = delegate.restore_selected_branch.take() { delegate.selected_index = delegate .matches .iter() .position(|entry| { entry.as_branch().is_some_and(|branch| { branch.ref_name == ref_name || branch.name() == ref_name.as_ref() }) }) .unwrap_or(0); } else { delegate.selected_index = core::cmp::min(delegate.selected_index, delegate.matches.len() - 1); } delegate.last_query = query; }) .log_err(); }) } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { let Some(entry) = self.matches.get(self.selected_index()) else { return; }; match entry { Entry::Branch { branch, .. } => { if let BranchSelectionBehavior::Select { on_select, .. } = &self.branch_selection_behavior { on_select(branch.clone(), window, cx); cx.emit(DismissEvent); return; } let current_branch = self.repo.as_ref().map(|repo| { repo.read_with(cx, |repo, _| { repo.branch.as_ref().map(|branch| branch.ref_name.clone()) }) }); if current_branch .flatten() .is_some_and(|current_branch| current_branch == branch.ref_name) { cx.emit(DismissEvent); return; } let Some(repo) = self.repo.clone() else { return; }; let branch = branch.clone(); cx.spawn(async move |_, cx| { repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string())) .await??; anyhow::Ok(()) }) .detach_and_prompt_err( "Failed to change branch", window, cx, |_, _, _| None, ); } Entry::NewUrl { url } => { self.state = PickerState::CreateRemote(url.clone().into()); self.matches = Vec::new(); self.selected_index = 0; cx.defer_in(window, |picker, window, cx| { picker.refresh_placeholder(window, cx); picker.set_query("", window, cx); cx.notify(); }); // returning early to prevent dismissing the modal, so a user can enter // a remote name first. return; } Entry::NewRemoteName { name, url } => { self.create_remote(name.clone(), url.to_string(), window, cx); } Entry::NewBranch { name } => { let from_branch = if secondary { self.default_branch.clone() } else { None }; self.create_branch(from_branch, name.into(), window, cx); } } cx.emit(DismissEvent); } fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { self.state = PickerState::List; cx.emit(DismissEvent); } fn render_match( &self, ix: usize, selected: bool, _window: &mut Window, cx: &mut Context>, ) -> Option { let entry = &self.matches.get(ix)?; let (commit_time, absolute_time, author_name, subject) = entry .as_branch() .and_then(|branch| { branch.most_recent_commit.as_ref().map(|commit| { let subject = commit.subject.clone(); let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) .unwrap_or_else(|_| OffsetDateTime::now_utc()); let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); let formatted_time = time_format::format_localized_timestamp( commit_time, OffsetDateTime::now_utc(), local_offset, time_format::TimestampFormat::Relative, ); let absolute_time = time_format::format_localized_timestamp( commit_time, OffsetDateTime::now_utc(), local_offset, time_format::TimestampFormat::EnhancedAbsolute, ); let author = commit.author_name.clone(); ( Some(formatted_time), Some(absolute_time), Some(author), Some(subject), ) }) }) .unwrap_or_else(|| (None, None, None, None)); let is_head_branch = entry.as_branch().is_some_and(|branch| branch.is_head); let is_checked_branch = entry.as_branch().is_some_and(|branch| { if self.is_select_only() { self.branch_selection_behavior .selected_branch() .is_some_and(|selected_branch| branch_matches_ref(branch, selected_branch)) } else { branch.is_head } }); let entry_icon = match entry { Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => { IconName::Plus } Entry::Branch { branch, .. } => { if is_checked_branch { IconName::Check } else if branch.is_remote() { branch .remote_name() .and_then(|remote_name| self.remote_provider_icons.get(remote_name)) .copied() .unwrap_or(IconName::Server) } else { IconName::GitBranch } } }; let entry_title = match entry { Entry::NewUrl { .. } => Label::new("Create Remote Repository") .single_line() .truncate() .into_any_element(), Entry::NewBranch { name } => Label::new(format!("Create Branch: \"{name}\"…")) .single_line() .truncate() .into_any_element(), Entry::NewRemoteName { name, .. } => Label::new(format!("Create Remote: \"{name}\"")) .single_line() .truncate() .into_any_element(), Entry::Branch { branch, positions } => { HighlightedLabel::new(branch.name().to_string(), positions.clone()) .single_line() .truncate() .into_any_element() } }; let focus_handle = self.focus_handle.clone(); let picker = cx.entity(); let is_new_items = matches!( entry, Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } ); let deleted_branch_icon = |entry_ix: usize| { let picker = picker.clone(); let focus_handle = focus_handle.clone(); let force_delete = self.is_force_delete_hovering_index(entry_ix); div() .id(("delete-hover", entry_ix)) .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { if *hovered { this.delegate.hovered_delete_index = Some(entry_ix); } else if this.delegate.hovered_delete_index == Some(entry_ix) { this.delegate.hovered_delete_index = None; } cx.notify(); })) .child( IconButton::new(("delete", entry_ix), IconName::Trash) .icon_size(IconSize::Small) .when(force_delete, |this| this.icon_color(Color::Error)) .tooltip(move |_, cx| { cx.new(|cx| { DeleteBranchTooltip::new( picker.clone(), focus_handle.clone(), entry_ix, cx, ) }) .into() }) .on_click(cx.listener(move |this, _, window, cx| { this.delegate.delete_at( entry_ix, this.delegate.modifiers.alt, window, cx, ); })), ) }; let create_from_default_button = self.default_branch.as_ref().map(|default_branch| { let tooltip_label: SharedString = format!("Create New From: {default_branch}").into(); let focus_handle = self.focus_handle.clone(); IconButton::new("create_from_default", IconName::GitBranchPlus) .icon_size(IconSize::Small) .tooltip(move |_, cx| { Tooltip::for_action_in( tooltip_label.clone(), &menu::SecondaryConfirm, &focus_handle, cx, ) }) .on_click(cx.listener(|this, _, window, cx| { this.delegate.confirm(true, window, cx); })) .into_any_element() }); let list_item = ListItem::new(format!("vcs-menu-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) .child( h_flex() .w_full() .min_w_0() .gap_2p5() .flex_grow_1() .child( Icon::new(entry_icon) .color(if is_checked_branch { Color::Accent } else { Color::Muted }) .size(IconSize::Small), ) .child( v_flex() .id("info_container") .w_full() .min_w_0() .child(entry_title) .child({ let message = match entry { Entry::NewUrl { url } => format!("Based off {url}"), Entry::NewRemoteName { url, .. } => { format!("Based off {url}") } Entry::NewBranch { .. } => { if let Some(current_branch) = self.repo.as_ref().and_then(|repo| { repo.read(cx).branch.as_ref().map(|b| b.name()) }) { format!("Based off {}", current_branch) } else { "Based off the current branch".to_string() } } Entry::Branch { .. } => String::new(), }; if matches!(entry, Entry::Branch { .. }) { let show_author_name = ProjectSettings::get_global(cx) .git .branch_picker .show_author_name; let has_author = show_author_name && author_name.is_some(); let has_commit = commit_time.is_some(); let author_for_meta = if show_author_name { author_name } else { None }; let dot = || { Label::new("•") .alpha(0.5) .color(Color::Muted) .size(LabelSize::Small) }; h_flex() .w_full() .min_w_0() .gap_1p5() .when_some(author_for_meta, |this, author| { this.child( Label::new(author) .color(Color::Muted) .size(LabelSize::Small), ) }) .when_some(commit_time, |this, time| { this.when(has_author, |this| this.child(dot())).child( Label::new(time) .color(Color::Muted) .size(LabelSize::Small), ) }) .when_some(subject, |this, subj| { this.when(has_commit, |this| this.child(dot())).child( Label::new(subj.to_string()) .color(Color::Muted) .size(LabelSize::Small) .truncate() .flex_1(), ) }) .when(!has_commit, |this| { this.child( Label::new("No commits found") .color(Color::Muted) .size(LabelSize::Small), ) }) .into_any_element() } else { Label::new(message) .size(LabelSize::Small) .color(Color::Muted) .truncate() .into_any_element() } }) .when_some( entry.as_branch().map(|b| b.name().to_string()), |this, branch_name| { let absolute_time = absolute_time.clone(); this.tooltip({ let is_head = is_head_branch; let is_checked = is_checked_branch; let is_select_only = self.is_select_only(); Tooltip::element(move |_, _| { v_flex() .child(Label::new(branch_name.clone())) .when(is_select_only && is_checked, |this| { this.child( Label::new("Selected Branch") .size(LabelSize::Small) .color(Color::Muted), ) }) .when(is_head, |this| { this.child( Label::new("Current Branch") .size(LabelSize::Small) .color(Color::Muted), ) }) .when_some(absolute_time.clone(), |this, time| { this.child( Label::new(time) .size(LabelSize::Small) .color(Color::Muted), ) }) .into_any_element() }) }) }, ), ), ) .when( !self.is_select_only() && !is_new_items && !is_head_branch, |this| { this.end_slot(deleted_branch_icon(ix)) .show_end_slot_on_hover() }, ) .when_some( if is_new_items { create_from_default_button } else { None }, |this, create_from_default_button| { this.end_slot(create_from_default_button) .show_end_slot_on_hover() }, ); let section_header = (self.branch_filter == BranchFilter::All) .then(|| entry.as_branch()) .flatten() .and_then(|branch| { let starts_section = ix == 0 || self.matches[ix - 1] .as_branch() .is_none_or(|previous_branch| { previous_branch.is_remote() != branch.is_remote() }); starts_section.then(|| { if branch.is_remote() { ("Remote Branches", ix != 0) } else { ("Local Branches", false) } }) }); Some( v_flex() .w_full() .when_some(section_header, |this, (section_header, show_divider)| { this.pt_1p5() .when(show_divider, |this| { this.mt_1() .border_t_1() .border_color(cx.theme().colors().border_variant) }) .child(ListSubHeader::new(section_header).inset(true)) }) .child(list_item) .into_any_element(), ) } fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { if self.is_select_only() || !self.show_footer || self.editor_position() == PickerEditorPosition::End { return None; } let focus_handle = self.focus_handle.clone(); let footer_container = || { h_flex() .w_full() .p_1p5() .border_t_1() .border_color(cx.theme().colors().border_variant) }; match self.state { PickerState::List => { let selected_entry = self.matches.get(self.selected_index); let branch_from_default_button = self .default_branch .as_ref() .filter(|_| matches!(selected_entry, Some(Entry::NewBranch { .. }))) .map(|default_branch| { let button_label = format!("Create New From: {default_branch}"); Button::new("branch-from-default", button_label) .key_binding( KeyBinding::for_action_in( &menu::SecondaryConfirm, &focus_handle, cx, ) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(cx.listener(|this, _, window, cx| { this.delegate.confirm(true, window, cx); })) }); let delete_and_select_btns = h_flex() .gap_1() .when( !selected_entry .and_then(|entry| entry.as_branch()) .is_some_and(|branch| branch.is_head), |this| { this.child( Button::new("delete-branch", "Delete") .key_binding( KeyBinding::for_action_in( &branch_picker::DeleteBranch, &focus_handle, cx, ) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(|_, window, cx| { window.dispatch_action( branch_picker::DeleteBranch.boxed_clone(), cx, ); }), ) }, ) .child( Button::new("switch_branch", "Switch") .key_binding( KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(cx.listener(|this, _, window, cx| { this.delegate.confirm(false, window, cx); })), ); Some( footer_container() .justify_end() .map(|this| match branch_from_default_button { Some(button) => this.child(button).child( Button::new("create", "Create") .key_binding( KeyBinding::for_action_in( &menu::Confirm, &focus_handle, cx, ) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(cx.listener(|this, _, window, cx| { this.delegate.confirm(false, window, cx); })), ), None => this.child(delete_and_select_btns), }) .into_any_element(), ) } PickerState::NewBranch => { let branch_from_default_button = self.default_branch.as_ref().map(|default_branch| { let button_label = format!("Create New From: {default_branch}"); Button::new("branch-from-default", button_label) .key_binding( KeyBinding::for_action_in( &menu::SecondaryConfirm, &focus_handle, cx, ) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(cx.listener(|this, _, window, cx| { this.delegate.confirm(true, window, cx); })) }); Some( footer_container() .gap_1() .justify_end() .when_some(branch_from_default_button, |this, button| { this.child(button) }) .child( Button::new("create-new-branch", "Create") .key_binding( KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(cx.listener(|this, _, window, cx| { this.delegate.confirm(false, window, cx); })), ) .into_any_element(), ) } PickerState::CreateRemote(_) => Some( footer_container() .justify_end() .child( Button::new("confirm-create-remote", "Confirm") .key_binding( KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(cx.listener(|this, _, window, cx| { this.delegate.confirm(false, window, cx); })) .disabled(self.last_query.is_empty()), ) .into_any_element(), ), PickerState::NewRemote => None, } } } #[cfg(test)] mod tests { use std::collections::HashSet; use super::*; use git::repository::{ CommitSummary, Remote, Upstream, UpstreamTracking, UpstreamTrackingStatus, }; use gpui::{AppContext, TestAppContext, VisualTestContext}; use project::{FakeFs, Project}; use rand::{Rng, rngs::StdRng}; use serde_json::json; use settings::SettingsStore; use util::path; use workspace::MultiWorkspace; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); }); } fn create_test_branch( name: &str, is_head: bool, remote_name: Option<&str>, timestamp: Option, ) -> Branch { create_test_branch_with_upstream(name, is_head, remote_name, timestamp, None) } fn create_test_branch_with_upstream( name: &str, is_head: bool, remote_name: Option<&str>, timestamp: Option, upstream_ref_name: Option<&str>, ) -> Branch { let ref_name = match remote_name { Some(remote_name) => format!("refs/remotes/{remote_name}/{name}"), None => format!("refs/heads/{name}"), }; Branch { is_head, ref_name: ref_name.into(), upstream: upstream_ref_name.map(|ref_name| Upstream { ref_name: ref_name.into(), tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead: 0, behind: 0, }), }), most_recent_commit: timestamp.map(|ts| CommitSummary { sha: "abc123".into(), commit_timestamp: ts, author_name: "Test Author".into(), subject: "Test commit".into(), has_parent: true, }), } } fn create_test_branches() -> Vec { vec![ create_test_branch("main", true, None, Some(1000)), create_test_branch("feature-auth", false, None, Some(900)), create_test_branch("feature-ui", false, None, Some(800)), create_test_branch("develop", false, None, Some(700)), ] } #[test] fn test_select_branch_shows_tracked_remotes_and_prioritizes_active_remote_branches() { let selected_branch = SharedString::from("origin/main"); let branches: Arc<[Branch]> = Arc::from([ create_test_branch_with_upstream( "feature", true, None, Some(1200), Some("refs/remotes/origin/feature"), ), create_test_branch_with_upstream( "main", false, None, Some(1100), Some("refs/remotes/origin/main"), ), create_test_branch("main", false, Some("origin"), Some(1000)), create_test_branch("feature", false, Some("origin"), Some(900)), create_test_branch("topic", false, Some("origin"), Some(850)), create_test_branch("main", false, Some("fork"), Some(800)), ]); let checkout_branches = process_branches(&branches, true); assert!( checkout_branches .iter() .all(|branch| branch.name() != "origin/main" && branch.name() != "origin/feature"), "remote branches tracked by a local branch should be collapsed when checking out" ); let processed_branches = process_branches(&branches, false); assert_eq!( processed_branches.len(), branches.len(), "no branches should be filtered out when selecting a branch" ); assert!( processed_branches .iter() .any(|branch| branch.name() == "origin/main"), "remote branches should be selectable even when a local branch tracks them" ); let mut entries = processed_branches .into_iter() .map(|branch| Entry::Branch { branch, positions: Vec::new(), }) .collect::>(); let selection_context = BranchSelectionContext { selected_branch: Some(selected_branch), active_branch_ref_name: Some("refs/heads/feature".into()), active_branch_upstream_ref_name: Some("refs/remotes/origin/feature".into()), active_branch_remote_name: Some("origin".into()), }; sort_branch_entries(&mut entries, Some(&selection_context)); let ordered_branch_names = entries.iter().map(Entry::name).collect::>(); assert_eq!(ordered_branch_names.first(), Some(&"origin/main")); assert!( entries .windows(2) .filter(|entries| { entries[0].as_branch().map(Branch::is_remote) != entries[1].as_branch().map(Branch::is_remote) }) .count() <= 1, "local and remote branches should each form a contiguous section" ); assert!( ordered_branch_names .iter() .position(|name| *name == "origin/topic") < ordered_branch_names .iter() .position(|name| *name == "fork/main"), "branches on the active branch's remote should be prioritized within their section" ); } async fn init_branch_list_test( repository: Option>, branches: Vec, cx: &mut TestAppContext, ) -> (Entity, VisualTestContext) { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; let window_handle = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); let workspace = window_handle .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let branch_list = window_handle .update(cx, |_multi_workspace, window, cx| { cx.new(|cx| { let mut delegate = BranchListDelegate::new( workspace.downgrade(), repository, BranchListStyle::Modal, BranchSelectionBehavior::Checkout, cx, ); delegate.all_branches = branches; let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, _| { picker.delegate.focus_handle = picker_focus_handle.clone(); }); let _subscription = cx.subscribe(&picker, |_, _, _, cx| { cx.emit(DismissEvent); }); BranchList { picker, picker_focus_handle, _subscriptions: vec![_subscription], embedded: false, } }) }) .unwrap(); let cx = VisualTestContext::from_window(window_handle.into(), cx); (branch_list, cx) } async fn init_fake_repository_with_fs( cx: &mut TestAppContext, ) -> (Arc, Entity, Entity) { let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ ".git": {}, "file.txt": "buffer_text".to_string() }), ) .await; fs.set_head_for_repo( path!("/dir/.git").as_ref(), &[("file.txt", "test".to_string())], "deadbeef", ); fs.set_index_for_repo( path!("/dir/.git").as_ref(), &[("file.txt", "index_text".to_string())], ); let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let repository = cx.read(|cx| project.read(cx).active_repository(cx)); (fs, project, repository.unwrap()) } async fn init_fake_repository( cx: &mut TestAppContext, ) -> (Entity, Entity) { let (_, project, repository) = init_fake_repository_with_fs(cx).await; (project, repository) } #[gpui::test] async fn test_update_branch_matches_with_query(cx: &mut TestAppContext) { init_test(cx); let branches = create_test_branches(); let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; let cx = &mut ctx; branch_list .update_in(cx, |branch_list, window, cx| { let query = "feature".to_string(); branch_list.picker.update(cx, |picker, cx| { picker.delegate.update_matches(query, window, cx) }) }) .await; cx.run_until_parked(); branch_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { // Should have 2 existing branches + 1 "create new branch" entry = 3 total assert_eq!(picker.delegate.matches.len(), 3); assert!( picker .delegate .matches .iter() .any(|m| m.name() == "feature-auth") ); assert!( picker .delegate .matches .iter() .any(|m| m.name() == "feature-ui") ); // Verify the last entry is the "create new branch" option let last_match = picker.delegate.matches.last().unwrap(); assert!(last_match.is_new_branch()); }) }); for branch_filter in [BranchFilter::Local, BranchFilter::Remote] { branch_list .update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { picker.delegate.branch_filter = branch_filter; picker .delegate .update_matches(String::from("missing-branch"), window, cx) }) }) .await; cx.run_until_parked(); branch_list.update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { assert!( picker.delegate.render_header(window, cx).is_none(), "create-only results should not be shown under a branch section header" ); assert!(matches!( picker.delegate.matches.as_slice(), [Entry::NewBranch { .. }] )); }) }); } branch_list .update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { picker.delegate.branch_filter = BranchFilter::Remote; picker .delegate .update_matches(String::from("feature-ui"), window, cx) }) }) .await; cx.run_until_parked(); branch_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { assert!( picker.delegate.matches.is_empty(), "an exact branch hidden by the active filter should not be offered for creation" ); }) }); branch_list .update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { picker .delegate .update_matches(String::from("feature-u"), window, cx) }) }) .await; cx.run_until_parked(); branch_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { assert!(matches!( picker.delegate.matches.as_slice(), [Entry::NewBranch { .. }] )); }) }); } async fn update_branch_list_matches_with_empty_query( branch_list: &Entity, cx: &mut VisualTestContext, ) { branch_list .update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { picker.delegate.update_matches(String::new(), window, cx) }) }) .await; cx.run_until_parked(); } #[gpui::test] async fn test_delete_branch(cx: &mut TestAppContext) { init_test(cx); let (_project, repository) = init_fake_repository(cx).await; let branches = create_test_branches(); let branch_names = branches .iter() .map(|branch| branch.name().to_string()) .collect::>(); let repo = repository.clone(); cx.spawn(async move |mut cx| { for branch in branch_names { repo.update(&mut cx, |repo, _| repo.create_branch(branch, None)) .await .unwrap() .unwrap(); } }) .await; cx.run_until_parked(); let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; let cx = &mut ctx; update_branch_list_matches_with_empty_query(&branch_list, cx).await; let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { assert_eq!(picker.delegate.matches.len(), 4); let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string(); picker.delegate.delete_at(1, false, window, cx); branch_to_delete }) }); cx.run_until_parked(); let expected_branches = ["main", "feature-auth", "feature-ui", "develop"] .into_iter() .filter(|name| name != &branch_to_delete) .collect::>(); let repo_branches = branch_list .update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, cx| { picker .delegate .repo .as_ref() .unwrap() .update(cx, |repo, _cx| repo.branches()) }) }) .await .unwrap() .unwrap() .branches; let repo_branches = repo_branches .iter() .map(|b| b.name()) .collect::>(); assert_eq!(&repo_branches, &expected_branches); branch_list.update(cx, move |branch_list, cx| { branch_list.picker.update(cx, move |picker, _cx| { assert_eq!(picker.delegate.matches.len(), 3); let branches = picker .delegate .matches .iter() .map(|be| be.name()) .collect::>(); assert_eq!(branches, expected_branches); }) }); } #[gpui::test] async fn test_delete_unmerged_branch_prompts_for_force_delete(cx: &mut TestAppContext) { init_test(cx); let (fs, _project, repository) = init_fake_repository_with_fs(cx).await; let branches = create_test_branches(); let branch_names = branches .iter() .map(|branch| branch.name().to_string()) .collect::>(); let repo = repository.clone(); cx.spawn(async move |mut cx| { for branch in branch_names { repo.update(&mut cx, |repo, _| repo.create_branch(branch, None)) .await .unwrap() .unwrap(); } }) .await; cx.run_until_parked(); let branch_to_delete = "feature-auth"; fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| { state .branches_requiring_force_delete .insert(branch_to_delete.to_string()); }) .expect("failed to mark test branch as requiring force delete"); let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; let cx = &mut ctx; update_branch_list_matches_with_empty_query(&branch_list, cx).await; branch_list.update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { let branch_index = picker .delegate .matches .iter() .position(|entry| entry.name() == branch_to_delete) .unwrap(); picker.delegate.delete_at(branch_index, false, window, cx); }) }); cx.run_until_parked(); assert!(cx.has_pending_prompt()); cx.simulate_prompt_answer("Force Delete"); cx.run_until_parked(); let repo_branches = branch_list .update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, cx| { picker .delegate .repo .as_ref() .unwrap() .update(cx, |repo, _cx| repo.branches()) }) }) .await .unwrap() .unwrap() .branches; assert!( repo_branches .iter() .all(|branch| branch.name() != branch_to_delete) ); } #[gpui::test] async fn test_delete_unmerged_branch_cancel_keeps_branch(cx: &mut TestAppContext) { init_test(cx); let (fs, _project, repository) = init_fake_repository_with_fs(cx).await; let branches = create_test_branches(); let branch_names = branches .iter() .map(|branch| branch.name().to_string()) .collect::>(); let repo = repository.clone(); cx.spawn(async move |mut cx| { for branch in branch_names { repo.update(&mut cx, |repo, _| repo.create_branch(branch, None)) .await .unwrap() .unwrap(); } }) .await; cx.run_until_parked(); let branch_to_delete = "feature-auth"; fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| { state .branches_requiring_force_delete .insert(branch_to_delete.to_string()); }) .expect("failed to mark test branch as requiring force delete"); let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; let cx = &mut ctx; update_branch_list_matches_with_empty_query(&branch_list, cx).await; let initial_match_count = branch_list.update(cx, |branch_list, cx| { branch_list .picker .update(cx, |picker, _| picker.delegate.matches.len()) }); branch_list.update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { let branch_index = picker .delegate .matches .iter() .position(|entry| entry.name() == branch_to_delete) .unwrap(); picker.delegate.delete_at(branch_index, false, window, cx); }) }); cx.run_until_parked(); assert!(cx.has_pending_prompt()); cx.simulate_prompt_answer("Cancel"); cx.run_until_parked(); assert!(!cx.has_pending_prompt()); let repo_branches = branch_list .update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, cx| { picker .delegate .repo .as_ref() .unwrap() .update(cx, |repo, _cx| repo.branches()) }) }) .await .unwrap() .unwrap() .branches; assert!( repo_branches .iter() .any(|branch| branch.name() == branch_to_delete), "branch should still exist after cancelling the force-delete prompt" ); let final_match_count = branch_list.update(cx, |branch_list, cx| { branch_list .picker .update(cx, |picker, _| picker.delegate.matches.len()) }); assert_eq!( initial_match_count, final_match_count, "picker matches should be unchanged after cancel" ); } #[gpui::test] async fn test_force_delete_click_deletes_branch_without_prompt(cx: &mut TestAppContext) { init_test(cx); let (fs, _project, repository) = init_fake_repository_with_fs(cx).await; let branches = create_test_branches(); let branch_names = branches .iter() .map(|branch| branch.name().to_string()) .collect::>(); let repo = repository.clone(); cx.spawn(async move |mut cx| { for branch in branch_names { repo.update(&mut cx, |repo, _| repo.create_branch(branch, None)) .await .unwrap() .unwrap(); } }) .await; cx.run_until_parked(); let branch_to_delete = "feature-auth"; fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| { state .branches_requiring_force_delete .insert(branch_to_delete.to_string()); }) .expect("failed to mark test branch as requiring force delete"); let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; let cx = &mut ctx; update_branch_list_matches_with_empty_query(&branch_list, cx).await; branch_list.update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { picker.delegate.modifiers = Modifiers::alt(); let branch_index = picker .delegate .matches .iter() .position(|entry| entry.name() == branch_to_delete) .unwrap(); picker.delegate.delete_at(branch_index, true, window, cx); }) }); cx.run_until_parked(); assert!(!cx.has_pending_prompt()); let repo_branches = branch_list .update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, cx| { picker .delegate .repo .as_ref() .unwrap() .update(cx, |repo, _cx| repo.branches()) }) }) .await .unwrap() .unwrap() .branches; assert!( repo_branches .iter() .all(|branch| branch.name() != branch_to_delete) ); } #[gpui::test] async fn test_delete_remote_branch(cx: &mut TestAppContext) { init_test(cx); let (_project, repository) = init_fake_repository(cx).await; let branches = vec![ create_test_branch("main", true, Some("origin"), Some(1000)), create_test_branch("feature-auth", false, Some("origin"), Some(900)), create_test_branch("feature-ui", false, Some("fork"), Some(800)), create_test_branch("develop", false, Some("private"), Some(700)), ]; let branch_names = branches .iter() .map(|branch| branch.name().to_string()) .collect::>(); let repo = repository.clone(); cx.spawn(async move |mut cx| { for branch in branch_names { repo.update(&mut cx, |repo, _| repo.create_branch(branch, None)) .await .unwrap() .unwrap(); } }) .await; cx.run_until_parked(); let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; let cx = &mut ctx; // Enable remote filter branch_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { picker.delegate.branch_filter = BranchFilter::Remote; }); }); update_branch_list_matches_with_empty_query(&branch_list, cx).await; // Check matches, it should match all existing branches and no option to create new branch let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { assert_eq!(picker.delegate.matches.len(), 4); let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string(); picker.delegate.delete_at(1, false, window, cx); branch_to_delete }) }); cx.run_until_parked(); let expected_branches = [ "origin/main", "origin/feature-auth", "fork/feature-ui", "private/develop", ] .into_iter() .filter(|name| name != &branch_to_delete) .collect::>(); let repo_branches = branch_list .update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, cx| { picker .delegate .repo .as_ref() .unwrap() .update(cx, |repo, _cx| repo.branches()) }) }) .await .unwrap() .unwrap() .branches; let repo_branches = repo_branches .iter() .map(|b| b.name()) .collect::>(); assert_eq!(&repo_branches, &expected_branches); // Check matches, it should match one less branch than before branch_list.update(cx, move |branch_list, cx| { branch_list.picker.update(cx, move |picker, _cx| { assert_eq!(picker.delegate.matches.len(), 3); let branches = picker .delegate .matches .iter() .map(|be| be.name()) .collect::>(); assert_eq!(branches, expected_branches); }) }); } #[gpui::test] async fn test_branch_filter_shows_all_local_and_remote_branches(cx: &mut TestAppContext) { init_test(cx); let branches = vec![ create_test_branch("main", true, Some("origin"), Some(1000)), create_test_branch("feature-auth", false, Some("fork"), Some(900)), create_test_branch("feature-ui", false, None, Some(800)), create_test_branch("develop", false, None, Some(700)), ]; let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; let cx = &mut ctx; update_branch_list_matches_with_empty_query(&branch_list, cx).await; branch_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { assert_eq!(picker.delegate.matches.len(), 4); let branches = picker .delegate .matches .iter() .map(|be| be.name()) .collect::>(); assert_eq!( branches, ["origin/main", "fork/feature-auth", "feature-ui", "develop"] .into_iter() .collect::>() ); // Locals should be listed before remotes. let ordered = picker .delegate .matches .iter() .map(|be| be.name()) .collect::>(); assert_eq!( ordered, vec!["feature-ui", "develop", "origin/main", "fork/feature-auth"] ); // Verify the last entry is NOT the "create new branch" option let last_match = picker.delegate.matches.last().unwrap(); assert!(!last_match.is_new_branch()); assert!(!last_match.is_new_url()); }) }); branch_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { picker.delegate.branch_filter = BranchFilter::Local; }) }); update_branch_list_matches_with_empty_query(&branch_list, cx).await; branch_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { assert_eq!( picker .delegate .matches .iter() .map(|entry| entry.name()) .collect::>(), ["feature-ui", "develop"].into_iter().collect() ); picker.delegate.branch_filter = BranchFilter::Remote; }) }); update_branch_list_matches_with_empty_query(&branch_list, cx).await; branch_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { assert_eq!(picker.delegate.matches.len(), 2); let branches = picker .delegate .matches .iter() .map(|be| be.name()) .collect::>(); assert_eq!( branches, ["origin/main", "fork/feature-auth"] .into_iter() .collect::>() ); // Verify the last entry is NOT the "create new branch" option let last_match = picker.delegate.matches.last().unwrap(); assert!(!last_match.is_new_url()); }) }); branch_list .update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { picker.delegate.branch_filter = BranchFilter::Remote; picker .delegate .update_matches(String::from("fork"), window, cx) }) }) .await; cx.run_until_parked(); branch_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { // Should have 1 existing branch + 1 "create new branch" entry = 2 total assert_eq!(picker.delegate.matches.len(), 2); assert!( picker .delegate .matches .iter() .any(|m| m.name() == "fork/feature-auth") ); // Verify the last entry is the "create new branch" option let last_match = picker.delegate.matches.last().unwrap(); assert!(last_match.is_new_branch()); }) }); } #[gpui::test] async fn test_branch_filter_is_restored_for_new_pickers(cx: &mut TestAppContext) { init_test(cx); cx.update(|cx| cx.set_global(GlobalBranchFilter(BranchFilter::Local))); let (branch_list, mut ctx) = init_branch_list_test(None, create_test_branches(), cx).await; branch_list.update(&mut ctx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _| { assert!(picker.delegate.branch_filter == BranchFilter::Local); }); }); } #[test] fn test_branch_filter_cycle() { assert!(BranchFilter::All.next() == BranchFilter::Local); assert!(BranchFilter::Local.next() == BranchFilter::Remote); assert!(BranchFilter::Remote.next() == BranchFilter::All); } #[gpui::test] async fn test_select_picker_lists_remote_branch_tracked_by_local_branch( cx: &mut TestAppContext, ) { init_test(cx); let (project, repository) = init_fake_repository(cx).await; cx.run_until_parked(); // Local `main` tracks `origin/main`; the two can point to different // commits, so both must be offered when picking a diff base. let branches = vec![ create_test_branch_with_upstream( "main", true, None, Some(1000), Some("refs/remotes/origin/main"), ), create_test_branch("main", false, Some("origin"), Some(900)), ]; repository.update(cx, |repository, cx| { repository.set_branch_list_for_test(branches, cx); }); let window_handle = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); let workspace = window_handle .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) .unwrap(); let checkout_list = window_handle .update(cx, |_, window, cx| { cx.new(|cx| { BranchList::new( workspace.downgrade(), Some(repository.clone()), BranchListStyle::Modal, rems(34.), window, cx, ) }) }) .unwrap(); let select_list = window_handle .update(cx, |_, window, cx| { cx.new(|cx| { BranchList::new_select( workspace.downgrade(), Some(repository.clone()), BranchListStyle::Modal, rems(34.), Some("main".into()), Arc::new(|_: Branch, _: &mut Window, _: &mut App| {}), window, cx, ) }) }) .unwrap(); let mut ctx = VisualTestContext::from_window(window_handle.into(), cx); let cx = &mut ctx; update_branch_list_matches_with_empty_query(&checkout_list, cx).await; checkout_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { let names = picker .delegate .matches .iter() .map(Entry::name) .collect::>(); assert_eq!( names, vec!["main"], "the checkout picker should collapse remote branches tracked by a local branch" ); }) }); update_branch_list_matches_with_empty_query(&select_list, cx).await; select_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { let names = picker .delegate .matches .iter() .map(Entry::name) .collect::>(); assert_eq!( names, ["main", "origin/main"].into_iter().collect::>(), "the select picker should offer both a local branch and its remote upstream" ); }) }); } #[gpui::test] async fn test_new_branch_creation_with_query(cx: &mut TestAppContext) { const MAIN_BRANCH: &str = "main"; const FEATURE_BRANCH: &str = "feature"; const NEW_BRANCH: &str = "new-feature-branch"; init_test(cx); let (_project, repository) = init_fake_repository(cx).await; let branches = vec![ create_test_branch(MAIN_BRANCH, true, None, Some(1000)), create_test_branch(FEATURE_BRANCH, false, None, Some(900)), ]; let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; let cx = &mut ctx; branch_list .update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { // Surrounding the branch with whitespace allows us to then // assert that this whitespace is trimmed away. picker .delegate .update_matches(format!(" {NEW_BRANCH} "), window, cx) }) }) .await; cx.run_until_parked(); branch_list.update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { let last_match = picker.delegate.matches.last().unwrap(); assert!(last_match.is_new_branch()); assert_eq!(last_match.name(), NEW_BRANCH); // State is NewBranch because no existing branches fuzzy-match the query assert!(matches!(picker.delegate.state, PickerState::NewBranch)); picker.delegate.confirm(false, window, cx); }) }); cx.run_until_parked(); let branches = branch_list .update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, cx| { picker .delegate .repo .as_ref() .unwrap() .update(cx, |repo, _cx| repo.branches()) }) }) .await .unwrap() .unwrap() .branches; let new_branch = branches .into_iter() .find(|branch| branch.name() == NEW_BRANCH) .expect("new-feature-branch should exist"); assert_eq!( new_branch.ref_name.as_ref(), &format!("refs/heads/{NEW_BRANCH}"), "branch ref_name should not have duplicate refs/heads/ prefix" ); } #[test] fn test_normalize_branch_name() { assert_eq!(normalize_branch_name(" branch-name "), "branch-name"); assert_eq!(normalize_branch_name("branch name"), "branch-name"); assert_eq!(normalize_branch_name(" branch name "), "branch--name"); } #[gpui::test] async fn test_remote_url_detection_https(cx: &mut TestAppContext) { init_test(cx); let (_project, repository) = init_fake_repository(cx).await; let branches = vec![create_test_branch("main", true, None, Some(1000))]; let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; let cx = &mut ctx; branch_list .update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { let query = "https://github.com/user/repo.git".to_string(); picker.delegate.update_matches(query, window, cx) }) }) .await; cx.run_until_parked(); branch_list .update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { let last_match = picker.delegate.matches.last().unwrap(); assert!(last_match.is_new_url()); assert!(matches!(picker.delegate.state, PickerState::NewRemote)); picker.delegate.confirm(false, window, cx); assert_eq!(picker.delegate.matches.len(), 0); if let PickerState::CreateRemote(remote_url) = &picker.delegate.state && remote_url.as_ref() == "https://github.com/user/repo.git" { } else { panic!("wrong picker state"); } picker .delegate .update_matches("my_new_remote".to_string(), window, cx) }) }) .await; cx.run_until_parked(); branch_list.update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { assert_eq!(picker.delegate.matches.len(), 1); assert!(matches!( picker.delegate.matches.first(), Some(Entry::NewRemoteName { name, url }) if name == "my_new_remote" && url.as_ref() == "https://github.com/user/repo.git" )); picker.delegate.confirm(false, window, cx); }) }); cx.run_until_parked(); // List remotes let remotes = branch_list .update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, cx| { picker .delegate .repo .as_ref() .unwrap() .update(cx, |repo, _cx| repo.get_remotes(None, false)) }) }) .await .unwrap() .unwrap(); assert_eq!( remotes, vec![Remote { name: SharedString::from("my_new_remote") }] ); } #[gpui::test] async fn test_confirm_remote_url_transitions(cx: &mut TestAppContext) { init_test(cx); let branches = vec![create_test_branch("main_branch", true, None, Some(1000))]; let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; let cx = &mut ctx; branch_list .update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { let query = "https://github.com/user/repo.git".to_string(); picker.delegate.update_matches(query, window, cx) }) }) .await; cx.run_until_parked(); // Try to create a new remote but cancel in the middle of the process branch_list .update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { picker.delegate.selected_index = picker.delegate.matches.len() - 1; picker.delegate.confirm(false, window, cx); assert!(matches!( picker.delegate.state, PickerState::CreateRemote(_) )); if let PickerState::CreateRemote(ref url) = picker.delegate.state { assert_eq!(url.as_ref(), "https://github.com/user/repo.git"); } assert_eq!(picker.delegate.matches.len(), 0); picker.delegate.dismissed(window, cx); assert!(matches!(picker.delegate.state, PickerState::List)); let query = "main".to_string(); picker.delegate.update_matches(query, window, cx) }) }) .await; cx.run_until_parked(); // Try to search a branch again to see if the state is restored properly branch_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { // Should have 1 existing branch + 1 "create new branch" entry = 2 total assert_eq!(picker.delegate.matches.len(), 2); assert!( picker .delegate .matches .iter() .any(|m| m.name() == "main_branch") ); // Verify the last entry is the "create new branch" option let last_match = picker.delegate.matches.last().unwrap(); assert!(last_match.is_new_branch()); }) }); } #[gpui::test] async fn test_confirm_remote_url_does_not_dismiss(cx: &mut TestAppContext) { const REMOTE_URL: &str = "https://github.com/user/repo.git"; init_test(cx); let branches = vec![create_test_branch("main", true, None, Some(1000))]; let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; let cx = &mut ctx; let subscription = cx.update(|_, cx| { cx.subscribe(&branch_list, |_, _: &DismissEvent, _| { panic!("DismissEvent should not be emitted when confirming a remote URL"); }) }); branch_list .update_in(cx, |branch_list, window, cx| { window.focus(&branch_list.picker_focus_handle, cx); assert!( branch_list.picker_focus_handle.is_focused(window), "Branch picker should be focused when selecting an entry" ); branch_list.picker.update(cx, |picker, cx| { picker .delegate .update_matches(REMOTE_URL.to_string(), window, cx) }) }) .await; cx.run_until_parked(); branch_list.update_in(cx, |branch_list, window, cx| { // Re-focus the picker since workspace initialization during run_until_parked window.focus(&branch_list.picker_focus_handle, cx); branch_list.picker.update(cx, |picker, cx| { let last_match = picker.delegate.matches.last().unwrap(); assert!(last_match.is_new_url()); assert!(matches!(picker.delegate.state, PickerState::NewRemote)); picker.delegate.confirm(false, window, cx); assert!( matches!(picker.delegate.state, PickerState::CreateRemote(ref url) if url.as_ref() == REMOTE_URL), "State should transition to CreateRemote with the URL" ); }); assert!( branch_list.picker_focus_handle.is_focused(window), "Branch list picker should still be focused after confirming remote URL" ); }); cx.run_until_parked(); drop(subscription); } #[gpui::test(iterations = 10)] async fn test_empty_query_displays_all_branches(mut rng: StdRng, cx: &mut TestAppContext) { init_test(cx); let branch_count = rng.random_range(13..540); let branches: Vec = (0..branch_count) .map(|i| create_test_branch(&format!("branch-{:02}", i), i == 0, None, Some(i * 100))) .collect(); let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; let cx = &mut ctx; update_branch_list_matches_with_empty_query(&branch_list, cx).await; branch_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { assert_eq!(picker.delegate.matches.len(), branch_count as usize); }) }); } }