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