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