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