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