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