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