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