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 entry_icon = match entry {
889 Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => {
890 IconName::Plus
891 }
892 Entry::Branch { branch, .. } => {
893 if branch.is_remote() {
894 IconName::Screen
895 } else {
896 IconName::GitBranchAlt
897 }
898 }
899 };
900
901 let entry_title = match entry {
902 Entry::NewUrl { .. } => Label::new("Create Remote Repository")
903 .single_line()
904 .truncate()
905 .into_any_element(),
906 Entry::NewBranch { name } => Label::new(format!("Create Branch: \"{name}\"…"))
907 .single_line()
908 .truncate()
909 .into_any_element(),
910 Entry::NewRemoteName { name, .. } => Label::new(format!("Create Remote: \"{name}\""))
911 .single_line()
912 .truncate()
913 .into_any_element(),
914 Entry::Branch { branch, positions } => {
915 HighlightedLabel::new(branch.name().to_string(), positions.clone())
916 .single_line()
917 .truncate()
918 .into_any_element()
919 }
920 };
921
922 let focus_handle = self.focus_handle.clone();
923 let is_new_items = matches!(
924 entry,
925 Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. }
926 );
927
928 let is_head_branch = entry.as_branch().is_some_and(|branch| branch.is_head);
929
930 let deleted_branch_icon = |entry_ix: usize| {
931 IconButton::new(("delete", entry_ix), IconName::Trash)
932 .icon_size(IconSize::Small)
933 .tooltip(move |_, cx| {
934 Tooltip::for_action_in(
935 "Delete Branch",
936 &branch_picker::DeleteBranch,
937 &focus_handle,
938 cx,
939 )
940 })
941 .on_click(cx.listener(move |this, _, window, cx| {
942 this.delegate.delete_at(entry_ix, window, cx);
943 }))
944 };
945
946 let create_from_default_button = self.default_branch.as_ref().map(|default_branch| {
947 let tooltip_label: SharedString = format!("Create New From: {default_branch}").into();
948 let focus_handle = self.focus_handle.clone();
949
950 IconButton::new("create_from_default", IconName::GitBranchPlus)
951 .icon_size(IconSize::Small)
952 .tooltip(move |_, cx| {
953 Tooltip::for_action_in(
954 tooltip_label.clone(),
955 &menu::SecondaryConfirm,
956 &focus_handle,
957 cx,
958 )
959 })
960 .on_click(cx.listener(|this, _, window, cx| {
961 this.delegate.confirm(true, window, cx);
962 }))
963 .into_any_element()
964 });
965
966 Some(
967 ListItem::new(format!("vcs-menu-{ix}"))
968 .inset(true)
969 .spacing(ListItemSpacing::Sparse)
970 .toggle_state(selected)
971 .child(
972 h_flex()
973 .w_full()
974 .gap_2p5()
975 .flex_grow()
976 .child(
977 Icon::new(entry_icon)
978 .color(Color::Muted)
979 .size(IconSize::Small),
980 )
981 .child(
982 v_flex()
983 .id("info_container")
984 .w_full()
985 .child(entry_title)
986 .child({
987 let message = match entry {
988 Entry::NewUrl { url } => format!("Based off {url}"),
989 Entry::NewRemoteName { url, .. } => {
990 format!("Based off {url}")
991 }
992 Entry::NewBranch { .. } => {
993 if let Some(current_branch) =
994 self.repo.as_ref().and_then(|repo| {
995 repo.read(cx).branch.as_ref().map(|b| b.name())
996 })
997 {
998 format!("Based off {}", current_branch)
999 } else {
1000 "Based off the current branch".to_string()
1001 }
1002 }
1003 Entry::Branch { .. } => String::new(),
1004 };
1005
1006 if matches!(entry, Entry::Branch { .. }) {
1007 let show_author_name = ProjectSettings::get_global(cx)
1008 .git
1009 .branch_picker
1010 .show_author_name;
1011 let has_author = show_author_name && author_name.is_some();
1012 let has_commit = commit_time.is_some();
1013 let author_for_meta =
1014 if show_author_name { author_name } else { None };
1015
1016 let dot = || {
1017 Label::new("•")
1018 .alpha(0.5)
1019 .color(Color::Muted)
1020 .size(LabelSize::Small)
1021 };
1022
1023 h_flex()
1024 .w_full()
1025 .min_w_0()
1026 .gap_1p5()
1027 .when_some(author_for_meta, |this, author| {
1028 this.child(
1029 Label::new(author)
1030 .color(Color::Muted)
1031 .size(LabelSize::Small),
1032 )
1033 })
1034 .when_some(commit_time, |this, time| {
1035 this.when(has_author, |this| this.child(dot()))
1036 .child(
1037 Label::new(time)
1038 .color(Color::Muted)
1039 .size(LabelSize::Small),
1040 )
1041 })
1042 .when_some(subject, |this, subj| {
1043 this.when(has_commit, |this| this.child(dot()))
1044 .child(
1045 Label::new(subj.to_string())
1046 .color(Color::Muted)
1047 .size(LabelSize::Small)
1048 .truncate()
1049 .flex_1(),
1050 )
1051 })
1052 .when(!has_commit, |this| {
1053 this.child(
1054 Label::new("No commits found")
1055 .color(Color::Muted)
1056 .size(LabelSize::Small),
1057 )
1058 })
1059 .into_any_element()
1060 } else {
1061 Label::new(message)
1062 .size(LabelSize::Small)
1063 .color(Color::Muted)
1064 .truncate()
1065 .into_any_element()
1066 }
1067 })
1068 .when_some(
1069 entry.as_branch().map(|b| b.name().to_string()),
1070 |this, branch_name| {
1071 this.map(|this| {
1072 if is_head_branch {
1073 this.tooltip(move |_, cx| {
1074 Tooltip::with_meta(
1075 branch_name.clone(),
1076 None,
1077 "Current Branch",
1078 cx,
1079 )
1080 })
1081 } else {
1082 this.tooltip(Tooltip::text(branch_name))
1083 }
1084 })
1085 },
1086 ),
1087 ),
1088 )
1089 .when(!is_new_items && !is_head_branch, |this| {
1090 this.map(|this| {
1091 if self.selected_index() == ix {
1092 this.end_slot(deleted_branch_icon(ix))
1093 } else {
1094 this.end_hover_slot(deleted_branch_icon(ix))
1095 }
1096 })
1097 })
1098 .when_some(
1099 if is_new_items {
1100 create_from_default_button
1101 } else {
1102 None
1103 },
1104 |this, create_from_default_button| {
1105 this.map(|this| {
1106 if self.selected_index() == ix {
1107 this.end_slot(create_from_default_button)
1108 } else {
1109 this.end_hover_slot(create_from_default_button)
1110 }
1111 })
1112 },
1113 ),
1114 )
1115 }
1116
1117 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1118 if self.editor_position() == PickerEditorPosition::End {
1119 return None;
1120 }
1121 let focus_handle = self.focus_handle.clone();
1122
1123 let footer_container = || {
1124 h_flex()
1125 .w_full()
1126 .p_1p5()
1127 .border_t_1()
1128 .border_color(cx.theme().colors().border_variant)
1129 };
1130
1131 match self.state {
1132 PickerState::List => {
1133 let selected_entry = self.matches.get(self.selected_index);
1134
1135 let branch_from_default_button = self
1136 .default_branch
1137 .as_ref()
1138 .filter(|_| matches!(selected_entry, Some(Entry::NewBranch { .. })))
1139 .map(|default_branch| {
1140 let button_label = format!("Create New From: {default_branch}");
1141
1142 Button::new("branch-from-default", button_label)
1143 .key_binding(
1144 KeyBinding::for_action_in(
1145 &menu::SecondaryConfirm,
1146 &focus_handle,
1147 cx,
1148 )
1149 .map(|kb| kb.size(rems_from_px(12.))),
1150 )
1151 .on_click(cx.listener(|this, _, window, cx| {
1152 this.delegate.confirm(true, window, cx);
1153 }))
1154 });
1155
1156 let delete_and_select_btns = h_flex()
1157 .gap_1()
1158 .when(
1159 !selected_entry
1160 .and_then(|entry| entry.as_branch())
1161 .is_some_and(|branch| branch.is_head),
1162 |this| {
1163 this.child(
1164 Button::new("delete-branch", "Delete")
1165 .key_binding(
1166 KeyBinding::for_action_in(
1167 &branch_picker::DeleteBranch,
1168 &focus_handle,
1169 cx,
1170 )
1171 .map(|kb| kb.size(rems_from_px(12.))),
1172 )
1173 .on_click(|_, window, cx| {
1174 window.dispatch_action(
1175 branch_picker::DeleteBranch.boxed_clone(),
1176 cx,
1177 );
1178 }),
1179 )
1180 },
1181 )
1182 .child(
1183 Button::new("select_branch", "Select")
1184 .key_binding(
1185 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1186 .map(|kb| kb.size(rems_from_px(12.))),
1187 )
1188 .on_click(cx.listener(|this, _, window, cx| {
1189 this.delegate.confirm(false, window, cx);
1190 })),
1191 );
1192
1193 Some(
1194 footer_container()
1195 .map(|this| {
1196 if branch_from_default_button.is_some() {
1197 this.justify_end().when_some(
1198 branch_from_default_button,
1199 |this, button| {
1200 this.child(button).child(
1201 Button::new("create", "Create")
1202 .key_binding(
1203 KeyBinding::for_action_in(
1204 &menu::Confirm,
1205 &focus_handle,
1206 cx,
1207 )
1208 .map(|kb| kb.size(rems_from_px(12.))),
1209 )
1210 .on_click(cx.listener(|this, _, window, cx| {
1211 this.delegate.confirm(false, window, cx);
1212 })),
1213 )
1214 },
1215 )
1216 } else {
1217 this.justify_between()
1218 .child({
1219 let focus_handle = focus_handle.clone();
1220 let filter_label = match self.branch_filter {
1221 BranchFilter::All => "Filter Remote",
1222 BranchFilter::Remote => "Show All",
1223 };
1224 Button::new("filter-remotes", filter_label)
1225 .toggle_state(matches!(
1226 self.branch_filter,
1227 BranchFilter::Remote
1228 ))
1229 .key_binding(
1230 KeyBinding::for_action_in(
1231 &branch_picker::FilterRemotes,
1232 &focus_handle,
1233 cx,
1234 )
1235 .map(|kb| kb.size(rems_from_px(12.))),
1236 )
1237 .on_click(|_click, window, cx| {
1238 window.dispatch_action(
1239 branch_picker::FilterRemotes.boxed_clone(),
1240 cx,
1241 );
1242 })
1243 })
1244 .child(delete_and_select_btns)
1245 }
1246 })
1247 .into_any_element(),
1248 )
1249 }
1250 PickerState::NewBranch => {
1251 let branch_from_default_button =
1252 self.default_branch.as_ref().map(|default_branch| {
1253 let button_label = format!("Create New From: {default_branch}");
1254
1255 Button::new("branch-from-default", button_label)
1256 .key_binding(
1257 KeyBinding::for_action_in(
1258 &menu::SecondaryConfirm,
1259 &focus_handle,
1260 cx,
1261 )
1262 .map(|kb| kb.size(rems_from_px(12.))),
1263 )
1264 .on_click(cx.listener(|this, _, window, cx| {
1265 this.delegate.confirm(true, window, cx);
1266 }))
1267 });
1268
1269 Some(
1270 footer_container()
1271 .gap_1()
1272 .justify_end()
1273 .when_some(branch_from_default_button, |this, button| {
1274 this.child(button)
1275 })
1276 .child(
1277 Button::new("branch-from-default", "Create")
1278 .key_binding(
1279 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1280 .map(|kb| kb.size(rems_from_px(12.))),
1281 )
1282 .on_click(cx.listener(|this, _, window, cx| {
1283 this.delegate.confirm(false, window, cx);
1284 })),
1285 )
1286 .into_any_element(),
1287 )
1288 }
1289 PickerState::CreateRemote(_) => Some(
1290 footer_container()
1291 .justify_end()
1292 .child(
1293 Button::new("branch-from-default", "Confirm")
1294 .key_binding(
1295 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1296 .map(|kb| kb.size(rems_from_px(12.))),
1297 )
1298 .on_click(cx.listener(|this, _, window, cx| {
1299 this.delegate.confirm(false, window, cx);
1300 }))
1301 .disabled(self.last_query.is_empty()),
1302 )
1303 .into_any_element(),
1304 ),
1305 PickerState::NewRemote => None,
1306 }
1307 }
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312 use std::collections::HashSet;
1313
1314 use super::*;
1315 use git::repository::{CommitSummary, Remote};
1316 use gpui::{AppContext, TestAppContext, VisualTestContext};
1317 use project::{FakeFs, Project};
1318 use rand::{Rng, rngs::StdRng};
1319 use serde_json::json;
1320 use settings::SettingsStore;
1321 use util::path;
1322 use workspace::MultiWorkspace;
1323
1324 fn init_test(cx: &mut TestAppContext) {
1325 cx.update(|cx| {
1326 let settings_store = SettingsStore::test(cx);
1327 cx.set_global(settings_store);
1328 theme_settings::init(theme::LoadThemes::JustBase, cx);
1329 editor::init(cx);
1330 });
1331 }
1332
1333 fn create_test_branch(
1334 name: &str,
1335 is_head: bool,
1336 remote_name: Option<&str>,
1337 timestamp: Option<i64>,
1338 ) -> Branch {
1339 let ref_name = match remote_name {
1340 Some(remote_name) => format!("refs/remotes/{remote_name}/{name}"),
1341 None => format!("refs/heads/{name}"),
1342 };
1343
1344 Branch {
1345 is_head,
1346 ref_name: ref_name.into(),
1347 upstream: None,
1348 most_recent_commit: timestamp.map(|ts| CommitSummary {
1349 sha: "abc123".into(),
1350 commit_timestamp: ts,
1351 author_name: "Test Author".into(),
1352 subject: "Test commit".into(),
1353 has_parent: true,
1354 }),
1355 }
1356 }
1357
1358 fn create_test_branches() -> Vec<Branch> {
1359 vec![
1360 create_test_branch("main", true, None, Some(1000)),
1361 create_test_branch("feature-auth", false, None, Some(900)),
1362 create_test_branch("feature-ui", false, None, Some(800)),
1363 create_test_branch("develop", false, None, Some(700)),
1364 ]
1365 }
1366
1367 async fn init_branch_list_test(
1368 repository: Option<Entity<Repository>>,
1369 branches: Vec<Branch>,
1370 cx: &mut TestAppContext,
1371 ) -> (Entity<BranchList>, VisualTestContext) {
1372 let fs = FakeFs::new(cx.executor());
1373 let project = Project::test(fs, [], cx).await;
1374
1375 let window_handle =
1376 cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
1377 let workspace = window_handle
1378 .read_with(cx, |mw, _| mw.workspace().clone())
1379 .unwrap();
1380
1381 let branch_list = window_handle
1382 .update(cx, |_multi_workspace, window, cx| {
1383 cx.new(|cx| {
1384 let mut delegate = BranchListDelegate::new(
1385 workspace.downgrade(),
1386 repository,
1387 BranchListStyle::Modal,
1388 cx,
1389 );
1390 delegate.all_branches = Some(branches);
1391 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
1392 let picker_focus_handle = picker.focus_handle(cx);
1393 picker.update(cx, |picker, _| {
1394 picker.delegate.focus_handle = picker_focus_handle.clone();
1395 });
1396
1397 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
1398 cx.emit(DismissEvent);
1399 });
1400
1401 BranchList {
1402 picker,
1403 picker_focus_handle,
1404 width: rems(34.),
1405 _subscription: Some(_subscription),
1406 embedded: false,
1407 }
1408 })
1409 })
1410 .unwrap();
1411
1412 let cx = VisualTestContext::from_window(window_handle.into(), cx);
1413
1414 (branch_list, cx)
1415 }
1416
1417 async fn init_fake_repository(
1418 cx: &mut TestAppContext,
1419 ) -> (Entity<Project>, Entity<Repository>) {
1420 let fs = FakeFs::new(cx.executor());
1421 fs.insert_tree(
1422 path!("/dir"),
1423 json!({
1424 ".git": {},
1425 "file.txt": "buffer_text".to_string()
1426 }),
1427 )
1428 .await;
1429 fs.set_head_for_repo(
1430 path!("/dir/.git").as_ref(),
1431 &[("file.txt", "test".to_string())],
1432 "deadbeef",
1433 );
1434 fs.set_index_for_repo(
1435 path!("/dir/.git").as_ref(),
1436 &[("file.txt", "index_text".to_string())],
1437 );
1438
1439 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1440 let repository = cx.read(|cx| project.read(cx).active_repository(cx));
1441
1442 (project, repository.unwrap())
1443 }
1444
1445 #[gpui::test]
1446 async fn test_update_branch_matches_with_query(cx: &mut TestAppContext) {
1447 init_test(cx);
1448
1449 let branches = create_test_branches();
1450 let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1451 let cx = &mut ctx;
1452
1453 branch_list
1454 .update_in(cx, |branch_list, window, cx| {
1455 let query = "feature".to_string();
1456 branch_list.picker.update(cx, |picker, cx| {
1457 picker.delegate.update_matches(query, window, cx)
1458 })
1459 })
1460 .await;
1461 cx.run_until_parked();
1462
1463 branch_list.update(cx, |branch_list, cx| {
1464 branch_list.picker.update(cx, |picker, _cx| {
1465 // Should have 2 existing branches + 1 "create new branch" entry = 3 total
1466 assert_eq!(picker.delegate.matches.len(), 3);
1467 assert!(
1468 picker
1469 .delegate
1470 .matches
1471 .iter()
1472 .any(|m| m.name() == "feature-auth")
1473 );
1474 assert!(
1475 picker
1476 .delegate
1477 .matches
1478 .iter()
1479 .any(|m| m.name() == "feature-ui")
1480 );
1481 // Verify the last entry is the "create new branch" option
1482 let last_match = picker.delegate.matches.last().unwrap();
1483 assert!(last_match.is_new_branch());
1484 })
1485 });
1486 }
1487
1488 async fn update_branch_list_matches_with_empty_query(
1489 branch_list: &Entity<BranchList>,
1490 cx: &mut VisualTestContext,
1491 ) {
1492 branch_list
1493 .update_in(cx, |branch_list, window, cx| {
1494 branch_list.picker.update(cx, |picker, cx| {
1495 picker.delegate.update_matches(String::new(), window, cx)
1496 })
1497 })
1498 .await;
1499 cx.run_until_parked();
1500 }
1501
1502 #[gpui::test]
1503 async fn test_delete_branch(cx: &mut TestAppContext) {
1504 init_test(cx);
1505 let (_project, repository) = init_fake_repository(cx).await;
1506
1507 let branches = create_test_branches();
1508
1509 let branch_names = branches
1510 .iter()
1511 .map(|branch| branch.name().to_string())
1512 .collect::<Vec<String>>();
1513 let repo = repository.clone();
1514 cx.spawn(async move |mut cx| {
1515 for branch in branch_names {
1516 repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
1517 .await
1518 .unwrap()
1519 .unwrap();
1520 }
1521 })
1522 .await;
1523 cx.run_until_parked();
1524
1525 let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1526 let cx = &mut ctx;
1527
1528 update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1529
1530 let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| {
1531 branch_list.picker.update(cx, |picker, cx| {
1532 assert_eq!(picker.delegate.matches.len(), 4);
1533 let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
1534 picker.delegate.delete_at(1, window, cx);
1535 branch_to_delete
1536 })
1537 });
1538 cx.run_until_parked();
1539
1540 let expected_branches = ["main", "feature-auth", "feature-ui", "develop"]
1541 .into_iter()
1542 .filter(|name| name != &branch_to_delete)
1543 .collect::<HashSet<_>>();
1544 let repo_branches = branch_list
1545 .update(cx, |branch_list, cx| {
1546 branch_list.picker.update(cx, |picker, cx| {
1547 picker
1548 .delegate
1549 .repo
1550 .as_ref()
1551 .unwrap()
1552 .update(cx, |repo, _cx| repo.branches())
1553 })
1554 })
1555 .await
1556 .unwrap()
1557 .unwrap();
1558 let repo_branches = repo_branches
1559 .iter()
1560 .map(|b| b.name())
1561 .collect::<HashSet<_>>();
1562 assert_eq!(&repo_branches, &expected_branches);
1563
1564 branch_list.update(cx, move |branch_list, cx| {
1565 branch_list.picker.update(cx, move |picker, _cx| {
1566 assert_eq!(picker.delegate.matches.len(), 3);
1567 let branches = picker
1568 .delegate
1569 .matches
1570 .iter()
1571 .map(|be| be.name())
1572 .collect::<HashSet<_>>();
1573 assert_eq!(branches, expected_branches);
1574 })
1575 });
1576 }
1577
1578 #[gpui::test]
1579 async fn test_delete_remote_branch(cx: &mut TestAppContext) {
1580 init_test(cx);
1581 let (_project, repository) = init_fake_repository(cx).await;
1582 let branches = vec![
1583 create_test_branch("main", true, Some("origin"), Some(1000)),
1584 create_test_branch("feature-auth", false, Some("origin"), Some(900)),
1585 create_test_branch("feature-ui", false, Some("fork"), Some(800)),
1586 create_test_branch("develop", false, Some("private"), Some(700)),
1587 ];
1588
1589 let branch_names = branches
1590 .iter()
1591 .map(|branch| branch.name().to_string())
1592 .collect::<Vec<String>>();
1593 let repo = repository.clone();
1594 cx.spawn(async move |mut cx| {
1595 for branch in branch_names {
1596 repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
1597 .await
1598 .unwrap()
1599 .unwrap();
1600 }
1601 })
1602 .await;
1603 cx.run_until_parked();
1604
1605 let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1606 let cx = &mut ctx;
1607 // Enable remote filter
1608 branch_list.update(cx, |branch_list, cx| {
1609 branch_list.picker.update(cx, |picker, _cx| {
1610 picker.delegate.branch_filter = BranchFilter::Remote;
1611 });
1612 });
1613 update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1614
1615 // Check matches, it should match all existing branches and no option to create new branch
1616 let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| {
1617 branch_list.picker.update(cx, |picker, cx| {
1618 assert_eq!(picker.delegate.matches.len(), 4);
1619 let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
1620 picker.delegate.delete_at(1, window, cx);
1621 branch_to_delete
1622 })
1623 });
1624 cx.run_until_parked();
1625
1626 let expected_branches = [
1627 "origin/main",
1628 "origin/feature-auth",
1629 "fork/feature-ui",
1630 "private/develop",
1631 ]
1632 .into_iter()
1633 .filter(|name| name != &branch_to_delete)
1634 .collect::<HashSet<_>>();
1635 let repo_branches = branch_list
1636 .update(cx, |branch_list, cx| {
1637 branch_list.picker.update(cx, |picker, cx| {
1638 picker
1639 .delegate
1640 .repo
1641 .as_ref()
1642 .unwrap()
1643 .update(cx, |repo, _cx| repo.branches())
1644 })
1645 })
1646 .await
1647 .unwrap()
1648 .unwrap();
1649 let repo_branches = repo_branches
1650 .iter()
1651 .map(|b| b.name())
1652 .collect::<HashSet<_>>();
1653 assert_eq!(&repo_branches, &expected_branches);
1654
1655 // Check matches, it should match one less branch than before
1656 branch_list.update(cx, move |branch_list, cx| {
1657 branch_list.picker.update(cx, move |picker, _cx| {
1658 assert_eq!(picker.delegate.matches.len(), 3);
1659 let branches = picker
1660 .delegate
1661 .matches
1662 .iter()
1663 .map(|be| be.name())
1664 .collect::<HashSet<_>>();
1665 assert_eq!(branches, expected_branches);
1666 })
1667 });
1668 }
1669
1670 #[gpui::test]
1671 async fn test_branch_filter_shows_all_then_remotes_and_applies_query(cx: &mut TestAppContext) {
1672 init_test(cx);
1673
1674 let branches = vec![
1675 create_test_branch("main", true, Some("origin"), Some(1000)),
1676 create_test_branch("feature-auth", false, Some("fork"), Some(900)),
1677 create_test_branch("feature-ui", false, None, Some(800)),
1678 create_test_branch("develop", false, None, Some(700)),
1679 ];
1680
1681 let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1682 let cx = &mut ctx;
1683
1684 update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1685
1686 branch_list.update(cx, |branch_list, cx| {
1687 branch_list.picker.update(cx, |picker, _cx| {
1688 assert_eq!(picker.delegate.matches.len(), 4);
1689
1690 let branches = picker
1691 .delegate
1692 .matches
1693 .iter()
1694 .map(|be| be.name())
1695 .collect::<HashSet<_>>();
1696 assert_eq!(
1697 branches,
1698 ["origin/main", "fork/feature-auth", "feature-ui", "develop"]
1699 .into_iter()
1700 .collect::<HashSet<_>>()
1701 );
1702
1703 // Locals should be listed before remotes.
1704 let ordered = picker
1705 .delegate
1706 .matches
1707 .iter()
1708 .map(|be| be.name())
1709 .collect::<Vec<_>>();
1710 assert_eq!(
1711 ordered,
1712 vec!["feature-ui", "develop", "origin/main", "fork/feature-auth"]
1713 );
1714
1715 // Verify the last entry is NOT the "create new branch" option
1716 let last_match = picker.delegate.matches.last().unwrap();
1717 assert!(!last_match.is_new_branch());
1718 assert!(!last_match.is_new_url());
1719 })
1720 });
1721
1722 branch_list.update(cx, |branch_list, cx| {
1723 branch_list.picker.update(cx, |picker, _cx| {
1724 picker.delegate.branch_filter = BranchFilter::Remote;
1725 })
1726 });
1727
1728 update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1729
1730 branch_list
1731 .update_in(cx, |branch_list, window, cx| {
1732 branch_list.picker.update(cx, |picker, cx| {
1733 assert_eq!(picker.delegate.matches.len(), 2);
1734 let branches = picker
1735 .delegate
1736 .matches
1737 .iter()
1738 .map(|be| be.name())
1739 .collect::<HashSet<_>>();
1740 assert_eq!(
1741 branches,
1742 ["origin/main", "fork/feature-auth"]
1743 .into_iter()
1744 .collect::<HashSet<_>>()
1745 );
1746
1747 // Verify the last entry is NOT the "create new branch" option
1748 let last_match = picker.delegate.matches.last().unwrap();
1749 assert!(!last_match.is_new_url());
1750 picker.delegate.branch_filter = BranchFilter::Remote;
1751 picker
1752 .delegate
1753 .update_matches(String::from("fork"), window, cx)
1754 })
1755 })
1756 .await;
1757 cx.run_until_parked();
1758
1759 branch_list.update(cx, |branch_list, cx| {
1760 branch_list.picker.update(cx, |picker, _cx| {
1761 // Should have 1 existing branch + 1 "create new branch" entry = 2 total
1762 assert_eq!(picker.delegate.matches.len(), 2);
1763 assert!(
1764 picker
1765 .delegate
1766 .matches
1767 .iter()
1768 .any(|m| m.name() == "fork/feature-auth")
1769 );
1770 // Verify the last entry is the "create new branch" option
1771 let last_match = picker.delegate.matches.last().unwrap();
1772 assert!(last_match.is_new_branch());
1773 })
1774 });
1775 }
1776
1777 #[gpui::test]
1778 async fn test_new_branch_creation_with_query(test_cx: &mut TestAppContext) {
1779 const MAIN_BRANCH: &str = "main";
1780 const FEATURE_BRANCH: &str = "feature";
1781 const NEW_BRANCH: &str = "new-feature-branch";
1782
1783 init_test(test_cx);
1784 let (_project, repository) = init_fake_repository(test_cx).await;
1785
1786 let branches = vec![
1787 create_test_branch(MAIN_BRANCH, true, None, Some(1000)),
1788 create_test_branch(FEATURE_BRANCH, false, None, Some(900)),
1789 ];
1790
1791 let (branch_list, mut ctx) =
1792 init_branch_list_test(repository.into(), branches, test_cx).await;
1793 let cx = &mut ctx;
1794
1795 branch_list
1796 .update_in(cx, |branch_list, window, cx| {
1797 branch_list.picker.update(cx, |picker, cx| {
1798 picker
1799 .delegate
1800 .update_matches(NEW_BRANCH.to_string(), window, cx)
1801 })
1802 })
1803 .await;
1804
1805 cx.run_until_parked();
1806
1807 branch_list.update_in(cx, |branch_list, window, cx| {
1808 branch_list.picker.update(cx, |picker, cx| {
1809 let last_match = picker.delegate.matches.last().unwrap();
1810 assert!(last_match.is_new_branch());
1811 assert_eq!(last_match.name(), NEW_BRANCH);
1812 // State is NewBranch because no existing branches fuzzy-match the query
1813 assert!(matches!(picker.delegate.state, PickerState::NewBranch));
1814 picker.delegate.confirm(false, window, cx);
1815 })
1816 });
1817 cx.run_until_parked();
1818
1819 let branches = branch_list
1820 .update(cx, |branch_list, cx| {
1821 branch_list.picker.update(cx, |picker, cx| {
1822 picker
1823 .delegate
1824 .repo
1825 .as_ref()
1826 .unwrap()
1827 .update(cx, |repo, _cx| repo.branches())
1828 })
1829 })
1830 .await
1831 .unwrap()
1832 .unwrap();
1833
1834 let new_branch = branches
1835 .into_iter()
1836 .find(|branch| branch.name() == NEW_BRANCH)
1837 .expect("new-feature-branch should exist");
1838 assert_eq!(
1839 new_branch.ref_name.as_ref(),
1840 &format!("refs/heads/{NEW_BRANCH}"),
1841 "branch ref_name should not have duplicate refs/heads/ prefix"
1842 );
1843 }
1844
1845 #[gpui::test]
1846 async fn test_remote_url_detection_https(cx: &mut TestAppContext) {
1847 init_test(cx);
1848 let (_project, repository) = init_fake_repository(cx).await;
1849 let branches = vec![create_test_branch("main", true, None, Some(1000))];
1850
1851 let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1852 let cx = &mut ctx;
1853
1854 branch_list
1855 .update_in(cx, |branch_list, window, cx| {
1856 branch_list.picker.update(cx, |picker, cx| {
1857 let query = "https://github.com/user/repo.git".to_string();
1858 picker.delegate.update_matches(query, window, cx)
1859 })
1860 })
1861 .await;
1862
1863 cx.run_until_parked();
1864
1865 branch_list
1866 .update_in(cx, |branch_list, window, cx| {
1867 branch_list.picker.update(cx, |picker, cx| {
1868 let last_match = picker.delegate.matches.last().unwrap();
1869 assert!(last_match.is_new_url());
1870 assert!(matches!(picker.delegate.state, PickerState::NewRemote));
1871 picker.delegate.confirm(false, window, cx);
1872 assert_eq!(picker.delegate.matches.len(), 0);
1873 if let PickerState::CreateRemote(remote_url) = &picker.delegate.state
1874 && remote_url.as_ref() == "https://github.com/user/repo.git"
1875 {
1876 } else {
1877 panic!("wrong picker state");
1878 }
1879 picker
1880 .delegate
1881 .update_matches("my_new_remote".to_string(), window, cx)
1882 })
1883 })
1884 .await;
1885
1886 cx.run_until_parked();
1887
1888 branch_list.update_in(cx, |branch_list, window, cx| {
1889 branch_list.picker.update(cx, |picker, cx| {
1890 assert_eq!(picker.delegate.matches.len(), 1);
1891 assert!(matches!(
1892 picker.delegate.matches.first(),
1893 Some(Entry::NewRemoteName { name, url })
1894 if name == "my_new_remote" && url.as_ref() == "https://github.com/user/repo.git"
1895 ));
1896 picker.delegate.confirm(false, window, cx);
1897 })
1898 });
1899 cx.run_until_parked();
1900
1901 // List remotes
1902 let remotes = branch_list
1903 .update(cx, |branch_list, cx| {
1904 branch_list.picker.update(cx, |picker, cx| {
1905 picker
1906 .delegate
1907 .repo
1908 .as_ref()
1909 .unwrap()
1910 .update(cx, |repo, _cx| repo.get_remotes(None, false))
1911 })
1912 })
1913 .await
1914 .unwrap()
1915 .unwrap();
1916 assert_eq!(
1917 remotes,
1918 vec![Remote {
1919 name: SharedString::from("my_new_remote".to_string())
1920 }]
1921 );
1922 }
1923
1924 #[gpui::test]
1925 async fn test_confirm_remote_url_transitions(cx: &mut TestAppContext) {
1926 init_test(cx);
1927
1928 let branches = vec![create_test_branch("main_branch", true, None, Some(1000))];
1929 let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1930 let cx = &mut ctx;
1931
1932 branch_list
1933 .update_in(cx, |branch_list, window, cx| {
1934 branch_list.picker.update(cx, |picker, cx| {
1935 let query = "https://github.com/user/repo.git".to_string();
1936 picker.delegate.update_matches(query, window, cx)
1937 })
1938 })
1939 .await;
1940 cx.run_until_parked();
1941
1942 // Try to create a new remote but cancel in the middle of the process
1943 branch_list
1944 .update_in(cx, |branch_list, window, cx| {
1945 branch_list.picker.update(cx, |picker, cx| {
1946 picker.delegate.selected_index = picker.delegate.matches.len() - 1;
1947 picker.delegate.confirm(false, window, cx);
1948
1949 assert!(matches!(
1950 picker.delegate.state,
1951 PickerState::CreateRemote(_)
1952 ));
1953 if let PickerState::CreateRemote(ref url) = picker.delegate.state {
1954 assert_eq!(url.as_ref(), "https://github.com/user/repo.git");
1955 }
1956 assert_eq!(picker.delegate.matches.len(), 0);
1957 picker.delegate.dismissed(window, cx);
1958 assert!(matches!(picker.delegate.state, PickerState::List));
1959 let query = "main".to_string();
1960 picker.delegate.update_matches(query, window, cx)
1961 })
1962 })
1963 .await;
1964 cx.run_until_parked();
1965
1966 // Try to search a branch again to see if the state is restored properly
1967 branch_list.update(cx, |branch_list, cx| {
1968 branch_list.picker.update(cx, |picker, _cx| {
1969 // Should have 1 existing branch + 1 "create new branch" entry = 2 total
1970 assert_eq!(picker.delegate.matches.len(), 2);
1971 assert!(
1972 picker
1973 .delegate
1974 .matches
1975 .iter()
1976 .any(|m| m.name() == "main_branch")
1977 );
1978 // Verify the last entry is the "create new branch" option
1979 let last_match = picker.delegate.matches.last().unwrap();
1980 assert!(last_match.is_new_branch());
1981 })
1982 });
1983 }
1984
1985 #[gpui::test]
1986 async fn test_confirm_remote_url_does_not_dismiss(cx: &mut TestAppContext) {
1987 const REMOTE_URL: &str = "https://github.com/user/repo.git";
1988
1989 init_test(cx);
1990 let branches = vec![create_test_branch("main", true, None, Some(1000))];
1991
1992 let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1993 let cx = &mut ctx;
1994
1995 let subscription = cx.update(|_, cx| {
1996 cx.subscribe(&branch_list, |_, _: &DismissEvent, _| {
1997 panic!("DismissEvent should not be emitted when confirming a remote URL");
1998 })
1999 });
2000
2001 branch_list
2002 .update_in(cx, |branch_list, window, cx| {
2003 window.focus(&branch_list.picker_focus_handle, cx);
2004 assert!(
2005 branch_list.picker_focus_handle.is_focused(window),
2006 "Branch picker should be focused when selecting an entry"
2007 );
2008
2009 branch_list.picker.update(cx, |picker, cx| {
2010 picker
2011 .delegate
2012 .update_matches(REMOTE_URL.to_string(), window, cx)
2013 })
2014 })
2015 .await;
2016
2017 cx.run_until_parked();
2018
2019 branch_list.update_in(cx, |branch_list, window, cx| {
2020 // Re-focus the picker since workspace initialization during run_until_parked
2021 window.focus(&branch_list.picker_focus_handle, cx);
2022
2023 branch_list.picker.update(cx, |picker, cx| {
2024 let last_match = picker.delegate.matches.last().unwrap();
2025 assert!(last_match.is_new_url());
2026 assert!(matches!(picker.delegate.state, PickerState::NewRemote));
2027
2028 picker.delegate.confirm(false, window, cx);
2029
2030 assert!(
2031 matches!(picker.delegate.state, PickerState::CreateRemote(ref url) if url.as_ref() == REMOTE_URL),
2032 "State should transition to CreateRemote with the URL"
2033 );
2034 });
2035
2036 assert!(
2037 branch_list.picker_focus_handle.is_focused(window),
2038 "Branch list picker should still be focused after confirming remote URL"
2039 );
2040 });
2041
2042 cx.run_until_parked();
2043
2044 drop(subscription);
2045 }
2046
2047 #[gpui::test(iterations = 10)]
2048 async fn test_empty_query_displays_all_branches(mut rng: StdRng, cx: &mut TestAppContext) {
2049 init_test(cx);
2050 let branch_count = rng.random_range(13..540);
2051
2052 let branches: Vec<Branch> = (0..branch_count)
2053 .map(|i| create_test_branch(&format!("branch-{:02}", i), i == 0, None, Some(i * 100)))
2054 .collect();
2055
2056 let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
2057 let cx = &mut ctx;
2058
2059 update_branch_list_matches_with_empty_query(&branch_list, cx).await;
2060
2061 branch_list.update(cx, |branch_list, cx| {
2062 branch_list.picker.update(cx, |picker, _cx| {
2063 assert_eq!(picker.delegate.matches.len(), branch_count as usize);
2064 })
2065 });
2066 }
2067}