Skip to repository content3461 lines · 126.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:35:56.382Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
branch_picker.rs
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