Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:36:31.399Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

branch_picker.rs

3461 lines · 126.5 KB · rust
1use anyhow::Context as _;
2use editor::Editor;
3use fuzzy_nucleo::StringMatchCandidate;
4
5use collections::{HashMap, HashSet};
6use git::repository::{Branch, delete_branch_flag};
7use git::{GitHostingProviderRegistry, parse_git_remote_url};
8use gpui::http_client::Url;
9use gpui::{
10    Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Global,
11    InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, PromptLevel,
12    Render, SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems,
13};
14use picker::{Picker, PickerDelegate, PickerEditorPosition};
15use project::git_store::{Repository, RepositoryEvent};
16use project::project_settings::ProjectSettings;
17use settings::Settings;
18
19use std::sync::Arc;
20use time::OffsetDateTime;
21use ui::{
22    Banner, ContextMenu, Divider, HighlightedLabel, Indicator, KeyBinding, ListItem,
23    ListItemSpacing, ListSubHeader, PopoverMenu, PopoverMenuHandle, Severity, Tooltip, prelude::*,
24};
25use ui_input::ErasedEditor;
26use util::ResultExt;
27use workspace::notifications::DetachAndPromptErr;
28use workspace::{ModalView, Workspace};
29
30use crate::{branch_picker, git_panel::show_error_toast};
31
32actions!(
33    branch_picker,
34    [
35        /// Deletes the selected git branch or remote.
36        DeleteBranch,
37        /// Force deletes the selected git branch or remote.
38        ForceDeleteBranch,
39        /// Show all branches.
40        ShowAllBranches,
41        /// Show only local branches.
42        ShowLocalBranches,
43        /// Show only remote branches.
44        ShowRemoteBranches,
45        /// Cycle through branch filters.
46        CycleBranchFilter,
47        /// Toggles the branch filter menu.
48        ToggleFilterMenu
49    ]
50);
51
52pub fn checkout_branch(
53    workspace: &mut Workspace,
54    _: &zed_actions::git::CheckoutBranch,
55    window: &mut Window,
56    cx: &mut Context<Workspace>,
57) {
58    open(workspace, &zed_actions::git::Branch, window, cx);
59}
60
61pub fn switch(
62    workspace: &mut Workspace,
63    _: &zed_actions::git::Switch,
64    window: &mut Window,
65    cx: &mut Context<Workspace>,
66) {
67    open(workspace, &zed_actions::git::Branch, window, cx);
68}
69
70pub fn open(
71    workspace: &mut Workspace,
72    _: &zed_actions::git::Branch,
73    window: &mut Window,
74    cx: &mut Context<Workspace>,
75) {
76    let workspace_handle = workspace.weak_handle();
77    let repository = workspace.project().read(cx).active_repository(cx);
78
79    workspace.toggle_modal(window, cx, |window, cx| {
80        BranchList::new(
81            workspace_handle,
82            repository,
83            BranchListStyle::Modal,
84            rems(34.),
85            window,
86            cx,
87        )
88    })
89}
90
91pub fn popover(
92    workspace: WeakEntity<Workspace>,
93    modal_style: bool,
94    repository: Option<Entity<Repository>>,
95    window: &mut Window,
96    cx: &mut App,
97) -> Entity<BranchList> {
98    let (style, width) = if modal_style {
99        (BranchListStyle::Modal, rems(34.))
100    } else {
101        (BranchListStyle::Popover, rems(20.))
102    };
103
104    cx.new(|cx| {
105        let list = BranchList::new(workspace, repository, style, width, window, cx);
106        list.focus_handle(cx).focus(window, cx);
107        list
108    })
109}
110
111pub fn select_popover(
112    workspace: WeakEntity<Workspace>,
113    repository: Option<Entity<Repository>>,
114    selected_branch: Option<SharedString>,
115    on_select: SelectBranchCallback,
116    window: &mut Window,
117    cx: &mut App,
118) -> Entity<BranchList> {
119    cx.new(|cx| {
120        let list = BranchList::new_select(
121            workspace,
122            repository,
123            BranchListStyle::Popover,
124            rems(20.),
125            selected_branch,
126            on_select,
127            window,
128            cx,
129        );
130        list.focus_handle(cx).focus(window, cx);
131        list
132    })
133}
134
135pub fn select_modal(
136    workspace: WeakEntity<Workspace>,
137    repository: Option<Entity<Repository>>,
138    selected_branch: Option<SharedString>,
139    on_select: SelectBranchCallback,
140    window: &mut Window,
141    cx: &mut Context<BranchList>,
142) -> BranchList {
143    let list = BranchList::new_select(
144        workspace,
145        repository,
146        BranchListStyle::Modal,
147        rems(34.),
148        selected_branch,
149        on_select,
150        window,
151        cx,
152    );
153    list.focus_handle(cx).focus(window, cx);
154    list
155}
156
157pub type SelectBranchCallback = Arc<dyn Fn(Branch, &mut Window, &mut App)>;
158
159pub fn create_embedded(
160    workspace: WeakEntity<Workspace>,
161    repository: Option<Entity<Repository>>,
162    width: Rems,
163    show_footer: bool,
164    window: &mut Window,
165    cx: &mut Context<BranchList>,
166) -> BranchList {
167    BranchList::new_embedded(workspace, repository, width, show_footer, window, cx)
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
171enum BranchListStyle {
172    Modal,
173    Popover,
174}
175
176pub struct BranchList {
177    pub picker: Entity<Picker<BranchListDelegate>>,
178    picker_focus_handle: FocusHandle,
179    _subscriptions: Vec<Subscription>,
180    embedded: bool,
181}
182
183impl BranchList {
184    fn new(
185        workspace: WeakEntity<Workspace>,
186        repository: Option<Entity<Repository>>,
187        style: BranchListStyle,
188        width: Rems,
189        window: &mut Window,
190        cx: &mut Context<Self>,
191    ) -> Self {
192        let mut this = Self::new_inner(workspace, repository, style, width, false, window, cx);
193        this._subscriptions
194            .push(cx.subscribe(&this.picker, |this, _, _, cx| {
195                if !this.branch_filter_menu_open(cx) {
196                    cx.emit(DismissEvent);
197                }
198            }));
199        this
200    }
201
202    fn new_inner(
203        workspace: WeakEntity<Workspace>,
204        repository: Option<Entity<Repository>>,
205        style: BranchListStyle,
206        width: Rems,
207        embedded: bool,
208        window: &mut Window,
209        cx: &mut Context<Self>,
210    ) -> Self {
211        Self::new_inner_with_behavior(
212            workspace,
213            repository,
214            style,
215            width,
216            embedded,
217            BranchSelectionBehavior::Checkout,
218            window,
219            cx,
220        )
221    }
222
223    fn new_select(
224        workspace: WeakEntity<Workspace>,
225        repository: Option<Entity<Repository>>,
226        style: BranchListStyle,
227        width: Rems,
228        selected_branch: Option<SharedString>,
229        on_select: SelectBranchCallback,
230        window: &mut Window,
231        cx: &mut Context<Self>,
232    ) -> Self {
233        let mut this = Self::new_inner_with_behavior(
234            workspace,
235            repository,
236            style,
237            width,
238            false,
239            BranchSelectionBehavior::Select {
240                selected_branch,
241                on_select,
242            },
243            window,
244            cx,
245        );
246        this._subscriptions
247            .push(cx.subscribe(&this.picker, |this, _, _, cx| {
248                if !this.branch_filter_menu_open(cx) {
249                    cx.emit(DismissEvent);
250                }
251            }));
252        this
253    }
254
255    fn new_inner_with_behavior(
256        workspace: WeakEntity<Workspace>,
257        repository: Option<Entity<Repository>>,
258        style: BranchListStyle,
259        width: Rems,
260        embedded: bool,
261        branch_selection_behavior: BranchSelectionBehavior,
262        window: &mut Window,
263        cx: &mut Context<Self>,
264    ) -> Self {
265        let all_branches = repository
266            .as_ref()
267            .map(|repo| {
268                process_branches(
269                    &repo.read(cx).branch_list,
270                    !branch_selection_behavior.is_select_only(),
271                )
272            })
273            .unwrap_or_default();
274        let branch_list_error = repository
275            .as_ref()
276            .and_then(|repo| repo.read(cx).branch_list_error.clone());
277
278        let default_branch_request = repository.clone().map(|repository| {
279            repository.update(cx, |repository, _| repository.default_branch(false))
280        });
281        let remote_urls_request = repository
282            .clone()
283            .map(|repository| repository.update(cx, |repository, _| repository.remote_urls()));
284
285        let mut delegate = BranchListDelegate::new(
286            workspace,
287            repository.clone(),
288            style,
289            branch_selection_behavior,
290            cx,
291        );
292        delegate.all_branches = all_branches;
293        delegate.branch_list_error = branch_list_error;
294
295        let picker = cx.new(|cx| {
296            Picker::list(delegate, window, cx)
297                .initial_width(width)
298                .show_scrollbar(true)
299                .when(embedded, |picker| picker.embedded())
300        });
301        let picker_focus_handle = picker.focus_handle(cx);
302
303        picker.update(cx, |picker, _| {
304            picker.delegate.focus_handle = picker_focus_handle.clone();
305            picker.delegate.show_footer = !embedded && !picker.delegate.is_select_only();
306        });
307
308        let mut subscriptions = Vec::new();
309
310        if let Some(repo) = &repository {
311            subscriptions.push(cx.subscribe_in(
312                repo,
313                window,
314                move |this, repo, event, window, cx| {
315                    if matches!(event, RepositoryEvent::BranchListChanged) {
316                        let snapshot = repo.read(cx);
317                        let branch_list = snapshot.branch_list.clone();
318                        let branch_list_error = snapshot.branch_list_error.clone();
319                        this.picker.update(cx, |picker, cx| {
320                            picker.delegate.restore_selected_branch = picker
321                                .delegate
322                                .matches
323                                .get(picker.delegate.selected_index)
324                                .and_then(|entry| entry.as_branch().map(|b| b.ref_name.clone()));
325                            picker.delegate.all_branches = process_branches(
326                                &branch_list,
327                                !picker.delegate.branch_selection_behavior.is_select_only(),
328                            );
329                            picker.delegate.branch_list_error = branch_list_error;
330                            picker.refresh(window, cx);
331                        });
332                    }
333                },
334            ));
335        }
336
337        // Fetch default branch asynchronously since it requires a git operation
338        cx.spawn_in(window, async move |this, cx| {
339            let default_branch = default_branch_request
340                .context("No active repository")?
341                .await
342                .map(Result::ok)
343                .ok()
344                .flatten()
345                .flatten();
346
347            let _ = this.update_in(cx, |this, _window, cx| {
348                this.picker.update(cx, |picker, _cx| {
349                    picker.delegate.default_branch = default_branch;
350                });
351            });
352
353            anyhow::Ok(())
354        })
355        .detach_and_log_err(cx);
356
357        cx.spawn(async move |this, cx| {
358            let remote_urls = remote_urls_request
359                .context("No active repository")?
360                .await??;
361            let remote_provider_icons = cx.update(|cx| remote_provider_icons(&remote_urls, cx));
362            this.update(cx, |this, cx| {
363                this.picker.update(cx, |picker, cx| {
364                    picker.delegate.remote_provider_icons = remote_provider_icons;
365                    cx.notify();
366                });
367            })?;
368            anyhow::Ok(())
369        })
370        .detach_and_log_err(cx);
371
372        Self {
373            picker,
374            picker_focus_handle,
375            _subscriptions: subscriptions,
376            embedded,
377        }
378    }
379
380    fn new_embedded(
381        workspace: WeakEntity<Workspace>,
382        repository: Option<Entity<Repository>>,
383        width: Rems,
384        show_footer: bool,
385        window: &mut Window,
386        cx: &mut Context<Self>,
387    ) -> Self {
388        let mut this = Self::new_inner(
389            workspace,
390            repository,
391            BranchListStyle::Modal,
392            width,
393            true,
394            window,
395            cx,
396        );
397        this.picker.update(cx, |picker, _| {
398            picker.delegate.show_footer = show_footer;
399        });
400        this._subscriptions
401            .push(cx.subscribe(&this.picker, |_, _, _, cx| {
402                cx.emit(DismissEvent);
403            }));
404        this
405    }
406
407    pub fn handle_modifiers_changed(
408        &mut self,
409        ev: &ModifiersChangedEvent,
410        _: &mut Window,
411        cx: &mut Context<Self>,
412    ) {
413        self.picker.update(cx, |picker, cx| {
414            picker.delegate.modifiers = ev.modifiers;
415            cx.notify();
416        })
417    }
418
419    pub fn handle_delete(
420        &mut self,
421        _: &branch_picker::DeleteBranch,
422        window: &mut Window,
423        cx: &mut Context<Self>,
424    ) {
425        self.picker.update(cx, |picker, cx| {
426            if picker.delegate.is_select_only() {
427                return;
428            }
429            picker
430                .delegate
431                .delete_at(picker.delegate.selected_index, false, window, cx)
432        })
433    }
434
435    pub fn handle_force_delete(
436        &mut self,
437        _: &branch_picker::ForceDeleteBranch,
438        window: &mut Window,
439        cx: &mut Context<Self>,
440    ) {
441        self.picker.update(cx, |picker, cx| {
442            if picker.delegate.is_select_only() {
443                return;
444            }
445            picker
446                .delegate
447                .delete_at(picker.delegate.selected_index, true, window, cx)
448        })
449    }
450
451    pub(crate) fn set_branch_filter(
452        &mut self,
453        branch_filter: BranchFilter,
454        window: &mut Window,
455        cx: &mut Context<Self>,
456    ) {
457        cx.set_global(GlobalBranchFilter(branch_filter));
458        self.picker.update(cx, |picker, cx| {
459            if picker.delegate.branch_filter == branch_filter {
460                return;
461            }
462            picker.delegate.branch_filter = branch_filter;
463            picker.update_matches(picker.query(cx), window, cx);
464            picker.refresh_placeholder(window, cx);
465            cx.notify();
466        });
467    }
468
469    pub(crate) fn branch_filter_menu_open(&self, cx: &App) -> bool {
470        self.picker
471            .read(cx)
472            .delegate
473            .branch_filter_menu_handle
474            .is_deployed()
475    }
476
477    pub(crate) fn cycle_branch_filter(&mut self, window: &mut Window, cx: &mut Context<Self>) {
478        let branch_filter = self.picker.read(cx).delegate.branch_filter.next();
479        self.set_branch_filter(branch_filter, window, cx);
480    }
481}
482impl ModalView for BranchList {}
483impl EventEmitter<DismissEvent> for BranchList {}
484
485impl Focusable for BranchList {
486    fn focus_handle(&self, _cx: &App) -> FocusHandle {
487        self.picker_focus_handle.clone()
488    }
489}
490
491impl Render for BranchList {
492    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
493        v_flex()
494            .key_context("GitBranchSelector")
495            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
496            .on_action(cx.listener(Self::handle_delete))
497            .on_action(cx.listener(Self::handle_force_delete))
498            .on_action(
499                cx.listener(|this, _: &branch_picker::ShowAllBranches, window, cx| {
500                    this.set_branch_filter(BranchFilter::All, window, cx);
501                }),
502            )
503            .on_action(
504                cx.listener(|this, _: &branch_picker::ShowLocalBranches, window, cx| {
505                    this.set_branch_filter(BranchFilter::Local, window, cx);
506                }),
507            )
508            .on_action(
509                cx.listener(|this, _: &branch_picker::ShowRemoteBranches, window, cx| {
510                    this.set_branch_filter(BranchFilter::Remote, window, cx);
511                }),
512            )
513            .on_action(
514                cx.listener(|this, _: &branch_picker::CycleBranchFilter, window, cx| {
515                    this.cycle_branch_filter(window, cx);
516                }),
517            )
518            .on_action(
519                cx.listener(|this, _: &branch_picker::ToggleFilterMenu, window, cx| {
520                    let menu_handle = this
521                        .picker
522                        .read(cx)
523                        .delegate
524                        .branch_filter_menu_handle
525                        .clone();
526                    menu_handle.toggle(window, cx);
527                }),
528            )
529            .child(self.picker.clone())
530            .when(!self.embedded, |this| {
531                this.on_mouse_down_out({
532                    cx.listener(move |this, _, window, cx| {
533                        // The filter menu is a deferred popover, so clicks within it are outside
534                        // the branch picker's bounds even though it is part of this interaction.
535                        if this.branch_filter_menu_open(cx) {
536                            return;
537                        }
538                        this.picker.update(cx, |this, cx| {
539                            this.cancel(&Default::default(), window, cx);
540                        })
541                    })
542                })
543            })
544    }
545}
546
547#[derive(Debug, Clone, PartialEq)]
548enum Entry {
549    Branch {
550        branch: Branch,
551        positions: Vec<usize>,
552    },
553    NewUrl {
554        url: String,
555    },
556    NewBranch {
557        name: String,
558    },
559    NewRemoteName {
560        name: String,
561        url: SharedString,
562    },
563}
564
565impl Entry {
566    fn as_branch(&self) -> Option<&Branch> {
567        match self {
568            Entry::Branch { branch, .. } => Some(branch),
569            _ => None,
570        }
571    }
572
573    fn name(&self) -> &str {
574        match self {
575            Entry::Branch { branch, .. } => branch.name(),
576            Entry::NewUrl { url, .. } => url.as_str(),
577            Entry::NewBranch { name, .. } => name.as_str(),
578            Entry::NewRemoteName { name, .. } => name.as_str(),
579        }
580    }
581
582    #[cfg(test)]
583    fn is_new_url(&self) -> bool {
584        matches!(self, Self::NewUrl { .. })
585    }
586
587    #[cfg(test)]
588    fn is_new_branch(&self) -> bool {
589        matches!(self, Self::NewBranch { .. })
590    }
591}
592
593#[derive(Clone, Copy, PartialEq)]
594pub(crate) enum BranchFilter {
595    /// Show both local and remote branches.
596    All,
597    /// Only show local branches.
598    Local,
599    /// Only show remote branches.
600    Remote,
601}
602
603impl BranchFilter {
604    fn next(self) -> Self {
605        match self {
606            Self::All => Self::Local,
607            Self::Local => Self::Remote,
608            Self::Remote => Self::All,
609        }
610    }
611
612    fn label(self) -> &'static str {
613        match self {
614            Self::All => "All Branches",
615            Self::Local => "Local Branches",
616            Self::Remote => "Remote Branches",
617        }
618    }
619}
620
621struct GlobalBranchFilter(BranchFilter);
622
623impl Global for GlobalBranchFilter {}
624
625fn branch_filter_menu(
626    branch_filter: BranchFilter,
627    focus_handle: FocusHandle,
628    window: &mut Window,
629    cx: &mut App,
630) -> Entity<ContextMenu> {
631    ContextMenu::build(window, cx, |mut menu, _, _| {
632        menu = menu.context(focus_handle.clone());
633
634        let filter_actions: [(BranchFilter, Box<dyn Action>); 3] = [
635            (BranchFilter::All, ShowAllBranches.boxed_clone()),
636            (BranchFilter::Local, ShowLocalBranches.boxed_clone()),
637            (BranchFilter::Remote, ShowRemoteBranches.boxed_clone()),
638        ];
639
640        for (filter, action) in filter_actions {
641            let handler_focus = focus_handle.clone();
642            let dispatched = action.boxed_clone();
643            menu = menu.toggleable_entry(
644                filter.label(),
645                filter == branch_filter,
646                IconPosition::End,
647                Some(action),
648                move |window, cx| {
649                    window.focus(&handler_focus, cx);
650                    window.dispatch_action(dispatched.boxed_clone(), cx);
651                },
652            );
653        }
654        menu
655    })
656}
657
658pub struct BranchListDelegate {
659    workspace: WeakEntity<Workspace>,
660    matches: Vec<Entry>,
661    all_branches: Vec<Branch>,
662    branch_list_error: Option<SharedString>,
663    default_branch: Option<SharedString>,
664    repo: Option<Entity<Repository>>,
665    style: BranchListStyle,
666    selected_index: usize,
667    last_query: String,
668    modifiers: Modifiers,
669    branch_filter: BranchFilter,
670    branch_filter_menu_handle: PopoverMenuHandle<ContextMenu>,
671    state: PickerState,
672    branch_selection_behavior: BranchSelectionBehavior,
673    focus_handle: FocusHandle,
674    restore_selected_branch: Option<SharedString>,
675    show_footer: bool,
676    hovered_delete_index: Option<usize>,
677    remote_provider_icons: HashMap<String, IconName>,
678}
679
680enum BranchSelectionBehavior {
681    Checkout,
682    Select {
683        selected_branch: Option<SharedString>,
684        on_select: SelectBranchCallback,
685    },
686}
687
688impl BranchSelectionBehavior {
689    fn selected_branch(&self) -> Option<&SharedString> {
690        match self {
691            Self::Checkout => None,
692            Self::Select {
693                selected_branch, ..
694            } => selected_branch.as_ref(),
695        }
696    }
697
698    fn is_select_only(&self) -> bool {
699        matches!(self, Self::Select { .. })
700    }
701}
702
703#[derive(Clone)]
704struct BranchSelectionContext {
705    selected_branch: Option<SharedString>,
706    active_branch_ref_name: Option<SharedString>,
707    active_branch_upstream_ref_name: Option<SharedString>,
708    active_branch_remote_name: Option<SharedString>,
709}
710
711impl BranchSelectionContext {
712    fn new(
713        selected_branch: Option<SharedString>,
714        repo: Option<&Entity<Repository>>,
715        cx: &App,
716    ) -> Self {
717        let active_branch = repo.and_then(|repo| repo.read(cx).branch.clone());
718        let active_branch_ref_name = active_branch.as_ref().map(|branch| branch.ref_name.clone());
719        let active_branch_upstream_ref_name = active_branch.as_ref().and_then(|branch| {
720            branch
721                .upstream
722                .as_ref()
723                .map(|upstream| upstream.ref_name.clone())
724        });
725        let active_branch_remote_name = active_branch.as_ref().and_then(|branch| {
726            branch
727                .upstream
728                .as_ref()
729                .and_then(|upstream| upstream.remote_name())
730                .or_else(|| branch.remote_name())
731                .map(SharedString::from)
732        });
733
734        Self {
735            selected_branch,
736            active_branch_ref_name,
737            active_branch_upstream_ref_name,
738            active_branch_remote_name,
739        }
740    }
741
742    fn priority(&self, branch: &Branch) -> usize {
743        if self
744            .selected_branch
745            .as_ref()
746            .is_some_and(|selected_branch| branch_matches_ref(branch, selected_branch))
747        {
748            0
749        } else if self.is_on_active_branch_remote(branch) {
750            1
751        } else if self.is_active_branch(branch) || self.is_active_upstream(branch) {
752            3
753        } else {
754            2
755        }
756    }
757
758    fn is_active_branch(&self, branch: &Branch) -> bool {
759        self.active_branch_ref_name
760            .as_ref()
761            .is_some_and(|ref_name| branch.ref_name.as_ref() == ref_name.as_ref())
762    }
763
764    fn is_active_upstream(&self, branch: &Branch) -> bool {
765        self.active_branch_upstream_ref_name
766            .as_ref()
767            .is_some_and(|ref_name| branch.ref_name.as_ref() == ref_name.as_ref())
768    }
769
770    fn is_on_active_branch_remote(&self, branch: &Branch) -> bool {
771        if self.is_active_branch(branch) || self.is_active_upstream(branch) {
772            return false;
773        }
774
775        let Some(active_branch_remote_name) = &self.active_branch_remote_name else {
776            return false;
777        };
778
779        branch_remote_name(branch)
780            .is_some_and(|remote_name| remote_name == active_branch_remote_name.as_ref())
781    }
782}
783
784#[derive(Debug)]
785enum PickerState {
786    /// When we display list of branches/remotes
787    List,
788    /// When we set an url to create a new remote
789    NewRemote,
790    /// When we confirm the new remote url (after NewRemote)
791    CreateRemote(SharedString),
792    /// When we set a new branch to create
793    NewBranch,
794}
795
796fn delete_branch_command(is_remote: bool, branch_name: &str, force: bool) -> String {
797    format!(
798        "branch {} {branch_name}",
799        delete_branch_flag(is_remote, force)
800    )
801}
802
803struct BranchDeleteForceDeletePrompt {
804    required_error_substrings: &'static [&'static str],
805    message: fn(&str) -> String,
806}
807
808impl BranchDeleteForceDeletePrompt {
809    fn matches(&self, normalized_error_message: &str) -> bool {
810        self.required_error_substrings
811            .iter()
812            .all(|substring| normalized_error_message.contains(substring))
813    }
814}
815
816const BRANCH_DELETE_FORCE_DELETE_PROMPTS: &[BranchDeleteForceDeletePrompt] =
817    &[BranchDeleteForceDeletePrompt {
818        required_error_substrings: &["not fully merged"],
819        message: unmerged_branch_force_delete_prompt,
820    }];
821
822fn unmerged_branch_force_delete_prompt(branch_name: &str) -> String {
823    format!("Branch \"{branch_name}\" is not fully merged. Force delete it?")
824}
825
826// Git only reports these cases via localized stderr, so this best-effort check
827// may miss some locales and fall back to the raw error toast.
828fn force_delete_prompt_for_branch_delete_error(
829    error: &anyhow::Error,
830    branch_name: &str,
831) -> Option<String> {
832    let normalized_error_message = error.to_string().to_lowercase();
833    BRANCH_DELETE_FORCE_DELETE_PROMPTS
834        .iter()
835        .find(|prompt| prompt.matches(&normalized_error_message))
836        .map(|prompt| (prompt.message)(branch_name))
837}
838
839struct DeleteBranchTooltip {
840    picker: WeakEntity<Picker<BranchListDelegate>>,
841    focus_handle: FocusHandle,
842    delete_index: usize,
843    _subscription: Subscription,
844}
845
846impl DeleteBranchTooltip {
847    fn new(
848        picker: Entity<Picker<BranchListDelegate>>,
849        focus_handle: FocusHandle,
850        delete_index: usize,
851        cx: &mut Context<Self>,
852    ) -> Self {
853        let subscription = cx.observe(&picker, |_, _, cx| cx.notify());
854        Self {
855            picker: picker.downgrade(),
856            focus_handle,
857            delete_index,
858            _subscription: subscription,
859        }
860    }
861}
862
863impl Render for DeleteBranchTooltip {
864    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
865        let force_delete = self
866            .picker
867            .read_with(cx, |picker, _| {
868                picker
869                    .delegate
870                    .is_force_delete_hovering_index(self.delete_index)
871            })
872            .unwrap_or(false);
873        if force_delete {
874            Tooltip::for_action_in(
875                "Force Delete Branch",
876                &branch_picker::ForceDeleteBranch,
877                &self.focus_handle,
878                cx,
879            )
880            .into_any_element()
881        } else {
882            Tooltip::with_meta_in(
883                "Delete Branch",
884                Some(&branch_picker::DeleteBranch),
885                "Hold alt to force delete",
886                &self.focus_handle,
887                cx,
888            )
889            .into_any_element()
890        }
891    }
892}
893
894fn branch_matches_ref(branch: &Branch, branch_ref: &SharedString) -> bool {
895    branch.ref_name.as_ref() == branch_ref.as_ref() || branch.name() == branch_ref.as_ref()
896}
897
898// Git branch names can't contain whitespace, so we replace spaces with dashes,
899// but we need to first trim because a branch name can't start or end with a
900// dash.
901fn normalize_branch_name(query: &str) -> String {
902    query.trim().replace(' ', "-")
903}
904
905fn branch_remote_name(branch: &Branch) -> Option<&str> {
906    branch.remote_name().or_else(|| {
907        branch
908            .upstream
909            .as_ref()
910            .and_then(|upstream| upstream.remote_name())
911    })
912}
913
914fn sort_branch_entries(
915    matches: &mut [Entry],
916    branch_selection_context: Option<&BranchSelectionContext>,
917) {
918    let selected_branch_is_remote = branch_selection_context
919        .and_then(|context| context.selected_branch.as_ref())
920        .and_then(|selected_branch| {
921            matches.iter().find_map(|entry| {
922                let branch = entry.as_branch()?;
923                branch_matches_ref(branch, selected_branch).then(|| branch.is_remote())
924            })
925        })
926        .unwrap_or(false);
927
928    matches.sort_by_key(|entry| {
929        let Some(branch) = entry.as_branch() else {
930            return (true, 4);
931        };
932
933        let priority = branch_selection_context
934            .map(|context| context.priority(branch))
935            .unwrap_or(0);
936        (branch.is_remote() != selected_branch_is_remote, priority)
937    });
938}
939
940// Tracked remote branches are:
941// - collapsed when checking out to avoid detaching HEAD.
942// - kept when selecting a diff base because they may point to a different commit.
943fn process_branches(branches: &Arc<[Branch]>, collapse_tracked_remotes: bool) -> Vec<Branch> {
944    let mut result: Vec<Branch> = if collapse_tracked_remotes {
945        let remote_upstreams: HashSet<_> = branches
946            .iter()
947            .filter_map(|branch| {
948                branch
949                    .upstream
950                    .as_ref()
951                    .filter(|upstream| upstream.is_remote())
952                    .map(|upstream| upstream.ref_name.clone())
953            })
954            .collect();
955
956        branches
957            .iter()
958            .filter(|branch| !remote_upstreams.contains(&branch.ref_name))
959            .cloned()
960            .collect()
961    } else {
962        branches.to_vec()
963    };
964
965    result.sort_by_key(|branch| {
966        (
967            !branch.is_head,
968            branch
969                .most_recent_commit
970                .as_ref()
971                .map(|commit| 0 - commit.commit_timestamp),
972        )
973    });
974
975    result
976}
977
978impl BranchListDelegate {
979    fn new(
980        workspace: WeakEntity<Workspace>,
981        repo: Option<Entity<Repository>>,
982        style: BranchListStyle,
983        branch_selection_behavior: BranchSelectionBehavior,
984        cx: &mut Context<BranchList>,
985    ) -> Self {
986        let restore_selected_branch = match &branch_selection_behavior {
987            BranchSelectionBehavior::Checkout => None,
988            BranchSelectionBehavior::Select {
989                selected_branch, ..
990            } => selected_branch.clone(),
991        };
992        let branch_filter = cx
993            .try_global::<GlobalBranchFilter>()
994            .map_or(BranchFilter::All, |filter| filter.0);
995
996        Self {
997            workspace,
998            matches: vec![],
999            repo,
1000            style,
1001            all_branches: Vec::new(),
1002            branch_list_error: None,
1003            default_branch: None,
1004            selected_index: 0,
1005            last_query: Default::default(),
1006            modifiers: Default::default(),
1007            branch_filter,
1008            branch_filter_menu_handle: PopoverMenuHandle::default(),
1009            state: PickerState::List,
1010            branch_selection_behavior,
1011            focus_handle: cx.focus_handle(),
1012            restore_selected_branch,
1013            show_footer: false,
1014            hovered_delete_index: None,
1015            remote_provider_icons: HashMap::default(),
1016        }
1017    }
1018
1019    fn is_select_only(&self) -> bool {
1020        self.branch_selection_behavior.is_select_only()
1021    }
1022
1023    fn branch_filter_trigger(&self) -> IconButton {
1024        IconButton::new("branch-filter", IconName::Filter)
1025            .icon_size(IconSize::Small)
1026            .toggle_state(self.branch_filter != BranchFilter::All)
1027            .when(self.branch_filter != BranchFilter::All, |this| {
1028                this.indicator(Indicator::dot().color(Color::Info))
1029            })
1030    }
1031
1032    fn branch_filter_tooltip(&self) -> impl Fn(&mut Window, &mut App) -> gpui::AnyView + 'static {
1033        let focus_handle = self.focus_handle.clone();
1034        move |_, cx| {
1035            Tooltip::for_action_in(
1036                "Filter Branches",
1037                &branch_picker::ToggleFilterMenu,
1038                &focus_handle,
1039                cx,
1040            )
1041        }
1042    }
1043
1044    fn is_force_delete_hovering_index(&self, index: usize) -> bool {
1045        self.modifiers.alt && self.hovered_delete_index == Some(index)
1046    }
1047
1048    fn create_branch(
1049        &self,
1050        from_branch: Option<SharedString>,
1051        new_branch_name: SharedString,
1052        window: &mut Window,
1053        cx: &mut Context<Picker<Self>>,
1054    ) {
1055        let Some(repo) = self.repo.clone() else {
1056            return;
1057        };
1058        let new_branch_name = normalize_branch_name(&new_branch_name);
1059        let base_branch = from_branch.map(|b| b.to_string());
1060        cx.spawn(async move |_, cx| {
1061            repo.update(cx, |repo, _| {
1062                repo.create_branch(new_branch_name, base_branch)
1063            })
1064            .await??;
1065
1066            Ok(())
1067        })
1068        .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| {
1069            Some(e.to_string())
1070        });
1071        cx.emit(DismissEvent);
1072    }
1073
1074    fn create_remote(
1075        &self,
1076        remote_name: String,
1077        remote_url: String,
1078        window: &mut Window,
1079        cx: &mut Context<Picker<Self>>,
1080    ) {
1081        let Some(repo) = self.repo.clone() else {
1082            return;
1083        };
1084
1085        let receiver = repo.update(cx, |repo, _| repo.create_remote(remote_name, remote_url));
1086
1087        cx.background_spawn(async move { receiver.await? })
1088            .detach_and_prompt_err("Failed to create remote", window, cx, |e, _, _cx| {
1089                Some(e.to_string())
1090            });
1091        cx.emit(DismissEvent);
1092    }
1093
1094    fn delete_at(
1095        &self,
1096        idx: usize,
1097        force: bool,
1098        window: &mut Window,
1099        cx: &mut Context<Picker<Self>>,
1100    ) {
1101        let Some(entry) = self.matches.get(idx).cloned() else {
1102            return;
1103        };
1104        let Some(repo) = self.repo.clone() else {
1105            return;
1106        };
1107
1108        let workspace = self.workspace.clone();
1109
1110        cx.spawn_in(window, async move |picker, cx| {
1111            let Entry::Branch { branch, .. } = &entry else {
1112                log::error!("Failed to delete entry: wrong entry to delete");
1113                return Ok(());
1114            };
1115
1116            if branch.is_head {
1117                return Ok(());
1118            }
1119
1120            let is_remote = branch.is_remote();
1121            let branch_name = branch.name().to_string();
1122            let initial_result = repo
1123                .update(cx, |repo, _| {
1124                    repo.delete_branch(is_remote, branch_name.clone(), force)
1125                })
1126                .await?;
1127
1128            let (result, attempted_force) = match initial_result {
1129                Ok(()) => (Ok(()), force),
1130                Err(error) => {
1131                    if is_remote {
1132                        log::error!("Failed to delete remote branch: {error}");
1133                    } else {
1134                        log::error!("Failed to delete branch: {error}");
1135                    }
1136
1137                    let force_delete_prompt = (!force)
1138                        .then(|| force_delete_prompt_for_branch_delete_error(&error, entry.name()))
1139                        .flatten();
1140
1141                    if let Some(prompt_message) = force_delete_prompt {
1142                        let answer = cx.update(|window, cx| {
1143                            window.prompt(
1144                                PromptLevel::Warning,
1145                                &prompt_message,
1146                                None,
1147                                &["Force Delete", "Cancel"],
1148                                cx,
1149                            )
1150                        })?;
1151
1152                        if answer.await != Ok(0) {
1153                            return Ok(());
1154                        }
1155
1156                        let retry = repo
1157                            .update(cx, |repo, _| {
1158                                repo.delete_branch(is_remote, branch_name, true)
1159                            })
1160                            .await?;
1161
1162                        if let Err(error) = &retry {
1163                            log::error!("Failed to force delete branch: {error}");
1164                        }
1165                        (retry, true)
1166                    } else {
1167                        (Err(error), force)
1168                    }
1169                }
1170            };
1171
1172            if let Err(error) = result {
1173                if let Some(workspace) = workspace.upgrade() {
1174                    cx.update(|_window, cx| {
1175                        show_error_toast(
1176                            workspace,
1177                            delete_branch_command(is_remote, entry.name(), attempted_force),
1178                            error,
1179                            cx,
1180                        )
1181                    })?;
1182                }
1183
1184                return Ok(());
1185            }
1186
1187            picker.update_in(cx, |picker, _, cx| {
1188                picker.delegate.matches.retain(|e| e != &entry);
1189
1190                if let Entry::Branch { branch, .. } = &entry {
1191                    picker
1192                        .delegate
1193                        .all_branches
1194                        .retain(|e| e.ref_name != branch.ref_name);
1195                }
1196
1197                if picker.delegate.matches.is_empty() {
1198                    picker.delegate.selected_index = 0;
1199                } else if picker.delegate.selected_index >= picker.delegate.matches.len() {
1200                    picker.delegate.selected_index = picker.delegate.matches.len() - 1;
1201                }
1202
1203                picker.delegate.hovered_delete_index = None;
1204
1205                cx.notify();
1206            })?;
1207
1208            anyhow::Ok(())
1209        })
1210        .detach();
1211    }
1212}
1213
1214fn remote_provider_icons(
1215    remote_urls: &HashMap<String, String>,
1216    cx: &App,
1217) -> HashMap<String, IconName> {
1218    let Some(provider_registry) = GitHostingProviderRegistry::try_global(cx) else {
1219        return HashMap::default();
1220    };
1221
1222    remote_urls
1223        .iter()
1224        .filter_map(|(remote_name, remote_url)| {
1225            let (provider, _) = parse_git_remote_url(provider_registry.clone(), remote_url)?;
1226            Some((
1227                remote_name.clone(),
1228                crate::get_provider_icon(&provider.name()),
1229            ))
1230        })
1231        .collect()
1232}
1233
1234impl PickerDelegate for BranchListDelegate {
1235    type ListItem = AnyElement;
1236
1237    fn name() -> &'static str {
1238        "branch picker"
1239    }
1240
1241    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
1242        match self.state {
1243            PickerState::List | PickerState::NewRemote | PickerState::NewBranch => {
1244                "Switch or type to create a branch…"
1245            }
1246            PickerState::CreateRemote(_) => "Enter a name for this remote…",
1247        }
1248        .into()
1249    }
1250
1251    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1252        match self.state {
1253            PickerState::CreateRemote(_) => {
1254                Some(SharedString::new_static("Remote name can't be empty"))
1255            }
1256            _ => None,
1257        }
1258    }
1259
1260    fn render_editor(
1261        &self,
1262        editor: &Arc<dyn ErasedEditor>,
1263        _window: &mut Window,
1264        _cx: &mut Context<Picker<Self>>,
1265    ) -> Option<Div> {
1266        let editor = editor.as_any().downcast_ref::<Entity<Editor>>().unwrap();
1267        let editor_start = matches!(self.editor_position(), PickerEditorPosition::Start);
1268        let editor_bottom = matches!(self.editor_position(), PickerEditorPosition::End);
1269
1270        let warning_banner = || {
1271            self.branch_list_error.as_deref().map(|error| {
1272                let message = format!("Some branches could not be loaded: {error}");
1273                div().p_1p5().child(
1274                    Banner::new()
1275                        .severity(Severity::Warning)
1276                        .child(div().min_w_0().flex_1().child(Label::new(message))),
1277                )
1278            })
1279        };
1280
1281        Some(
1282            v_flex()
1283                .w_full()
1284                .min_w_0()
1285                .when(editor_bottom, |this| {
1286                    this.child(Divider::horizontal())
1287                        .when_some(warning_banner(), |this, banner| this.child(banner))
1288                })
1289                .child(
1290                    h_flex()
1291                        .h_9()
1292                        .px_2p5()
1293                        .flex_none()
1294                        .overflow_hidden()
1295                        .child(editor.clone())
1296                        .map(|this| {
1297                            let branch_filter = self.branch_filter;
1298                            let focus_handle = self.focus_handle.clone();
1299
1300                            this.gap_1().justify_between().child(
1301                                PopoverMenu::new("branch-filter-menu")
1302                                    .with_handle(self.branch_filter_menu_handle.clone())
1303                                    .trigger_with_tooltip(
1304                                        self.branch_filter_trigger(),
1305                                        self.branch_filter_tooltip(),
1306                                    )
1307                                    .menu(move |window, cx| {
1308                                        Some(branch_filter_menu(
1309                                            branch_filter,
1310                                            focus_handle.clone(),
1311                                            window,
1312                                            cx,
1313                                        ))
1314                                    })
1315                                    .map(|this| {
1316                                        if editor_bottom {
1317                                            this.anchor(gpui::Anchor::BottomRight)
1318                                                .attach(gpui::Anchor::TopRight)
1319                                                .offset(gpui::Point {
1320                                                    x: px(0.0),
1321                                                    y: px(-1.0),
1322                                                })
1323                                        } else {
1324                                            this.anchor(gpui::Anchor::TopRight)
1325                                                .attach(gpui::Anchor::BottomRight)
1326                                                .offset(gpui::Point {
1327                                                    x: px(1.0),
1328                                                    y: px(1.0),
1329                                                })
1330                                        }
1331                                    }),
1332                            )
1333                        }),
1334                )
1335                .when(editor_start, |this| {
1336                    this.child(Divider::horizontal())
1337                        .when_some(warning_banner(), |this, banner| this.child(banner))
1338                }),
1339        )
1340    }
1341
1342    fn editor_position(&self) -> PickerEditorPosition {
1343        if self.is_select_only() {
1344            return PickerEditorPosition::Start;
1345        }
1346
1347        match self.style {
1348            BranchListStyle::Modal => PickerEditorPosition::Start,
1349            BranchListStyle::Popover => PickerEditorPosition::End,
1350        }
1351    }
1352
1353    fn render_header(
1354        &self,
1355        _window: &mut Window,
1356        _cx: &mut Context<Picker<Self>>,
1357    ) -> Option<AnyElement> {
1358        if self.branch_filter == BranchFilter::All
1359            || !self
1360                .matches
1361                .first()
1362                .is_some_and(|entry| entry.as_branch().is_some())
1363        {
1364            return None;
1365        }
1366
1367        Some(
1368            div()
1369                .pt_1p5()
1370                .mb_neg_0p5()
1371                .child(ListSubHeader::new(self.branch_filter.label()).inset(true))
1372                .into_any_element(),
1373        )
1374    }
1375
1376    fn has_another_open_menu(&self, window: &Window, cx: &App) -> bool {
1377        self.branch_filter_menu_handle.is_deployed()
1378            || self.branch_filter_menu_handle.is_focused(window, cx)
1379    }
1380
1381    fn match_count(&self) -> usize {
1382        self.matches.len()
1383    }
1384
1385    fn selected_index(&self) -> usize {
1386        self.selected_index
1387    }
1388
1389    fn set_selected_index(
1390        &mut self,
1391        ix: usize,
1392        _window: &mut Window,
1393        _: &mut Context<Picker<Self>>,
1394    ) {
1395        self.selected_index = ix;
1396    }
1397
1398    fn update_matches(
1399        &mut self,
1400        query: String,
1401        window: &mut Window,
1402        cx: &mut Context<Picker<Self>>,
1403    ) -> Task<()> {
1404        let all_branches = self.all_branches.clone();
1405        let branch_selection_context = self.is_select_only().then(|| {
1406            BranchSelectionContext::new(
1407                self.branch_selection_behavior.selected_branch().cloned(),
1408                self.repo.as_ref(),
1409                cx,
1410            )
1411        });
1412
1413        let branch_filter = self.branch_filter;
1414        cx.spawn_in(window, async move |picker, cx| {
1415            let branch_matches_filter = |branch: &Branch| match branch_filter {
1416                BranchFilter::All => true,
1417                BranchFilter::Local => !branch.is_remote(),
1418                BranchFilter::Remote => branch.is_remote(),
1419            };
1420
1421            let mut matches: Vec<Entry> = if query.is_empty() {
1422                let mut matches: Vec<Entry> = all_branches
1423                    .iter()
1424                    .filter(|branch| branch_matches_filter(branch))
1425                    .map(|branch| Entry::Branch {
1426                        branch: branch.clone(),
1427                        positions: Vec::new(),
1428                    })
1429                    .collect();
1430
1431                sort_branch_entries(&mut matches, branch_selection_context.as_ref());
1432
1433                matches
1434            } else {
1435                let branches = all_branches
1436                    .iter()
1437                    .filter(|branch| branch_matches_filter(branch))
1438                    .collect::<Vec<_>>();
1439                let candidates = branches
1440                    .iter()
1441                    .enumerate()
1442                    .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
1443                    .collect::<Vec<StringMatchCandidate>>();
1444                let mut matches: Vec<Entry> = fuzzy_nucleo::match_strings_async(
1445                    &candidates,
1446                    &query,
1447                    fuzzy_nucleo::Case::Smart,
1448                    fuzzy_nucleo::LengthPenalty::On,
1449                    10000,
1450                    &Default::default(),
1451                    cx.background_executor().clone(),
1452                )
1453                .await
1454                .into_iter()
1455                .map(|candidate| Entry::Branch {
1456                    branch: branches[candidate.candidate_id].clone(),
1457                    positions: candidate.positions,
1458                })
1459                .collect();
1460
1461                sort_branch_entries(&mut matches, branch_selection_context.as_ref());
1462
1463                matches
1464            };
1465            picker
1466                .update(cx, |picker, _| {
1467                    if let PickerState::CreateRemote(url) = &picker.delegate.state {
1468                        let query = normalize_branch_name(&query);
1469                        if !query.is_empty() {
1470                            picker.delegate.matches = vec![Entry::NewRemoteName {
1471                                name: query.clone(),
1472                                url: url.clone(),
1473                            }];
1474                            picker.delegate.selected_index = 0;
1475                        } else {
1476                            picker.delegate.matches = Vec::new();
1477                            picker.delegate.selected_index = 0;
1478                        }
1479                        picker.delegate.last_query = query;
1480                        return;
1481                    }
1482
1483                    if !picker.delegate.is_select_only()
1484                        && !query.is_empty()
1485                        && !all_branches.iter().any(|branch| branch.name() == query)
1486                    {
1487                        let query = normalize_branch_name(&query);
1488                        let is_url = query.trim_start_matches("git@").parse::<Url>().is_ok();
1489                        let entry = if is_url {
1490                            Entry::NewUrl { url: query }
1491                        } else {
1492                            Entry::NewBranch { name: query }
1493                        };
1494                        // Only transition to NewBranch/NewRemote states when we only show their list item
1495                        // Otherwise, stay in List state so footer buttons remain visible
1496                        picker.delegate.state = if matches.is_empty() {
1497                            if is_url {
1498                                PickerState::NewRemote
1499                            } else {
1500                                PickerState::NewBranch
1501                            }
1502                        } else {
1503                            PickerState::List
1504                        };
1505                        matches.push(entry);
1506                    } else {
1507                        picker.delegate.state = PickerState::List;
1508                    }
1509                    let delegate = &mut picker.delegate;
1510                    delegate.matches = matches;
1511                    if delegate.matches.is_empty() {
1512                        delegate.selected_index = 0;
1513                    } else if let Some(ref_name) = delegate.restore_selected_branch.take() {
1514                        delegate.selected_index = delegate
1515                            .matches
1516                            .iter()
1517                            .position(|entry| {
1518                                entry.as_branch().is_some_and(|branch| {
1519                                    branch.ref_name == ref_name
1520                                        || branch.name() == ref_name.as_ref()
1521                                })
1522                            })
1523                            .unwrap_or(0);
1524                    } else {
1525                        delegate.selected_index =
1526                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
1527                    }
1528                    delegate.last_query = query;
1529                })
1530                .log_err();
1531        })
1532    }
1533
1534    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
1535        let Some(entry) = self.matches.get(self.selected_index()) else {
1536            return;
1537        };
1538
1539        match entry {
1540            Entry::Branch { branch, .. } => {
1541                if let BranchSelectionBehavior::Select { on_select, .. } =
1542                    &self.branch_selection_behavior
1543                {
1544                    on_select(branch.clone(), window, cx);
1545                    cx.emit(DismissEvent);
1546                    return;
1547                }
1548
1549                let current_branch = self.repo.as_ref().map(|repo| {
1550                    repo.read_with(cx, |repo, _| {
1551                        repo.branch.as_ref().map(|branch| branch.ref_name.clone())
1552                    })
1553                });
1554
1555                if current_branch
1556                    .flatten()
1557                    .is_some_and(|current_branch| current_branch == branch.ref_name)
1558                {
1559                    cx.emit(DismissEvent);
1560                    return;
1561                }
1562
1563                let Some(repo) = self.repo.clone() else {
1564                    return;
1565                };
1566
1567                let branch = branch.clone();
1568                cx.spawn(async move |_, cx| {
1569                    repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))
1570                        .await??;
1571
1572                    anyhow::Ok(())
1573                })
1574                .detach_and_prompt_err(
1575                    "Failed to change branch",
1576                    window,
1577                    cx,
1578                    |_, _, _| None,
1579                );
1580            }
1581            Entry::NewUrl { url } => {
1582                self.state = PickerState::CreateRemote(url.clone().into());
1583                self.matches = Vec::new();
1584                self.selected_index = 0;
1585
1586                cx.defer_in(window, |picker, window, cx| {
1587                    picker.refresh_placeholder(window, cx);
1588                    picker.set_query("", window, cx);
1589                    cx.notify();
1590                });
1591
1592                // returning early to prevent dismissing the modal, so a user can enter
1593                // a remote name first.
1594                return;
1595            }
1596            Entry::NewRemoteName { name, url } => {
1597                self.create_remote(name.clone(), url.to_string(), window, cx);
1598            }
1599            Entry::NewBranch { name } => {
1600                let from_branch = if secondary {
1601                    self.default_branch.clone()
1602                } else {
1603                    None
1604                };
1605                self.create_branch(from_branch, name.into(), window, cx);
1606            }
1607        }
1608
1609        cx.emit(DismissEvent);
1610    }
1611
1612    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
1613        self.state = PickerState::List;
1614        cx.emit(DismissEvent);
1615    }
1616
1617    fn render_match(
1618        &self,
1619        ix: usize,
1620        selected: bool,
1621        _window: &mut Window,
1622        cx: &mut Context<Picker<Self>>,
1623    ) -> Option<Self::ListItem> {
1624        let entry = &self.matches.get(ix)?;
1625
1626        let (commit_time, absolute_time, author_name, subject) = entry
1627            .as_branch()
1628            .and_then(|branch| {
1629                branch.most_recent_commit.as_ref().map(|commit| {
1630                    let subject = commit.subject.clone();
1631                    let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
1632                        .unwrap_or_else(|_| OffsetDateTime::now_utc());
1633                    let local_offset =
1634                        time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
1635                    let formatted_time = time_format::format_localized_timestamp(
1636                        commit_time,
1637                        OffsetDateTime::now_utc(),
1638                        local_offset,
1639                        time_format::TimestampFormat::Relative,
1640                    );
1641                    let absolute_time = time_format::format_localized_timestamp(
1642                        commit_time,
1643                        OffsetDateTime::now_utc(),
1644                        local_offset,
1645                        time_format::TimestampFormat::EnhancedAbsolute,
1646                    );
1647                    let author = commit.author_name.clone();
1648                    (
1649                        Some(formatted_time),
1650                        Some(absolute_time),
1651                        Some(author),
1652                        Some(subject),
1653                    )
1654                })
1655            })
1656            .unwrap_or_else(|| (None, None, None, None));
1657
1658        let is_head_branch = entry.as_branch().is_some_and(|branch| branch.is_head);
1659        let is_checked_branch = entry.as_branch().is_some_and(|branch| {
1660            if self.is_select_only() {
1661                self.branch_selection_behavior
1662                    .selected_branch()
1663                    .is_some_and(|selected_branch| branch_matches_ref(branch, selected_branch))
1664            } else {
1665                branch.is_head
1666            }
1667        });
1668
1669        let entry_icon = match entry {
1670            Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => {
1671                IconName::Plus
1672            }
1673            Entry::Branch { branch, .. } => {
1674                if is_checked_branch {
1675                    IconName::Check
1676                } else if branch.is_remote() {
1677                    branch
1678                        .remote_name()
1679                        .and_then(|remote_name| self.remote_provider_icons.get(remote_name))
1680                        .copied()
1681                        .unwrap_or(IconName::Server)
1682                } else {
1683                    IconName::GitBranch
1684                }
1685            }
1686        };
1687
1688        let entry_title = match entry {
1689            Entry::NewUrl { .. } => Label::new("Create Remote Repository")
1690                .single_line()
1691                .truncate()
1692                .into_any_element(),
1693            Entry::NewBranch { name } => Label::new(format!("Create Branch: \"{name}\"…"))
1694                .single_line()
1695                .truncate()
1696                .into_any_element(),
1697            Entry::NewRemoteName { name, .. } => Label::new(format!("Create Remote: \"{name}\""))
1698                .single_line()
1699                .truncate()
1700                .into_any_element(),
1701            Entry::Branch { branch, positions } => {
1702                HighlightedLabel::new(branch.name().to_string(), positions.clone())
1703                    .single_line()
1704                    .truncate()
1705                    .into_any_element()
1706            }
1707        };
1708
1709        let focus_handle = self.focus_handle.clone();
1710        let picker = cx.entity();
1711        let is_new_items = matches!(
1712            entry,
1713            Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. }
1714        );
1715
1716        let deleted_branch_icon = |entry_ix: usize| {
1717            let picker = picker.clone();
1718            let focus_handle = focus_handle.clone();
1719            let force_delete = self.is_force_delete_hovering_index(entry_ix);
1720
1721            div()
1722                .id(("delete-hover", entry_ix))
1723                .on_hover(cx.listener(move |this, hovered: &bool, _, cx| {
1724                    if *hovered {
1725                        this.delegate.hovered_delete_index = Some(entry_ix);
1726                    } else if this.delegate.hovered_delete_index == Some(entry_ix) {
1727                        this.delegate.hovered_delete_index = None;
1728                    }
1729                    cx.notify();
1730                }))
1731                .child(
1732                    IconButton::new(("delete", entry_ix), IconName::Trash)
1733                        .icon_size(IconSize::Small)
1734                        .when(force_delete, |this| this.icon_color(Color::Error))
1735                        .tooltip(move |_, cx| {
1736                            cx.new(|cx| {
1737                                DeleteBranchTooltip::new(
1738                                    picker.clone(),
1739                                    focus_handle.clone(),
1740                                    entry_ix,
1741                                    cx,
1742                                )
1743                            })
1744                            .into()
1745                        })
1746                        .on_click(cx.listener(move |this, _, window, cx| {
1747                            this.delegate.delete_at(
1748                                entry_ix,
1749                                this.delegate.modifiers.alt,
1750                                window,
1751                                cx,
1752                            );
1753                        })),
1754                )
1755        };
1756
1757        let create_from_default_button = self.default_branch.as_ref().map(|default_branch| {
1758            let tooltip_label: SharedString = format!("Create New From: {default_branch}").into();
1759            let focus_handle = self.focus_handle.clone();
1760
1761            IconButton::new("create_from_default", IconName::GitBranchPlus)
1762                .icon_size(IconSize::Small)
1763                .tooltip(move |_, cx| {
1764                    Tooltip::for_action_in(
1765                        tooltip_label.clone(),
1766                        &menu::SecondaryConfirm,
1767                        &focus_handle,
1768                        cx,
1769                    )
1770                })
1771                .on_click(cx.listener(|this, _, window, cx| {
1772                    this.delegate.confirm(true, window, cx);
1773                }))
1774                .into_any_element()
1775        });
1776
1777        let list_item = ListItem::new(format!("vcs-menu-{ix}"))
1778            .inset(true)
1779            .spacing(ListItemSpacing::Sparse)
1780            .toggle_state(selected)
1781            .child(
1782                h_flex()
1783                    .w_full()
1784                    .min_w_0()
1785                    .gap_2p5()
1786                    .flex_grow_1()
1787                    .child(
1788                        Icon::new(entry_icon)
1789                            .color(if is_checked_branch {
1790                                Color::Accent
1791                            } else {
1792                                Color::Muted
1793                            })
1794                            .size(IconSize::Small),
1795                    )
1796                    .child(
1797                        v_flex()
1798                            .id("info_container")
1799                            .w_full()
1800                            .min_w_0()
1801                            .child(entry_title)
1802                            .child({
1803                                let message = match entry {
1804                                    Entry::NewUrl { url } => format!("Based off {url}"),
1805                                    Entry::NewRemoteName { url, .. } => {
1806                                        format!("Based off {url}")
1807                                    }
1808                                    Entry::NewBranch { .. } => {
1809                                        if let Some(current_branch) =
1810                                            self.repo.as_ref().and_then(|repo| {
1811                                                repo.read(cx).branch.as_ref().map(|b| b.name())
1812                                            })
1813                                        {
1814                                            format!("Based off {}", current_branch)
1815                                        } else {
1816                                            "Based off the current branch".to_string()
1817                                        }
1818                                    }
1819                                    Entry::Branch { .. } => String::new(),
1820                                };
1821
1822                                if matches!(entry, Entry::Branch { .. }) {
1823                                    let show_author_name = ProjectSettings::get_global(cx)
1824                                        .git
1825                                        .branch_picker
1826                                        .show_author_name;
1827                                    let has_author = show_author_name && author_name.is_some();
1828                                    let has_commit = commit_time.is_some();
1829                                    let author_for_meta =
1830                                        if show_author_name { author_name } else { None };
1831
1832                                    let dot = || {
1833                                        Label::new("•")
1834                                            .alpha(0.5)
1835                                            .color(Color::Muted)
1836                                            .size(LabelSize::Small)
1837                                    };
1838
1839                                    h_flex()
1840                                        .w_full()
1841                                        .min_w_0()
1842                                        .gap_1p5()
1843                                        .when_some(author_for_meta, |this, author| {
1844                                            this.child(
1845                                                Label::new(author)
1846                                                    .color(Color::Muted)
1847                                                    .size(LabelSize::Small),
1848                                            )
1849                                        })
1850                                        .when_some(commit_time, |this, time| {
1851                                            this.when(has_author, |this| this.child(dot())).child(
1852                                                Label::new(time)
1853                                                    .color(Color::Muted)
1854                                                    .size(LabelSize::Small),
1855                                            )
1856                                        })
1857                                        .when_some(subject, |this, subj| {
1858                                            this.when(has_commit, |this| this.child(dot())).child(
1859                                                Label::new(subj.to_string())
1860                                                    .color(Color::Muted)
1861                                                    .size(LabelSize::Small)
1862                                                    .truncate()
1863                                                    .flex_1(),
1864                                            )
1865                                        })
1866                                        .when(!has_commit, |this| {
1867                                            this.child(
1868                                                Label::new("No commits found")
1869                                                    .color(Color::Muted)
1870                                                    .size(LabelSize::Small),
1871                                            )
1872                                        })
1873                                        .into_any_element()
1874                                } else {
1875                                    Label::new(message)
1876                                        .size(LabelSize::Small)
1877                                        .color(Color::Muted)
1878                                        .truncate()
1879                                        .into_any_element()
1880                                }
1881                            })
1882                            .when_some(
1883                                entry.as_branch().map(|b| b.name().to_string()),
1884                                |this, branch_name| {
1885                                    let absolute_time = absolute_time.clone();
1886                                    this.tooltip({
1887                                        let is_head = is_head_branch;
1888                                        let is_checked = is_checked_branch;
1889                                        let is_select_only = self.is_select_only();
1890                                        Tooltip::element(move |_, _| {
1891                                            v_flex()
1892                                                .child(Label::new(branch_name.clone()))
1893                                                .when(is_select_only && is_checked, |this| {
1894                                                    this.child(
1895                                                        Label::new("Selected Branch")
1896                                                            .size(LabelSize::Small)
1897                                                            .color(Color::Muted),
1898                                                    )
1899                                                })
1900                                                .when(is_head, |this| {
1901                                                    this.child(
1902                                                        Label::new("Current Branch")
1903                                                            .size(LabelSize::Small)
1904                                                            .color(Color::Muted),
1905                                                    )
1906                                                })
1907                                                .when_some(absolute_time.clone(), |this, time| {
1908                                                    this.child(
1909                                                        Label::new(time)
1910                                                            .size(LabelSize::Small)
1911                                                            .color(Color::Muted),
1912                                                    )
1913                                                })
1914                                                .into_any_element()
1915                                        })
1916                                    })
1917                                },
1918                            ),
1919                    ),
1920            )
1921            .when(
1922                !self.is_select_only() && !is_new_items && !is_head_branch,
1923                |this| {
1924                    this.end_slot(deleted_branch_icon(ix))
1925                        .show_end_slot_on_hover()
1926                },
1927            )
1928            .when_some(
1929                if is_new_items {
1930                    create_from_default_button
1931                } else {
1932                    None
1933                },
1934                |this, create_from_default_button| {
1935                    this.end_slot(create_from_default_button)
1936                        .show_end_slot_on_hover()
1937                },
1938            );
1939
1940        let section_header = (self.branch_filter == BranchFilter::All)
1941            .then(|| entry.as_branch())
1942            .flatten()
1943            .and_then(|branch| {
1944                let starts_section = ix == 0
1945                    || self.matches[ix - 1]
1946                        .as_branch()
1947                        .is_none_or(|previous_branch| {
1948                            previous_branch.is_remote() != branch.is_remote()
1949                        });
1950                starts_section.then(|| {
1951                    if branch.is_remote() {
1952                        ("Remote Branches", ix != 0)
1953                    } else {
1954                        ("Local Branches", false)
1955                    }
1956                })
1957            });
1958
1959        Some(
1960            v_flex()
1961                .w_full()
1962                .when_some(section_header, |this, (section_header, show_divider)| {
1963                    this.pt_1p5()
1964                        .when(show_divider, |this| {
1965                            this.mt_1()
1966                                .border_t_1()
1967                                .border_color(cx.theme().colors().border_variant)
1968                        })
1969                        .child(ListSubHeader::new(section_header).inset(true))
1970                })
1971                .child(list_item)
1972                .into_any_element(),
1973        )
1974    }
1975
1976    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1977        if self.is_select_only()
1978            || !self.show_footer
1979            || self.editor_position() == PickerEditorPosition::End
1980        {
1981            return None;
1982        }
1983        let focus_handle = self.focus_handle.clone();
1984
1985        let footer_container = || {
1986            h_flex()
1987                .w_full()
1988                .p_1p5()
1989                .border_t_1()
1990                .border_color(cx.theme().colors().border_variant)
1991        };
1992
1993        match self.state {
1994            PickerState::List => {
1995                let selected_entry = self.matches.get(self.selected_index);
1996
1997                let branch_from_default_button = self
1998                    .default_branch
1999                    .as_ref()
2000                    .filter(|_| matches!(selected_entry, Some(Entry::NewBranch { .. })))
2001                    .map(|default_branch| {
2002                        let button_label = format!("Create New From: {default_branch}");
2003
2004                        Button::new("branch-from-default", button_label)
2005                            .key_binding(
2006                                KeyBinding::for_action_in(
2007                                    &menu::SecondaryConfirm,
2008                                    &focus_handle,
2009                                    cx,
2010                                )
2011                                .map(|kb| kb.size(rems_from_px(12.))),
2012                            )
2013                            .on_click(cx.listener(|this, _, window, cx| {
2014                                this.delegate.confirm(true, window, cx);
2015                            }))
2016                    });
2017
2018                let delete_and_select_btns = h_flex()
2019                    .gap_1()
2020                    .when(
2021                        !selected_entry
2022                            .and_then(|entry| entry.as_branch())
2023                            .is_some_and(|branch| branch.is_head),
2024                        |this| {
2025                            this.child(
2026                                Button::new("delete-branch", "Delete")
2027                                    .key_binding(
2028                                        KeyBinding::for_action_in(
2029                                            &branch_picker::DeleteBranch,
2030                                            &focus_handle,
2031                                            cx,
2032                                        )
2033                                        .map(|kb| kb.size(rems_from_px(12.))),
2034                                    )
2035                                    .on_click(|_, window, cx| {
2036                                        window.dispatch_action(
2037                                            branch_picker::DeleteBranch.boxed_clone(),
2038                                            cx,
2039                                        );
2040                                    }),
2041                            )
2042                        },
2043                    )
2044                    .child(
2045                        Button::new("switch_branch", "Switch")
2046                            .key_binding(
2047                                KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
2048                                    .map(|kb| kb.size(rems_from_px(12.))),
2049                            )
2050                            .on_click(cx.listener(|this, _, window, cx| {
2051                                this.delegate.confirm(false, window, cx);
2052                            })),
2053                    );
2054
2055                Some(
2056                    footer_container()
2057                        .justify_end()
2058                        .map(|this| match branch_from_default_button {
2059                            Some(button) => this.child(button).child(
2060                                Button::new("create", "Create")
2061                                    .key_binding(
2062                                        KeyBinding::for_action_in(
2063                                            &menu::Confirm,
2064                                            &focus_handle,
2065                                            cx,
2066                                        )
2067                                        .map(|kb| kb.size(rems_from_px(12.))),
2068                                    )
2069                                    .on_click(cx.listener(|this, _, window, cx| {
2070                                        this.delegate.confirm(false, window, cx);
2071                                    })),
2072                            ),
2073                            None => this.child(delete_and_select_btns),
2074                        })
2075                        .into_any_element(),
2076                )
2077            }
2078            PickerState::NewBranch => {
2079                let branch_from_default_button =
2080                    self.default_branch.as_ref().map(|default_branch| {
2081                        let button_label = format!("Create New From: {default_branch}");
2082
2083                        Button::new("branch-from-default", button_label)
2084                            .key_binding(
2085                                KeyBinding::for_action_in(
2086                                    &menu::SecondaryConfirm,
2087                                    &focus_handle,
2088                                    cx,
2089                                )
2090                                .map(|kb| kb.size(rems_from_px(12.))),
2091                            )
2092                            .on_click(cx.listener(|this, _, window, cx| {
2093                                this.delegate.confirm(true, window, cx);
2094                            }))
2095                    });
2096
2097                Some(
2098                    footer_container()
2099                        .gap_1()
2100                        .justify_end()
2101                        .when_some(branch_from_default_button, |this, button| {
2102                            this.child(button)
2103                        })
2104                        .child(
2105                            Button::new("create-new-branch", "Create")
2106                                .key_binding(
2107                                    KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
2108                                        .map(|kb| kb.size(rems_from_px(12.))),
2109                                )
2110                                .on_click(cx.listener(|this, _, window, cx| {
2111                                    this.delegate.confirm(false, window, cx);
2112                                })),
2113                        )
2114                        .into_any_element(),
2115                )
2116            }
2117            PickerState::CreateRemote(_) => Some(
2118                footer_container()
2119                    .justify_end()
2120                    .child(
2121                        Button::new("confirm-create-remote", "Confirm")
2122                            .key_binding(
2123                                KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
2124                                    .map(|kb| kb.size(rems_from_px(12.))),
2125                            )
2126                            .on_click(cx.listener(|this, _, window, cx| {
2127                                this.delegate.confirm(false, window, cx);
2128                            }))
2129                            .disabled(self.last_query.is_empty()),
2130                    )
2131                    .into_any_element(),
2132            ),
2133            PickerState::NewRemote => None,
2134        }
2135    }
2136}
2137
2138#[cfg(test)]
2139mod tests {
2140    use std::collections::HashSet;
2141
2142    use super::*;
2143    use git::repository::{
2144        CommitSummary, Remote, Upstream, UpstreamTracking, UpstreamTrackingStatus,
2145    };
2146    use gpui::{AppContext, TestAppContext, VisualTestContext};
2147    use project::{FakeFs, Project};
2148    use rand::{Rng, rngs::StdRng};
2149    use serde_json::json;
2150    use settings::SettingsStore;
2151    use util::path;
2152    use workspace::MultiWorkspace;
2153
2154    fn init_test(cx: &mut TestAppContext) {
2155        cx.update(|cx| {
2156            let settings_store = SettingsStore::test(cx);
2157            cx.set_global(settings_store);
2158            theme_settings::init(theme::LoadThemes::JustBase, cx);
2159            editor::init(cx);
2160        });
2161    }
2162
2163    fn create_test_branch(
2164        name: &str,
2165        is_head: bool,
2166        remote_name: Option<&str>,
2167        timestamp: Option<i64>,
2168    ) -> Branch {
2169        create_test_branch_with_upstream(name, is_head, remote_name, timestamp, None)
2170    }
2171
2172    fn create_test_branch_with_upstream(
2173        name: &str,
2174        is_head: bool,
2175        remote_name: Option<&str>,
2176        timestamp: Option<i64>,
2177        upstream_ref_name: Option<&str>,
2178    ) -> Branch {
2179        let ref_name = match remote_name {
2180            Some(remote_name) => format!("refs/remotes/{remote_name}/{name}"),
2181            None => format!("refs/heads/{name}"),
2182        };
2183
2184        Branch {
2185            is_head,
2186            ref_name: ref_name.into(),
2187            upstream: upstream_ref_name.map(|ref_name| Upstream {
2188                ref_name: ref_name.into(),
2189                tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
2190                    ahead: 0,
2191                    behind: 0,
2192                }),
2193            }),
2194            most_recent_commit: timestamp.map(|ts| CommitSummary {
2195                sha: "abc123".into(),
2196                commit_timestamp: ts,
2197                author_name: "Test Author".into(),
2198                subject: "Test commit".into(),
2199                has_parent: true,
2200            }),
2201        }
2202    }
2203
2204    fn create_test_branches() -> Vec<Branch> {
2205        vec![
2206            create_test_branch("main", true, None, Some(1000)),
2207            create_test_branch("feature-auth", false, None, Some(900)),
2208            create_test_branch("feature-ui", false, None, Some(800)),
2209            create_test_branch("develop", false, None, Some(700)),
2210        ]
2211    }
2212
2213    #[test]
2214    fn test_select_branch_shows_tracked_remotes_and_prioritizes_active_remote_branches() {
2215        let selected_branch = SharedString::from("origin/main");
2216        let branches: Arc<[Branch]> = Arc::from([
2217            create_test_branch_with_upstream(
2218                "feature",
2219                true,
2220                None,
2221                Some(1200),
2222                Some("refs/remotes/origin/feature"),
2223            ),
2224            create_test_branch_with_upstream(
2225                "main",
2226                false,
2227                None,
2228                Some(1100),
2229                Some("refs/remotes/origin/main"),
2230            ),
2231            create_test_branch("main", false, Some("origin"), Some(1000)),
2232            create_test_branch("feature", false, Some("origin"), Some(900)),
2233            create_test_branch("topic", false, Some("origin"), Some(850)),
2234            create_test_branch("main", false, Some("fork"), Some(800)),
2235        ]);
2236
2237        let checkout_branches = process_branches(&branches, true);
2238        assert!(
2239            checkout_branches
2240                .iter()
2241                .all(|branch| branch.name() != "origin/main" && branch.name() != "origin/feature"),
2242            "remote branches tracked by a local branch should be collapsed when checking out"
2243        );
2244
2245        let processed_branches = process_branches(&branches, false);
2246        assert_eq!(
2247            processed_branches.len(),
2248            branches.len(),
2249            "no branches should be filtered out when selecting a branch"
2250        );
2251        assert!(
2252            processed_branches
2253                .iter()
2254                .any(|branch| branch.name() == "origin/main"),
2255            "remote branches should be selectable even when a local branch tracks them"
2256        );
2257
2258        let mut entries = processed_branches
2259            .into_iter()
2260            .map(|branch| Entry::Branch {
2261                branch,
2262                positions: Vec::new(),
2263            })
2264            .collect::<Vec<_>>();
2265        let selection_context = BranchSelectionContext {
2266            selected_branch: Some(selected_branch),
2267            active_branch_ref_name: Some("refs/heads/feature".into()),
2268            active_branch_upstream_ref_name: Some("refs/remotes/origin/feature".into()),
2269            active_branch_remote_name: Some("origin".into()),
2270        };
2271
2272        sort_branch_entries(&mut entries, Some(&selection_context));
2273
2274        let ordered_branch_names = entries.iter().map(Entry::name).collect::<Vec<_>>();
2275        assert_eq!(ordered_branch_names.first(), Some(&"origin/main"));
2276        assert!(
2277            entries
2278                .windows(2)
2279                .filter(|entries| {
2280                    entries[0].as_branch().map(Branch::is_remote)
2281                        != entries[1].as_branch().map(Branch::is_remote)
2282                })
2283                .count()
2284                <= 1,
2285            "local and remote branches should each form a contiguous section"
2286        );
2287        assert!(
2288            ordered_branch_names
2289                .iter()
2290                .position(|name| *name == "origin/topic")
2291                < ordered_branch_names
2292                    .iter()
2293                    .position(|name| *name == "fork/main"),
2294            "branches on the active branch's remote should be prioritized within their section"
2295        );
2296    }
2297
2298    async fn init_branch_list_test(
2299        repository: Option<Entity<Repository>>,
2300        branches: Vec<Branch>,
2301        cx: &mut TestAppContext,
2302    ) -> (Entity<BranchList>, VisualTestContext) {
2303        let fs = FakeFs::new(cx.executor());
2304        let project = Project::test(fs, [], cx).await;
2305
2306        let window_handle =
2307            cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
2308        let workspace = window_handle
2309            .read_with(cx, |mw, _| mw.workspace().clone())
2310            .unwrap();
2311
2312        let branch_list = window_handle
2313            .update(cx, |_multi_workspace, window, cx| {
2314                cx.new(|cx| {
2315                    let mut delegate = BranchListDelegate::new(
2316                        workspace.downgrade(),
2317                        repository,
2318                        BranchListStyle::Modal,
2319                        BranchSelectionBehavior::Checkout,
2320                        cx,
2321                    );
2322                    delegate.all_branches = branches;
2323                    let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
2324                    let picker_focus_handle = picker.focus_handle(cx);
2325                    picker.update(cx, |picker, _| {
2326                        picker.delegate.focus_handle = picker_focus_handle.clone();
2327                    });
2328
2329                    let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
2330                        cx.emit(DismissEvent);
2331                    });
2332
2333                    BranchList {
2334                        picker,
2335                        picker_focus_handle,
2336                        _subscriptions: vec![_subscription],
2337                        embedded: false,
2338                    }
2339                })
2340            })
2341            .unwrap();
2342
2343        let cx = VisualTestContext::from_window(window_handle.into(), cx);
2344
2345        (branch_list, cx)
2346    }
2347
2348    async fn init_fake_repository_with_fs(
2349        cx: &mut TestAppContext,
2350    ) -> (Arc<FakeFs>, Entity<Project>, Entity<Repository>) {
2351        let fs = FakeFs::new(cx.executor());
2352        fs.insert_tree(
2353            path!("/dir"),
2354            json!({
2355                ".git": {},
2356                "file.txt": "buffer_text".to_string()
2357            }),
2358        )
2359        .await;
2360        fs.set_head_for_repo(
2361            path!("/dir/.git").as_ref(),
2362            &[("file.txt", "test".to_string())],
2363            "deadbeef",
2364        );
2365        fs.set_index_for_repo(
2366            path!("/dir/.git").as_ref(),
2367            &[("file.txt", "index_text".to_string())],
2368        );
2369
2370        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2371        let repository = cx.read(|cx| project.read(cx).active_repository(cx));
2372
2373        (fs, project, repository.unwrap())
2374    }
2375
2376    async fn init_fake_repository(
2377        cx: &mut TestAppContext,
2378    ) -> (Entity<Project>, Entity<Repository>) {
2379        let (_, project, repository) = init_fake_repository_with_fs(cx).await;
2380        (project, repository)
2381    }
2382
2383    #[gpui::test]
2384    async fn test_update_branch_matches_with_query(cx: &mut TestAppContext) {
2385        init_test(cx);
2386
2387        let branches = create_test_branches();
2388        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
2389        let cx = &mut ctx;
2390
2391        branch_list
2392            .update_in(cx, |branch_list, window, cx| {
2393                let query = "feature".to_string();
2394                branch_list.picker.update(cx, |picker, cx| {
2395                    picker.delegate.update_matches(query, window, cx)
2396                })
2397            })
2398            .await;
2399        cx.run_until_parked();
2400
2401        branch_list.update(cx, |branch_list, cx| {
2402            branch_list.picker.update(cx, |picker, _cx| {
2403                // Should have 2 existing branches + 1 "create new branch" entry = 3 total
2404                assert_eq!(picker.delegate.matches.len(), 3);
2405                assert!(
2406                    picker
2407                        .delegate
2408                        .matches
2409                        .iter()
2410                        .any(|m| m.name() == "feature-auth")
2411                );
2412                assert!(
2413                    picker
2414                        .delegate
2415                        .matches
2416                        .iter()
2417                        .any(|m| m.name() == "feature-ui")
2418                );
2419                // Verify the last entry is the "create new branch" option
2420                let last_match = picker.delegate.matches.last().unwrap();
2421                assert!(last_match.is_new_branch());
2422            })
2423        });
2424
2425        for branch_filter in [BranchFilter::Local, BranchFilter::Remote] {
2426            branch_list
2427                .update_in(cx, |branch_list, window, cx| {
2428                    branch_list.picker.update(cx, |picker, cx| {
2429                        picker.delegate.branch_filter = branch_filter;
2430                        picker
2431                            .delegate
2432                            .update_matches(String::from("missing-branch"), window, cx)
2433                    })
2434                })
2435                .await;
2436            cx.run_until_parked();
2437
2438            branch_list.update_in(cx, |branch_list, window, cx| {
2439                branch_list.picker.update(cx, |picker, cx| {
2440                    assert!(
2441                        picker.delegate.render_header(window, cx).is_none(),
2442                        "create-only results should not be shown under a branch section header"
2443                    );
2444                    assert!(matches!(
2445                        picker.delegate.matches.as_slice(),
2446                        [Entry::NewBranch { .. }]
2447                    ));
2448                })
2449            });
2450        }
2451
2452        branch_list
2453            .update_in(cx, |branch_list, window, cx| {
2454                branch_list.picker.update(cx, |picker, cx| {
2455                    picker.delegate.branch_filter = BranchFilter::Remote;
2456                    picker
2457                        .delegate
2458                        .update_matches(String::from("feature-ui"), window, cx)
2459                })
2460            })
2461            .await;
2462        cx.run_until_parked();
2463
2464        branch_list.update(cx, |branch_list, cx| {
2465            branch_list.picker.update(cx, |picker, _cx| {
2466                assert!(
2467                    picker.delegate.matches.is_empty(),
2468                    "an exact branch hidden by the active filter should not be offered for creation"
2469                );
2470            })
2471        });
2472
2473        branch_list
2474            .update_in(cx, |branch_list, window, cx| {
2475                branch_list.picker.update(cx, |picker, cx| {
2476                    picker
2477                        .delegate
2478                        .update_matches(String::from("feature-u"), window, cx)
2479                })
2480            })
2481            .await;
2482        cx.run_until_parked();
2483
2484        branch_list.update(cx, |branch_list, cx| {
2485            branch_list.picker.update(cx, |picker, _cx| {
2486                assert!(matches!(
2487                    picker.delegate.matches.as_slice(),
2488                    [Entry::NewBranch { .. }]
2489                ));
2490            })
2491        });
2492    }
2493
2494    async fn update_branch_list_matches_with_empty_query(
2495        branch_list: &Entity<BranchList>,
2496        cx: &mut VisualTestContext,
2497    ) {
2498        branch_list
2499            .update_in(cx, |branch_list, window, cx| {
2500                branch_list.picker.update(cx, |picker, cx| {
2501                    picker.delegate.update_matches(String::new(), window, cx)
2502                })
2503            })
2504            .await;
2505        cx.run_until_parked();
2506    }
2507
2508    #[gpui::test]
2509    async fn test_delete_branch(cx: &mut TestAppContext) {
2510        init_test(cx);
2511        let (_project, repository) = init_fake_repository(cx).await;
2512
2513        let branches = create_test_branches();
2514
2515        let branch_names = branches
2516            .iter()
2517            .map(|branch| branch.name().to_string())
2518            .collect::<Vec<String>>();
2519        let repo = repository.clone();
2520        cx.spawn(async move |mut cx| {
2521            for branch in branch_names {
2522                repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
2523                    .await
2524                    .unwrap()
2525                    .unwrap();
2526            }
2527        })
2528        .await;
2529        cx.run_until_parked();
2530
2531        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
2532        let cx = &mut ctx;
2533
2534        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
2535
2536        let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| {
2537            branch_list.picker.update(cx, |picker, cx| {
2538                assert_eq!(picker.delegate.matches.len(), 4);
2539                let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
2540                picker.delegate.delete_at(1, false, window, cx);
2541                branch_to_delete
2542            })
2543        });
2544        cx.run_until_parked();
2545
2546        let expected_branches = ["main", "feature-auth", "feature-ui", "develop"]
2547            .into_iter()
2548            .filter(|name| name != &branch_to_delete)
2549            .collect::<HashSet<_>>();
2550        let repo_branches = branch_list
2551            .update(cx, |branch_list, cx| {
2552                branch_list.picker.update(cx, |picker, cx| {
2553                    picker
2554                        .delegate
2555                        .repo
2556                        .as_ref()
2557                        .unwrap()
2558                        .update(cx, |repo, _cx| repo.branches())
2559                })
2560            })
2561            .await
2562            .unwrap()
2563            .unwrap()
2564            .branches;
2565        let repo_branches = repo_branches
2566            .iter()
2567            .map(|b| b.name())
2568            .collect::<HashSet<_>>();
2569        assert_eq!(&repo_branches, &expected_branches);
2570
2571        branch_list.update(cx, move |branch_list, cx| {
2572            branch_list.picker.update(cx, move |picker, _cx| {
2573                assert_eq!(picker.delegate.matches.len(), 3);
2574                let branches = picker
2575                    .delegate
2576                    .matches
2577                    .iter()
2578                    .map(|be| be.name())
2579                    .collect::<HashSet<_>>();
2580                assert_eq!(branches, expected_branches);
2581            })
2582        });
2583    }
2584
2585    #[gpui::test]
2586    async fn test_delete_unmerged_branch_prompts_for_force_delete(cx: &mut TestAppContext) {
2587        init_test(cx);
2588        let (fs, _project, repository) = init_fake_repository_with_fs(cx).await;
2589
2590        let branches = create_test_branches();
2591        let branch_names = branches
2592            .iter()
2593            .map(|branch| branch.name().to_string())
2594            .collect::<Vec<String>>();
2595        let repo = repository.clone();
2596        cx.spawn(async move |mut cx| {
2597            for branch in branch_names {
2598                repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
2599                    .await
2600                    .unwrap()
2601                    .unwrap();
2602            }
2603        })
2604        .await;
2605        cx.run_until_parked();
2606
2607        let branch_to_delete = "feature-auth";
2608        fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| {
2609            state
2610                .branches_requiring_force_delete
2611                .insert(branch_to_delete.to_string());
2612        })
2613        .expect("failed to mark test branch as requiring force delete");
2614
2615        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
2616        let cx = &mut ctx;
2617        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
2618
2619        branch_list.update_in(cx, |branch_list, window, cx| {
2620            branch_list.picker.update(cx, |picker, cx| {
2621                let branch_index = picker
2622                    .delegate
2623                    .matches
2624                    .iter()
2625                    .position(|entry| entry.name() == branch_to_delete)
2626                    .unwrap();
2627                picker.delegate.delete_at(branch_index, false, window, cx);
2628            })
2629        });
2630        cx.run_until_parked();
2631        assert!(cx.has_pending_prompt());
2632
2633        cx.simulate_prompt_answer("Force Delete");
2634        cx.run_until_parked();
2635
2636        let repo_branches = branch_list
2637            .update(cx, |branch_list, cx| {
2638                branch_list.picker.update(cx, |picker, cx| {
2639                    picker
2640                        .delegate
2641                        .repo
2642                        .as_ref()
2643                        .unwrap()
2644                        .update(cx, |repo, _cx| repo.branches())
2645                })
2646            })
2647            .await
2648            .unwrap()
2649            .unwrap()
2650            .branches;
2651        assert!(
2652            repo_branches
2653                .iter()
2654                .all(|branch| branch.name() != branch_to_delete)
2655        );
2656    }
2657
2658    #[gpui::test]
2659    async fn test_delete_unmerged_branch_cancel_keeps_branch(cx: &mut TestAppContext) {
2660        init_test(cx);
2661        let (fs, _project, repository) = init_fake_repository_with_fs(cx).await;
2662
2663        let branches = create_test_branches();
2664        let branch_names = branches
2665            .iter()
2666            .map(|branch| branch.name().to_string())
2667            .collect::<Vec<String>>();
2668        let repo = repository.clone();
2669        cx.spawn(async move |mut cx| {
2670            for branch in branch_names {
2671                repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
2672                    .await
2673                    .unwrap()
2674                    .unwrap();
2675            }
2676        })
2677        .await;
2678        cx.run_until_parked();
2679
2680        let branch_to_delete = "feature-auth";
2681        fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| {
2682            state
2683                .branches_requiring_force_delete
2684                .insert(branch_to_delete.to_string());
2685        })
2686        .expect("failed to mark test branch as requiring force delete");
2687
2688        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
2689        let cx = &mut ctx;
2690        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
2691
2692        let initial_match_count = branch_list.update(cx, |branch_list, cx| {
2693            branch_list
2694                .picker
2695                .update(cx, |picker, _| picker.delegate.matches.len())
2696        });
2697
2698        branch_list.update_in(cx, |branch_list, window, cx| {
2699            branch_list.picker.update(cx, |picker, cx| {
2700                let branch_index = picker
2701                    .delegate
2702                    .matches
2703                    .iter()
2704                    .position(|entry| entry.name() == branch_to_delete)
2705                    .unwrap();
2706                picker.delegate.delete_at(branch_index, false, window, cx);
2707            })
2708        });
2709        cx.run_until_parked();
2710        assert!(cx.has_pending_prompt());
2711
2712        cx.simulate_prompt_answer("Cancel");
2713        cx.run_until_parked();
2714        assert!(!cx.has_pending_prompt());
2715
2716        let repo_branches = branch_list
2717            .update(cx, |branch_list, cx| {
2718                branch_list.picker.update(cx, |picker, cx| {
2719                    picker
2720                        .delegate
2721                        .repo
2722                        .as_ref()
2723                        .unwrap()
2724                        .update(cx, |repo, _cx| repo.branches())
2725                })
2726            })
2727            .await
2728            .unwrap()
2729            .unwrap()
2730            .branches;
2731        assert!(
2732            repo_branches
2733                .iter()
2734                .any(|branch| branch.name() == branch_to_delete),
2735            "branch should still exist after cancelling the force-delete prompt"
2736        );
2737
2738        let final_match_count = branch_list.update(cx, |branch_list, cx| {
2739            branch_list
2740                .picker
2741                .update(cx, |picker, _| picker.delegate.matches.len())
2742        });
2743        assert_eq!(
2744            initial_match_count, final_match_count,
2745            "picker matches should be unchanged after cancel"
2746        );
2747    }
2748
2749    #[gpui::test]
2750    async fn test_force_delete_click_deletes_branch_without_prompt(cx: &mut TestAppContext) {
2751        init_test(cx);
2752        let (fs, _project, repository) = init_fake_repository_with_fs(cx).await;
2753
2754        let branches = create_test_branches();
2755        let branch_names = branches
2756            .iter()
2757            .map(|branch| branch.name().to_string())
2758            .collect::<Vec<String>>();
2759        let repo = repository.clone();
2760        cx.spawn(async move |mut cx| {
2761            for branch in branch_names {
2762                repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
2763                    .await
2764                    .unwrap()
2765                    .unwrap();
2766            }
2767        })
2768        .await;
2769        cx.run_until_parked();
2770
2771        let branch_to_delete = "feature-auth";
2772        fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| {
2773            state
2774                .branches_requiring_force_delete
2775                .insert(branch_to_delete.to_string());
2776        })
2777        .expect("failed to mark test branch as requiring force delete");
2778
2779        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
2780        let cx = &mut ctx;
2781        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
2782
2783        branch_list.update_in(cx, |branch_list, window, cx| {
2784            branch_list.picker.update(cx, |picker, cx| {
2785                picker.delegate.modifiers = Modifiers::alt();
2786                let branch_index = picker
2787                    .delegate
2788                    .matches
2789                    .iter()
2790                    .position(|entry| entry.name() == branch_to_delete)
2791                    .unwrap();
2792                picker.delegate.delete_at(branch_index, true, window, cx);
2793            })
2794        });
2795        cx.run_until_parked();
2796        assert!(!cx.has_pending_prompt());
2797
2798        let repo_branches = branch_list
2799            .update(cx, |branch_list, cx| {
2800                branch_list.picker.update(cx, |picker, cx| {
2801                    picker
2802                        .delegate
2803                        .repo
2804                        .as_ref()
2805                        .unwrap()
2806                        .update(cx, |repo, _cx| repo.branches())
2807                })
2808            })
2809            .await
2810            .unwrap()
2811            .unwrap()
2812            .branches;
2813        assert!(
2814            repo_branches
2815                .iter()
2816                .all(|branch| branch.name() != branch_to_delete)
2817        );
2818    }
2819
2820    #[gpui::test]
2821    async fn test_delete_remote_branch(cx: &mut TestAppContext) {
2822        init_test(cx);
2823        let (_project, repository) = init_fake_repository(cx).await;
2824        let branches = vec![
2825            create_test_branch("main", true, Some("origin"), Some(1000)),
2826            create_test_branch("feature-auth", false, Some("origin"), Some(900)),
2827            create_test_branch("feature-ui", false, Some("fork"), Some(800)),
2828            create_test_branch("develop", false, Some("private"), Some(700)),
2829        ];
2830
2831        let branch_names = branches
2832            .iter()
2833            .map(|branch| branch.name().to_string())
2834            .collect::<Vec<String>>();
2835        let repo = repository.clone();
2836        cx.spawn(async move |mut cx| {
2837            for branch in branch_names {
2838                repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
2839                    .await
2840                    .unwrap()
2841                    .unwrap();
2842            }
2843        })
2844        .await;
2845        cx.run_until_parked();
2846
2847        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
2848        let cx = &mut ctx;
2849        // Enable remote filter
2850        branch_list.update(cx, |branch_list, cx| {
2851            branch_list.picker.update(cx, |picker, _cx| {
2852                picker.delegate.branch_filter = BranchFilter::Remote;
2853            });
2854        });
2855        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
2856
2857        // Check matches, it should match all existing branches and no option to create new branch
2858        let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| {
2859            branch_list.picker.update(cx, |picker, cx| {
2860                assert_eq!(picker.delegate.matches.len(), 4);
2861                let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
2862                picker.delegate.delete_at(1, false, window, cx);
2863                branch_to_delete
2864            })
2865        });
2866        cx.run_until_parked();
2867
2868        let expected_branches = [
2869            "origin/main",
2870            "origin/feature-auth",
2871            "fork/feature-ui",
2872            "private/develop",
2873        ]
2874        .into_iter()
2875        .filter(|name| name != &branch_to_delete)
2876        .collect::<HashSet<_>>();
2877        let repo_branches = branch_list
2878            .update(cx, |branch_list, cx| {
2879                branch_list.picker.update(cx, |picker, cx| {
2880                    picker
2881                        .delegate
2882                        .repo
2883                        .as_ref()
2884                        .unwrap()
2885                        .update(cx, |repo, _cx| repo.branches())
2886                })
2887            })
2888            .await
2889            .unwrap()
2890            .unwrap()
2891            .branches;
2892        let repo_branches = repo_branches
2893            .iter()
2894            .map(|b| b.name())
2895            .collect::<HashSet<_>>();
2896        assert_eq!(&repo_branches, &expected_branches);
2897
2898        // Check matches, it should match one less branch than before
2899        branch_list.update(cx, move |branch_list, cx| {
2900            branch_list.picker.update(cx, move |picker, _cx| {
2901                assert_eq!(picker.delegate.matches.len(), 3);
2902                let branches = picker
2903                    .delegate
2904                    .matches
2905                    .iter()
2906                    .map(|be| be.name())
2907                    .collect::<HashSet<_>>();
2908                assert_eq!(branches, expected_branches);
2909            })
2910        });
2911    }
2912
2913    #[gpui::test]
2914    async fn test_branch_filter_shows_all_local_and_remote_branches(cx: &mut TestAppContext) {
2915        init_test(cx);
2916
2917        let branches = vec![
2918            create_test_branch("main", true, Some("origin"), Some(1000)),
2919            create_test_branch("feature-auth", false, Some("fork"), Some(900)),
2920            create_test_branch("feature-ui", false, None, Some(800)),
2921            create_test_branch("develop", false, None, Some(700)),
2922        ];
2923
2924        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
2925        let cx = &mut ctx;
2926
2927        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
2928
2929        branch_list.update(cx, |branch_list, cx| {
2930            branch_list.picker.update(cx, |picker, _cx| {
2931                assert_eq!(picker.delegate.matches.len(), 4);
2932
2933                let branches = picker
2934                    .delegate
2935                    .matches
2936                    .iter()
2937                    .map(|be| be.name())
2938                    .collect::<HashSet<_>>();
2939                assert_eq!(
2940                    branches,
2941                    ["origin/main", "fork/feature-auth", "feature-ui", "develop"]
2942                        .into_iter()
2943                        .collect::<HashSet<_>>()
2944                );
2945
2946                // Locals should be listed before remotes.
2947                let ordered = picker
2948                    .delegate
2949                    .matches
2950                    .iter()
2951                    .map(|be| be.name())
2952                    .collect::<Vec<_>>();
2953                assert_eq!(
2954                    ordered,
2955                    vec!["feature-ui", "develop", "origin/main", "fork/feature-auth"]
2956                );
2957
2958                // Verify the last entry is NOT the "create new branch" option
2959                let last_match = picker.delegate.matches.last().unwrap();
2960                assert!(!last_match.is_new_branch());
2961                assert!(!last_match.is_new_url());
2962            })
2963        });
2964
2965        branch_list.update(cx, |branch_list, cx| {
2966            branch_list.picker.update(cx, |picker, _cx| {
2967                picker.delegate.branch_filter = BranchFilter::Local;
2968            })
2969        });
2970
2971        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
2972
2973        branch_list.update(cx, |branch_list, cx| {
2974            branch_list.picker.update(cx, |picker, _cx| {
2975                assert_eq!(
2976                    picker
2977                        .delegate
2978                        .matches
2979                        .iter()
2980                        .map(|entry| entry.name())
2981                        .collect::<HashSet<_>>(),
2982                    ["feature-ui", "develop"].into_iter().collect()
2983                );
2984                picker.delegate.branch_filter = BranchFilter::Remote;
2985            })
2986        });
2987
2988        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
2989
2990        branch_list.update(cx, |branch_list, cx| {
2991            branch_list.picker.update(cx, |picker, _cx| {
2992                assert_eq!(picker.delegate.matches.len(), 2);
2993                let branches = picker
2994                    .delegate
2995                    .matches
2996                    .iter()
2997                    .map(|be| be.name())
2998                    .collect::<HashSet<_>>();
2999                assert_eq!(
3000                    branches,
3001                    ["origin/main", "fork/feature-auth"]
3002                        .into_iter()
3003                        .collect::<HashSet<_>>()
3004                );
3005
3006                // Verify the last entry is NOT the "create new branch" option
3007                let last_match = picker.delegate.matches.last().unwrap();
3008                assert!(!last_match.is_new_url());
3009            })
3010        });
3011
3012        branch_list
3013            .update_in(cx, |branch_list, window, cx| {
3014                branch_list.picker.update(cx, |picker, cx| {
3015                    picker.delegate.branch_filter = BranchFilter::Remote;
3016                    picker
3017                        .delegate
3018                        .update_matches(String::from("fork"), window, cx)
3019                })
3020            })
3021            .await;
3022        cx.run_until_parked();
3023
3024        branch_list.update(cx, |branch_list, cx| {
3025            branch_list.picker.update(cx, |picker, _cx| {
3026                // Should have 1 existing branch + 1 "create new branch" entry = 2 total
3027                assert_eq!(picker.delegate.matches.len(), 2);
3028                assert!(
3029                    picker
3030                        .delegate
3031                        .matches
3032                        .iter()
3033                        .any(|m| m.name() == "fork/feature-auth")
3034                );
3035                // Verify the last entry is the "create new branch" option
3036                let last_match = picker.delegate.matches.last().unwrap();
3037                assert!(last_match.is_new_branch());
3038            })
3039        });
3040    }
3041
3042    #[gpui::test]
3043    async fn test_branch_filter_is_restored_for_new_pickers(cx: &mut TestAppContext) {
3044        init_test(cx);
3045        cx.update(|cx| cx.set_global(GlobalBranchFilter(BranchFilter::Local)));
3046
3047        let (branch_list, mut ctx) = init_branch_list_test(None, create_test_branches(), cx).await;
3048        branch_list.update(&mut ctx, |branch_list, cx| {
3049            branch_list.picker.update(cx, |picker, _| {
3050                assert!(picker.delegate.branch_filter == BranchFilter::Local);
3051            });
3052        });
3053    }
3054
3055    #[test]
3056    fn test_branch_filter_cycle() {
3057        assert!(BranchFilter::All.next() == BranchFilter::Local);
3058        assert!(BranchFilter::Local.next() == BranchFilter::Remote);
3059        assert!(BranchFilter::Remote.next() == BranchFilter::All);
3060    }
3061
3062    #[gpui::test]
3063    async fn test_select_picker_lists_remote_branch_tracked_by_local_branch(
3064        cx: &mut TestAppContext,
3065    ) {
3066        init_test(cx);
3067        let (project, repository) = init_fake_repository(cx).await;
3068        cx.run_until_parked();
3069
3070        // Local `main` tracks `origin/main`; the two can point to different
3071        // commits, so both must be offered when picking a diff base.
3072        let branches = vec![
3073            create_test_branch_with_upstream(
3074                "main",
3075                true,
3076                None,
3077                Some(1000),
3078                Some("refs/remotes/origin/main"),
3079            ),
3080            create_test_branch("main", false, Some("origin"), Some(900)),
3081        ];
3082        repository.update(cx, |repository, cx| {
3083            repository.set_branch_list_for_test(branches, cx);
3084        });
3085
3086        let window_handle =
3087            cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3088        let workspace = window_handle
3089            .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
3090            .unwrap();
3091
3092        let checkout_list = window_handle
3093            .update(cx, |_, window, cx| {
3094                cx.new(|cx| {
3095                    BranchList::new(
3096                        workspace.downgrade(),
3097                        Some(repository.clone()),
3098                        BranchListStyle::Modal,
3099                        rems(34.),
3100                        window,
3101                        cx,
3102                    )
3103                })
3104            })
3105            .unwrap();
3106        let select_list = window_handle
3107            .update(cx, |_, window, cx| {
3108                cx.new(|cx| {
3109                    BranchList::new_select(
3110                        workspace.downgrade(),
3111                        Some(repository.clone()),
3112                        BranchListStyle::Modal,
3113                        rems(34.),
3114                        Some("main".into()),
3115                        Arc::new(|_: Branch, _: &mut Window, _: &mut App| {}),
3116                        window,
3117                        cx,
3118                    )
3119                })
3120            })
3121            .unwrap();
3122
3123        let mut ctx = VisualTestContext::from_window(window_handle.into(), cx);
3124        let cx = &mut ctx;
3125
3126        update_branch_list_matches_with_empty_query(&checkout_list, cx).await;
3127        checkout_list.update(cx, |branch_list, cx| {
3128            branch_list.picker.update(cx, |picker, _cx| {
3129                let names = picker
3130                    .delegate
3131                    .matches
3132                    .iter()
3133                    .map(Entry::name)
3134                    .collect::<Vec<_>>();
3135                assert_eq!(
3136                    names,
3137                    vec!["main"],
3138                    "the checkout picker should collapse remote branches tracked by a local branch"
3139                );
3140            })
3141        });
3142
3143        update_branch_list_matches_with_empty_query(&select_list, cx).await;
3144        select_list.update(cx, |branch_list, cx| {
3145            branch_list.picker.update(cx, |picker, _cx| {
3146                let names = picker
3147                    .delegate
3148                    .matches
3149                    .iter()
3150                    .map(Entry::name)
3151                    .collect::<HashSet<_>>();
3152                assert_eq!(
3153                    names,
3154                    ["main", "origin/main"].into_iter().collect::<HashSet<_>>(),
3155                    "the select picker should offer both a local branch and its remote upstream"
3156                );
3157            })
3158        });
3159    }
3160
3161    #[gpui::test]
3162    async fn test_new_branch_creation_with_query(cx: &mut TestAppContext) {
3163        const MAIN_BRANCH: &str = "main";
3164        const FEATURE_BRANCH: &str = "feature";
3165        const NEW_BRANCH: &str = "new-feature-branch";
3166
3167        init_test(cx);
3168        let (_project, repository) = init_fake_repository(cx).await;
3169
3170        let branches = vec![
3171            create_test_branch(MAIN_BRANCH, true, None, Some(1000)),
3172            create_test_branch(FEATURE_BRANCH, false, None, Some(900)),
3173        ];
3174
3175        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
3176        let cx = &mut ctx;
3177
3178        branch_list
3179            .update_in(cx, |branch_list, window, cx| {
3180                branch_list.picker.update(cx, |picker, cx| {
3181                    // Surrounding the branch with whitespace allows us to then
3182                    // assert that this whitespace is trimmed away.
3183                    picker
3184                        .delegate
3185                        .update_matches(format!(" {NEW_BRANCH} "), window, cx)
3186                })
3187            })
3188            .await;
3189
3190        cx.run_until_parked();
3191
3192        branch_list.update_in(cx, |branch_list, window, cx| {
3193            branch_list.picker.update(cx, |picker, cx| {
3194                let last_match = picker.delegate.matches.last().unwrap();
3195                assert!(last_match.is_new_branch());
3196                assert_eq!(last_match.name(), NEW_BRANCH);
3197                // State is NewBranch because no existing branches fuzzy-match the query
3198                assert!(matches!(picker.delegate.state, PickerState::NewBranch));
3199                picker.delegate.confirm(false, window, cx);
3200            })
3201        });
3202        cx.run_until_parked();
3203
3204        let branches = branch_list
3205            .update(cx, |branch_list, cx| {
3206                branch_list.picker.update(cx, |picker, cx| {
3207                    picker
3208                        .delegate
3209                        .repo
3210                        .as_ref()
3211                        .unwrap()
3212                        .update(cx, |repo, _cx| repo.branches())
3213                })
3214            })
3215            .await
3216            .unwrap()
3217            .unwrap()
3218            .branches;
3219
3220        let new_branch = branches
3221            .into_iter()
3222            .find(|branch| branch.name() == NEW_BRANCH)
3223            .expect("new-feature-branch should exist");
3224        assert_eq!(
3225            new_branch.ref_name.as_ref(),
3226            &format!("refs/heads/{NEW_BRANCH}"),
3227            "branch ref_name should not have duplicate refs/heads/ prefix"
3228        );
3229    }
3230
3231    #[test]
3232    fn test_normalize_branch_name() {
3233        assert_eq!(normalize_branch_name(" branch-name "), "branch-name");
3234        assert_eq!(normalize_branch_name("branch name"), "branch-name");
3235        assert_eq!(normalize_branch_name("  branch  name  "), "branch--name");
3236    }
3237
3238    #[gpui::test]
3239    async fn test_remote_url_detection_https(cx: &mut TestAppContext) {
3240        init_test(cx);
3241        let (_project, repository) = init_fake_repository(cx).await;
3242        let branches = vec![create_test_branch("main", true, None, Some(1000))];
3243
3244        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
3245        let cx = &mut ctx;
3246
3247        branch_list
3248            .update_in(cx, |branch_list, window, cx| {
3249                branch_list.picker.update(cx, |picker, cx| {
3250                    let query = "https://github.com/user/repo.git".to_string();
3251                    picker.delegate.update_matches(query, window, cx)
3252                })
3253            })
3254            .await;
3255
3256        cx.run_until_parked();
3257
3258        branch_list
3259            .update_in(cx, |branch_list, window, cx| {
3260                branch_list.picker.update(cx, |picker, cx| {
3261                    let last_match = picker.delegate.matches.last().unwrap();
3262                    assert!(last_match.is_new_url());
3263                    assert!(matches!(picker.delegate.state, PickerState::NewRemote));
3264                    picker.delegate.confirm(false, window, cx);
3265                    assert_eq!(picker.delegate.matches.len(), 0);
3266                    if let PickerState::CreateRemote(remote_url) = &picker.delegate.state
3267                        && remote_url.as_ref() == "https://github.com/user/repo.git"
3268                    {
3269                    } else {
3270                        panic!("wrong picker state");
3271                    }
3272                    picker
3273                        .delegate
3274                        .update_matches("my_new_remote".to_string(), window, cx)
3275                })
3276            })
3277            .await;
3278
3279        cx.run_until_parked();
3280
3281        branch_list.update_in(cx, |branch_list, window, cx| {
3282            branch_list.picker.update(cx, |picker, cx| {
3283                assert_eq!(picker.delegate.matches.len(), 1);
3284                assert!(matches!(
3285                    picker.delegate.matches.first(),
3286                    Some(Entry::NewRemoteName { name, url })
3287                        if name == "my_new_remote" && url.as_ref() == "https://github.com/user/repo.git"
3288                ));
3289                picker.delegate.confirm(false, window, cx);
3290            })
3291        });
3292        cx.run_until_parked();
3293
3294        // List remotes
3295        let remotes = branch_list
3296            .update(cx, |branch_list, cx| {
3297                branch_list.picker.update(cx, |picker, cx| {
3298                    picker
3299                        .delegate
3300                        .repo
3301                        .as_ref()
3302                        .unwrap()
3303                        .update(cx, |repo, _cx| repo.get_remotes(None, false))
3304                })
3305            })
3306            .await
3307            .unwrap()
3308            .unwrap();
3309        assert_eq!(
3310            remotes,
3311            vec![Remote {
3312                name: SharedString::from("my_new_remote")
3313            }]
3314        );
3315    }
3316
3317    #[gpui::test]
3318    async fn test_confirm_remote_url_transitions(cx: &mut TestAppContext) {
3319        init_test(cx);
3320
3321        let branches = vec![create_test_branch("main_branch", true, None, Some(1000))];
3322        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
3323        let cx = &mut ctx;
3324
3325        branch_list
3326            .update_in(cx, |branch_list, window, cx| {
3327                branch_list.picker.update(cx, |picker, cx| {
3328                    let query = "https://github.com/user/repo.git".to_string();
3329                    picker.delegate.update_matches(query, window, cx)
3330                })
3331            })
3332            .await;
3333        cx.run_until_parked();
3334
3335        // Try to create a new remote but cancel in the middle of the process
3336        branch_list
3337            .update_in(cx, |branch_list, window, cx| {
3338                branch_list.picker.update(cx, |picker, cx| {
3339                    picker.delegate.selected_index = picker.delegate.matches.len() - 1;
3340                    picker.delegate.confirm(false, window, cx);
3341
3342                    assert!(matches!(
3343                        picker.delegate.state,
3344                        PickerState::CreateRemote(_)
3345                    ));
3346                    if let PickerState::CreateRemote(ref url) = picker.delegate.state {
3347                        assert_eq!(url.as_ref(), "https://github.com/user/repo.git");
3348                    }
3349                    assert_eq!(picker.delegate.matches.len(), 0);
3350                    picker.delegate.dismissed(window, cx);
3351                    assert!(matches!(picker.delegate.state, PickerState::List));
3352                    let query = "main".to_string();
3353                    picker.delegate.update_matches(query, window, cx)
3354                })
3355            })
3356            .await;
3357        cx.run_until_parked();
3358
3359        // Try to search a branch again to see if the state is restored properly
3360        branch_list.update(cx, |branch_list, cx| {
3361            branch_list.picker.update(cx, |picker, _cx| {
3362                // Should have 1 existing branch + 1 "create new branch" entry = 2 total
3363                assert_eq!(picker.delegate.matches.len(), 2);
3364                assert!(
3365                    picker
3366                        .delegate
3367                        .matches
3368                        .iter()
3369                        .any(|m| m.name() == "main_branch")
3370                );
3371                // Verify the last entry is the "create new branch" option
3372                let last_match = picker.delegate.matches.last().unwrap();
3373                assert!(last_match.is_new_branch());
3374            })
3375        });
3376    }
3377
3378    #[gpui::test]
3379    async fn test_confirm_remote_url_does_not_dismiss(cx: &mut TestAppContext) {
3380        const REMOTE_URL: &str = "https://github.com/user/repo.git";
3381
3382        init_test(cx);
3383        let branches = vec![create_test_branch("main", true, None, Some(1000))];
3384
3385        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
3386        let cx = &mut ctx;
3387
3388        let subscription = cx.update(|_, cx| {
3389            cx.subscribe(&branch_list, |_, _: &DismissEvent, _| {
3390                panic!("DismissEvent should not be emitted when confirming a remote URL");
3391            })
3392        });
3393
3394        branch_list
3395            .update_in(cx, |branch_list, window, cx| {
3396                window.focus(&branch_list.picker_focus_handle, cx);
3397                assert!(
3398                    branch_list.picker_focus_handle.is_focused(window),
3399                    "Branch picker should be focused when selecting an entry"
3400                );
3401
3402                branch_list.picker.update(cx, |picker, cx| {
3403                    picker
3404                        .delegate
3405                        .update_matches(REMOTE_URL.to_string(), window, cx)
3406                })
3407            })
3408            .await;
3409
3410        cx.run_until_parked();
3411
3412        branch_list.update_in(cx, |branch_list, window, cx| {
3413            // Re-focus the picker since workspace initialization during run_until_parked
3414            window.focus(&branch_list.picker_focus_handle, cx);
3415
3416            branch_list.picker.update(cx, |picker, cx| {
3417                let last_match = picker.delegate.matches.last().unwrap();
3418                assert!(last_match.is_new_url());
3419                assert!(matches!(picker.delegate.state, PickerState::NewRemote));
3420
3421                picker.delegate.confirm(false, window, cx);
3422
3423                assert!(
3424                    matches!(picker.delegate.state, PickerState::CreateRemote(ref url) if url.as_ref() == REMOTE_URL),
3425                    "State should transition to CreateRemote with the URL"
3426                );
3427            });
3428
3429            assert!(
3430                branch_list.picker_focus_handle.is_focused(window),
3431                "Branch list picker should still be focused after confirming remote URL"
3432            );
3433        });
3434
3435        cx.run_until_parked();
3436
3437        drop(subscription);
3438    }
3439
3440    #[gpui::test(iterations = 10)]
3441    async fn test_empty_query_displays_all_branches(mut rng: StdRng, cx: &mut TestAppContext) {
3442        init_test(cx);
3443        let branch_count = rng.random_range(13..540);
3444
3445        let branches: Vec<Branch> = (0..branch_count)
3446            .map(|i| create_test_branch(&format!("branch-{:02}", i), i == 0, None, Some(i * 100)))
3447            .collect();
3448
3449        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
3450        let cx = &mut ctx;
3451
3452        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
3453
3454        branch_list.update(cx, |branch_list, cx| {
3455            branch_list.picker.update(cx, |picker, _cx| {
3456                assert_eq!(picker.delegate.matches.len(), branch_count as usize);
3457            })
3458        });
3459    }
3460}
3461
Served at tenant.openagents/omega Member data and write actions are omitted.