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 let focus_handle = self.focus_handle.clone();
555
556 v_flex()
557 .when(
558 self.editor_position() == PickerEditorPosition::End,
559 |this| this.child(Divider::horizontal()),
560 )
561 .child(
562 h_flex()
563 .overflow_hidden()
564 .flex_none()
565 .h_9()
566 .px_2p5()
567 .child(editor.clone())
568 .when(
569 self.editor_position() == PickerEditorPosition::End,
570 |this| {
571 let tooltip_label = if self.display_remotes {
572 "Turn Off Remote Filter"
573 } else {
574 "Filter Remote Branches"
575 };
576
577 this.gap_1().justify_between().child({
578 IconButton::new("filter-remotes", IconName::Filter)
579 .disabled(self.loading)
580 .toggle_state(self.display_remotes)
581 .tooltip(move |_, cx| {
582 Tooltip::for_action_in(
583 tooltip_label,
584 &branch_picker::FilterRemotes,
585 &focus_handle,
586 cx,
587 )
588 })
589 .on_click(|_click, window, cx| {
590 window.dispatch_action(
591 branch_picker::FilterRemotes.boxed_clone(),
592 cx,
593 );
594 })
595 })
596 },
597 ),
598 )
599 .when(
600 self.editor_position() == PickerEditorPosition::Start,
601 |this| this.child(Divider::horizontal()),
602 )
603 }
604
605 fn editor_position(&self) -> PickerEditorPosition {
606 match self.style {
607 BranchListStyle::Modal => PickerEditorPosition::Start,
608 BranchListStyle::Popover => PickerEditorPosition::End,
609 }
610 }
611
612 fn match_count(&self) -> usize {
613 self.matches.len()
614 }
615
616 fn selected_index(&self) -> usize {
617 self.selected_index
618 }
619
620 fn set_selected_index(
621 &mut self,
622 ix: usize,
623 _window: &mut Window,
624 _: &mut Context<Picker<Self>>,
625 ) {
626 self.selected_index = ix;
627 }
628
629 fn update_matches(
630 &mut self,
631 query: String,
632 window: &mut Window,
633 cx: &mut Context<Picker<Self>>,
634 ) -> Task<()> {
635 let Some(all_branches) = self.all_branches.clone() else {
636 return Task::ready(());
637 };
638
639 const RECENT_BRANCHES_COUNT: usize = 10;
640 let display_remotes = self.display_remotes;
641 cx.spawn_in(window, async move |picker, cx| {
642 let mut matches: Vec<Entry> = if query.is_empty() {
643 all_branches
644 .into_iter()
645 .filter(|branch| {
646 if display_remotes {
647 branch.is_remote()
648 } else {
649 !branch.is_remote()
650 }
651 })
652 .take(RECENT_BRANCHES_COUNT)
653 .map(|branch| Entry::Branch {
654 branch,
655 positions: Vec::new(),
656 })
657 .collect()
658 } else {
659 let branches = all_branches
660 .iter()
661 .filter(|branch| {
662 if display_remotes {
663 branch.is_remote()
664 } else {
665 !branch.is_remote()
666 }
667 })
668 .collect::<Vec<_>>();
669 let candidates = branches
670 .iter()
671 .enumerate()
672 .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
673 .collect::<Vec<StringMatchCandidate>>();
674 fuzzy::match_strings(
675 &candidates,
676 &query,
677 true,
678 true,
679 10000,
680 &Default::default(),
681 cx.background_executor().clone(),
682 )
683 .await
684 .into_iter()
685 .map(|candidate| Entry::Branch {
686 branch: branches[candidate.candidate_id].clone(),
687 positions: candidate.positions,
688 })
689 .collect()
690 };
691 picker
692 .update(cx, |picker, _| {
693 if matches!(picker.delegate.state, PickerState::CreateRemote(_)) {
694 picker.delegate.last_query = query;
695 picker.delegate.matches = Vec::new();
696 picker.delegate.selected_index = 0;
697
698 return;
699 }
700
701 if !query.is_empty()
702 && !matches.first().is_some_and(|entry| entry.name() == query)
703 {
704 let query = query.replace(' ', "-");
705 let is_url = query.trim_start_matches("git@").parse::<Url>().is_ok();
706 let entry = if is_url {
707 Entry::NewUrl { url: query }
708 } else {
709 Entry::NewBranch { name: query }
710 };
711 // Only transition to NewBranch/NewRemote states when we only show their list item
712 // Otherwise, stay in List state so footer buttons remain visible
713 picker.delegate.state = if matches.is_empty() {
714 if is_url {
715 PickerState::NewRemote
716 } else {
717 PickerState::NewBranch
718 }
719 } else {
720 PickerState::List
721 };
722 matches.push(entry);
723 } else {
724 picker.delegate.state = PickerState::List;
725 }
726 let delegate = &mut picker.delegate;
727 delegate.matches = matches;
728 if delegate.matches.is_empty() {
729 delegate.selected_index = 0;
730 } else {
731 delegate.selected_index =
732 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
733 }
734 delegate.last_query = query;
735 })
736 .log_err();
737 })
738 }
739
740 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
741 if let PickerState::CreateRemote(remote_url) = &self.state {
742 self.create_remote(self.last_query.clone(), remote_url.to_string(), window, cx);
743 self.state = PickerState::List;
744 cx.notify();
745 return;
746 }
747
748 let Some(entry) = self.matches.get(self.selected_index()) else {
749 return;
750 };
751
752 match entry {
753 Entry::Branch { branch, .. } => {
754 let current_branch = self.repo.as_ref().map(|repo| {
755 repo.read_with(cx, |repo, _| {
756 repo.branch.as_ref().map(|branch| branch.ref_name.clone())
757 })
758 });
759
760 if current_branch
761 .flatten()
762 .is_some_and(|current_branch| current_branch == branch.ref_name)
763 {
764 cx.emit(DismissEvent);
765 return;
766 }
767
768 let Some(repo) = self.repo.clone() else {
769 return;
770 };
771
772 let branch = branch.clone();
773 cx.spawn(async move |_, cx| {
774 repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))?
775 .await??;
776
777 anyhow::Ok(())
778 })
779 .detach_and_prompt_err(
780 "Failed to change branch",
781 window,
782 cx,
783 |_, _, _| None,
784 );
785 }
786 Entry::NewUrl { url } => {
787 self.state = PickerState::CreateRemote(url.clone().into());
788 self.matches = Vec::new();
789 self.selected_index = 0;
790 cx.spawn_in(window, async move |this, cx| {
791 this.update_in(cx, |picker, window, cx| {
792 picker.set_query("", window, cx);
793 })
794 })
795 .detach_and_log_err(cx);
796 cx.notify();
797 }
798 Entry::NewBranch { name } => {
799 let from_branch = if secondary {
800 self.default_branch.clone()
801 } else {
802 None
803 };
804 self.create_branch(from_branch, name.into(), window, cx);
805 }
806 }
807
808 cx.emit(DismissEvent);
809 }
810
811 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
812 self.state = PickerState::List;
813 cx.emit(DismissEvent);
814 }
815
816 fn render_match(
817 &self,
818 ix: usize,
819 selected: bool,
820 _window: &mut Window,
821 cx: &mut Context<Picker<Self>>,
822 ) -> Option<Self::ListItem> {
823 let entry = &self.matches.get(ix)?;
824
825 let (commit_time, author_name, subject) = entry
826 .as_branch()
827 .and_then(|branch| {
828 branch.most_recent_commit.as_ref().map(|commit| {
829 let subject = commit.subject.clone();
830 let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
831 .unwrap_or_else(|_| OffsetDateTime::now_utc());
832 let local_offset =
833 time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
834 let formatted_time = time_format::format_localized_timestamp(
835 commit_time,
836 OffsetDateTime::now_utc(),
837 local_offset,
838 time_format::TimestampFormat::Relative,
839 );
840 let author = commit.author_name.clone();
841 (Some(formatted_time), Some(author), Some(subject))
842 })
843 })
844 .unwrap_or_else(|| (None, None, None));
845
846 let entry_icon = match entry {
847 Entry::NewUrl { .. } | Entry::NewBranch { .. } => {
848 Icon::new(IconName::Plus).color(Color::Muted)
849 }
850
851 Entry::Branch { .. } => {
852 if self.display_remotes {
853 Icon::new(IconName::Screen).color(Color::Muted)
854 } else {
855 Icon::new(IconName::GitBranchAlt).color(Color::Muted)
856 }
857 }
858 };
859
860 let entry_title = match entry {
861 Entry::NewUrl { .. } => Label::new("Create Remote Repository")
862 .single_line()
863 .truncate()
864 .into_any_element(),
865 Entry::NewBranch { name } => Label::new(format!("Create Branch: \"{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!(entry, Entry::NewUrl { .. } | Entry::NewBranch { .. });
879
880 let delete_branch_button = IconButton::new("delete", IconName::Trash)
881 .tooltip(move |_, cx| {
882 Tooltip::for_action_in(
883 "Delete Branch",
884 &branch_picker::DeleteBranch,
885 &focus_handle,
886 cx,
887 )
888 })
889 .on_click(cx.listener(|this, _, window, cx| {
890 let selected_idx = this.delegate.selected_index();
891 this.delegate.delete_at(selected_idx, window, cx);
892 }));
893
894 let create_from_default_button = self.default_branch.as_ref().map(|default_branch| {
895 let tooltip_label: SharedString = format!("Create New From: {default_branch}").into();
896 let focus_handle = self.focus_handle.clone();
897
898 IconButton::new("create_from_default", IconName::GitBranchPlus)
899 .tooltip(move |_, cx| {
900 Tooltip::for_action_in(
901 tooltip_label.clone(),
902 &menu::SecondaryConfirm,
903 &focus_handle,
904 cx,
905 )
906 })
907 .on_click(cx.listener(|this, _, window, cx| {
908 this.delegate.confirm(true, window, cx);
909 }))
910 .into_any_element()
911 });
912
913 Some(
914 ListItem::new(format!("vcs-menu-{ix}"))
915 .inset(true)
916 .spacing(ListItemSpacing::Sparse)
917 .toggle_state(selected)
918 .child(
919 h_flex()
920 .w_full()
921 .gap_3()
922 .flex_grow()
923 .child(entry_icon)
924 .child(
925 v_flex()
926 .id("info_container")
927 .w_full()
928 .child(entry_title)
929 .child(
930 h_flex()
931 .w_full()
932 .justify_between()
933 .gap_1p5()
934 .when(self.style == BranchListStyle::Modal, |el| {
935 el.child(div().max_w_96().child({
936 let message = match entry {
937 Entry::NewUrl { url } => {
938 format!("Based off {url}")
939 }
940 Entry::NewBranch { .. } => {
941 if let Some(current_branch) =
942 self.repo.as_ref().and_then(|repo| {
943 repo.read(cx)
944 .branch
945 .as_ref()
946 .map(|b| b.name())
947 })
948 {
949 format!("Based off {}", current_branch)
950 } else {
951 "Based off the current branch"
952 .to_string()
953 }
954 }
955 Entry::Branch { .. } => {
956 let show_author_name =
957 ProjectSettings::get_global(cx)
958 .git
959 .branch_picker
960 .show_author_name;
961
962 subject.map_or(
963 "No commits found".into(),
964 |subject| {
965 if show_author_name
966 && author_name.is_some()
967 {
968 format!(
969 "{} • {}",
970 author_name.unwrap(),
971 subject
972 )
973 } else {
974 subject.to_string()
975 }
976 },
977 )
978 }
979 };
980
981 Label::new(message)
982 .size(LabelSize::Small)
983 .color(Color::Muted)
984 .truncate()
985 }))
986 })
987 .when_some(commit_time, |label, commit_time| {
988 label.child(
989 Label::new(commit_time)
990 .size(LabelSize::Small)
991 .color(Color::Muted),
992 )
993 }),
994 )
995 .when_some(
996 entry.as_branch().map(|b| b.name().to_string()),
997 |this, branch_name| this.tooltip(Tooltip::text(branch_name)),
998 ),
999 ),
1000 )
1001 .when(
1002 self.editor_position() == PickerEditorPosition::End && !is_new_items,
1003 |this| {
1004 this.map(|this| {
1005 if self.selected_index() == ix {
1006 this.end_slot(delete_branch_button)
1007 } else {
1008 this.end_hover_slot(delete_branch_button)
1009 }
1010 })
1011 },
1012 )
1013 .when_some(
1014 if self.editor_position() == PickerEditorPosition::End && is_new_items {
1015 create_from_default_button
1016 } else {
1017 None
1018 },
1019 |this, create_from_default_button| {
1020 this.map(|this| {
1021 if self.selected_index() == ix {
1022 this.end_slot(create_from_default_button)
1023 } else {
1024 this.end_hover_slot(create_from_default_button)
1025 }
1026 })
1027 },
1028 ),
1029 )
1030 }
1031
1032 fn render_header(
1033 &self,
1034 _window: &mut Window,
1035 _cx: &mut Context<Picker<Self>>,
1036 ) -> Option<AnyElement> {
1037 matches!(self.state, PickerState::List).then(|| {
1038 let label = if self.display_remotes {
1039 "Remote"
1040 } else {
1041 "Local"
1042 };
1043
1044 ListHeader::new(label).inset(true).into_any_element()
1045 })
1046 }
1047
1048 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1049 if self.editor_position() == PickerEditorPosition::End {
1050 return None;
1051 }
1052
1053 let focus_handle = self.focus_handle.clone();
1054 let loading_icon = Icon::new(IconName::LoadCircle)
1055 .size(IconSize::Small)
1056 .with_rotate_animation(3);
1057
1058 let footer_container = || {
1059 h_flex()
1060 .w_full()
1061 .p_1p5()
1062 .border_t_1()
1063 .border_color(cx.theme().colors().border_variant)
1064 };
1065
1066 match self.state {
1067 PickerState::List => {
1068 let selected_entry = self.matches.get(self.selected_index);
1069
1070 let branch_from_default_button = self
1071 .default_branch
1072 .as_ref()
1073 .filter(|_| matches!(selected_entry, Some(Entry::NewBranch { .. })))
1074 .map(|default_branch| {
1075 let button_label = format!("Create New From: {default_branch}");
1076
1077 Button::new("branch-from-default", button_label)
1078 .key_binding(
1079 KeyBinding::for_action_in(
1080 &menu::SecondaryConfirm,
1081 &focus_handle,
1082 cx,
1083 )
1084 .map(|kb| kb.size(rems_from_px(12.))),
1085 )
1086 .on_click(cx.listener(|this, _, window, cx| {
1087 this.delegate.confirm(true, window, cx);
1088 }))
1089 });
1090
1091 let delete_and_select_btns = h_flex()
1092 .gap_1()
1093 .child(
1094 Button::new("delete-branch", "Delete")
1095 .disabled(self.loading)
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 if self.loading {
1144 this.justify_between()
1145 .child(loading_icon)
1146 .child(delete_and_select_btns)
1147 } else {
1148 this.justify_between()
1149 .child({
1150 let focus_handle = focus_handle.clone();
1151 Button::new("filter-remotes", "Filter Remotes")
1152 .disabled(self.loading)
1153 .toggle_state(self.display_remotes)
1154 .key_binding(
1155 KeyBinding::for_action_in(
1156 &branch_picker::FilterRemotes,
1157 &focus_handle,
1158 cx,
1159 )
1160 .map(|kb| kb.size(rems_from_px(12.))),
1161 )
1162 .on_click(|_click, window, cx| {
1163 window.dispatch_action(
1164 branch_picker::FilterRemotes.boxed_clone(),
1165 cx,
1166 );
1167 })
1168 })
1169 .child(delete_and_select_btns)
1170 }
1171 })
1172 .into_any_element(),
1173 )
1174 }
1175 PickerState::NewBranch => {
1176 let branch_from_default_button =
1177 self.default_branch.as_ref().map(|default_branch| {
1178 let button_label = format!("Create New From: {default_branch}");
1179
1180 Button::new("branch-from-default", button_label)
1181 .key_binding(
1182 KeyBinding::for_action_in(
1183 &menu::SecondaryConfirm,
1184 &focus_handle,
1185 cx,
1186 )
1187 .map(|kb| kb.size(rems_from_px(12.))),
1188 )
1189 .on_click(cx.listener(|this, _, window, cx| {
1190 this.delegate.confirm(true, window, cx);
1191 }))
1192 });
1193
1194 Some(
1195 footer_container()
1196 .gap_1()
1197 .justify_end()
1198 .when_some(branch_from_default_button, |this, button| {
1199 this.child(button)
1200 })
1201 .child(
1202 Button::new("branch-from-default", "Create")
1203 .key_binding(
1204 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1205 .map(|kb| kb.size(rems_from_px(12.))),
1206 )
1207 .on_click(cx.listener(|this, _, window, cx| {
1208 this.delegate.confirm(false, window, cx);
1209 })),
1210 )
1211 .into_any_element(),
1212 )
1213 }
1214 PickerState::CreateRemote(_) => Some(
1215 footer_container()
1216 .justify_end()
1217 .child(
1218 Label::new("Choose a name for this remote repository")
1219 .size(LabelSize::Small)
1220 .color(Color::Muted),
1221 )
1222 .child(
1223 Label::new("Save")
1224 .size(LabelSize::Small)
1225 .color(Color::Muted),
1226 )
1227 .into_any_element(),
1228 ),
1229 PickerState::NewRemote => None,
1230 }
1231 }
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236 use std::collections::HashSet;
1237
1238 use super::*;
1239 use git::repository::{CommitSummary, Remote};
1240 use gpui::{TestAppContext, VisualTestContext};
1241 use project::{FakeFs, Project};
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 cx: &mut TestAppContext,
1290 repository: Option<Entity<Repository>>,
1291 branches: Vec<Branch>,
1292 ) -> (VisualTestContext, Entity<BranchList>) {
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 (cx, branch_list)
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 (mut ctx, branch_list) = init_branch_list_test(cx, None, branches);
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 (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches);
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 (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches);
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.display_remotes = true;
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_update_remote_matches_with_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 (mut ctx, branch_list) = init_branch_list_test(cx, None, branches);
1552 let cx = &mut ctx;
1553
1554 update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1555
1556 // Check matches, it should match all existing branches and no option to create new branch
1557 branch_list
1558 .update_in(cx, |branch_list, window, cx| {
1559 branch_list.picker.update(cx, |picker, cx| {
1560 assert_eq!(picker.delegate.matches.len(), 2);
1561 let branches = picker
1562 .delegate
1563 .matches
1564 .iter()
1565 .map(|be| be.name())
1566 .collect::<HashSet<_>>();
1567 assert_eq!(
1568 branches,
1569 ["feature-ui", "develop"]
1570 .into_iter()
1571 .collect::<HashSet<_>>()
1572 );
1573
1574 // Verify the last entry is NOT the "create new branch" option
1575 let last_match = picker.delegate.matches.last().unwrap();
1576 assert!(!last_match.is_new_branch());
1577 assert!(!last_match.is_new_url());
1578 picker.delegate.display_remotes = true;
1579 picker.delegate.update_matches(String::new(), window, cx)
1580 })
1581 })
1582 .await;
1583 cx.run_until_parked();
1584
1585 branch_list
1586 .update_in(cx, |branch_list, window, cx| {
1587 branch_list.picker.update(cx, |picker, cx| {
1588 assert_eq!(picker.delegate.matches.len(), 2);
1589 let branches = picker
1590 .delegate
1591 .matches
1592 .iter()
1593 .map(|be| be.name())
1594 .collect::<HashSet<_>>();
1595 assert_eq!(
1596 branches,
1597 ["origin/main", "fork/feature-auth"]
1598 .into_iter()
1599 .collect::<HashSet<_>>()
1600 );
1601
1602 // Verify the last entry is NOT the "create new branch" option
1603 let last_match = picker.delegate.matches.last().unwrap();
1604 assert!(!last_match.is_new_url());
1605 picker.delegate.display_remotes = true;
1606 picker
1607 .delegate
1608 .update_matches(String::from("fork"), window, cx)
1609 })
1610 })
1611 .await;
1612 cx.run_until_parked();
1613
1614 branch_list.update(cx, |branch_list, cx| {
1615 branch_list.picker.update(cx, |picker, _cx| {
1616 // Should have 1 existing branch + 1 "create new branch" entry = 2 total
1617 assert_eq!(picker.delegate.matches.len(), 2);
1618 assert!(
1619 picker
1620 .delegate
1621 .matches
1622 .iter()
1623 .any(|m| m.name() == "fork/feature-auth")
1624 );
1625 // Verify the last entry is the "create new branch" option
1626 let last_match = picker.delegate.matches.last().unwrap();
1627 assert!(last_match.is_new_branch());
1628 })
1629 });
1630 }
1631
1632 #[gpui::test]
1633 async fn test_new_branch_creation_with_query(test_cx: &mut TestAppContext) {
1634 init_test(test_cx);
1635 let repository = init_fake_repository(test_cx).await;
1636
1637 let branches = vec![
1638 create_test_branch("main", true, None, Some(1000)),
1639 create_test_branch("feature", false, None, Some(900)),
1640 ];
1641
1642 let (mut ctx, branch_list) = init_branch_list_test(test_cx, repository.into(), branches);
1643 let cx = &mut ctx;
1644
1645 branch_list
1646 .update_in(cx, |branch_list, window, cx| {
1647 branch_list.picker.update(cx, |picker, cx| {
1648 let query = "new-feature-branch".to_string();
1649 picker.delegate.update_matches(query, window, cx)
1650 })
1651 })
1652 .await;
1653
1654 cx.run_until_parked();
1655
1656 branch_list.update_in(cx, |branch_list, window, cx| {
1657 branch_list.picker.update(cx, |picker, cx| {
1658 let last_match = picker.delegate.matches.last().unwrap();
1659 assert!(last_match.is_new_branch());
1660 assert_eq!(last_match.name(), "new-feature-branch");
1661 // State is NewBranch because no existing branches fuzzy-match the query
1662 assert!(matches!(picker.delegate.state, PickerState::NewBranch));
1663 picker.delegate.confirm(false, window, cx);
1664 })
1665 });
1666 cx.run_until_parked();
1667
1668 let branches = branch_list
1669 .update(cx, |branch_list, cx| {
1670 branch_list.picker.update(cx, |picker, cx| {
1671 picker
1672 .delegate
1673 .repo
1674 .as_ref()
1675 .unwrap()
1676 .update(cx, |repo, _cx| repo.branches())
1677 })
1678 })
1679 .await
1680 .unwrap()
1681 .unwrap();
1682
1683 let new_branch = branches
1684 .into_iter()
1685 .find(|branch| branch.name() == "new-feature-branch")
1686 .expect("new-feature-branch should exist");
1687 assert_eq!(
1688 new_branch.ref_name.as_ref(),
1689 "refs/heads/new-feature-branch",
1690 "branch ref_name should not have duplicate refs/heads/ prefix"
1691 );
1692 }
1693
1694 #[gpui::test]
1695 async fn test_remote_url_detection_https(cx: &mut TestAppContext) {
1696 init_test(cx);
1697 let repository = init_fake_repository(cx).await;
1698 let branches = vec![create_test_branch("main", true, None, Some(1000))];
1699
1700 let (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches);
1701 let cx = &mut ctx;
1702
1703 branch_list
1704 .update_in(cx, |branch_list, window, cx| {
1705 branch_list.picker.update(cx, |picker, cx| {
1706 let query = "https://github.com/user/repo.git".to_string();
1707 picker.delegate.update_matches(query, window, cx)
1708 })
1709 })
1710 .await;
1711
1712 cx.run_until_parked();
1713
1714 branch_list
1715 .update_in(cx, |branch_list, window, cx| {
1716 branch_list.picker.update(cx, |picker, cx| {
1717 let last_match = picker.delegate.matches.last().unwrap();
1718 assert!(last_match.is_new_url());
1719 assert!(matches!(picker.delegate.state, PickerState::NewRemote));
1720 picker.delegate.confirm(false, window, cx);
1721 assert_eq!(picker.delegate.matches.len(), 0);
1722 if let PickerState::CreateRemote(remote_url) = &picker.delegate.state
1723 && remote_url.as_ref() == "https://github.com/user/repo.git"
1724 {
1725 } else {
1726 panic!("wrong picker state");
1727 }
1728 picker
1729 .delegate
1730 .update_matches("my_new_remote".to_string(), window, cx)
1731 })
1732 })
1733 .await;
1734
1735 cx.run_until_parked();
1736
1737 branch_list.update_in(cx, |branch_list, window, cx| {
1738 branch_list.picker.update(cx, |picker, cx| {
1739 picker.delegate.confirm(false, window, cx);
1740 assert_eq!(picker.delegate.matches.len(), 0);
1741 })
1742 });
1743 cx.run_until_parked();
1744
1745 // List remotes
1746 let remotes = branch_list
1747 .update(cx, |branch_list, cx| {
1748 branch_list.picker.update(cx, |picker, cx| {
1749 picker
1750 .delegate
1751 .repo
1752 .as_ref()
1753 .unwrap()
1754 .update(cx, |repo, _cx| repo.get_remotes(None, false))
1755 })
1756 })
1757 .await
1758 .unwrap()
1759 .unwrap();
1760 assert_eq!(
1761 remotes,
1762 vec![Remote {
1763 name: SharedString::from("my_new_remote".to_string())
1764 }]
1765 );
1766 }
1767
1768 #[gpui::test]
1769 async fn test_confirm_remote_url_transitions(cx: &mut TestAppContext) {
1770 init_test(cx);
1771
1772 let branches = vec![create_test_branch("main_branch", true, None, Some(1000))];
1773 let (mut ctx, branch_list) = init_branch_list_test(cx, None, branches);
1774 let cx = &mut ctx;
1775
1776 branch_list
1777 .update_in(cx, |branch_list, window, cx| {
1778 branch_list.picker.update(cx, |picker, cx| {
1779 let query = "https://github.com/user/repo.git".to_string();
1780 picker.delegate.update_matches(query, window, cx)
1781 })
1782 })
1783 .await;
1784 cx.run_until_parked();
1785
1786 // Try to create a new remote but cancel in the middle of the process
1787 branch_list
1788 .update_in(cx, |branch_list, window, cx| {
1789 branch_list.picker.update(cx, |picker, cx| {
1790 picker.delegate.selected_index = picker.delegate.matches.len() - 1;
1791 picker.delegate.confirm(false, window, cx);
1792
1793 assert!(matches!(
1794 picker.delegate.state,
1795 PickerState::CreateRemote(_)
1796 ));
1797 if let PickerState::CreateRemote(ref url) = picker.delegate.state {
1798 assert_eq!(url.as_ref(), "https://github.com/user/repo.git");
1799 }
1800 assert_eq!(picker.delegate.matches.len(), 0);
1801 picker.delegate.dismissed(window, cx);
1802 assert!(matches!(picker.delegate.state, PickerState::List));
1803 let query = "main".to_string();
1804 picker.delegate.update_matches(query, window, cx)
1805 })
1806 })
1807 .await;
1808 cx.run_until_parked();
1809
1810 // Try to search a branch again to see if the state is restored properly
1811 branch_list.update(cx, |branch_list, cx| {
1812 branch_list.picker.update(cx, |picker, _cx| {
1813 // Should have 1 existing branch + 1 "create new branch" entry = 2 total
1814 assert_eq!(picker.delegate.matches.len(), 2);
1815 assert!(
1816 picker
1817 .delegate
1818 .matches
1819 .iter()
1820 .any(|m| m.name() == "main_branch")
1821 );
1822 // Verify the last entry is the "create new branch" option
1823 let last_match = picker.delegate.matches.last().unwrap();
1824 assert!(last_match.is_new_branch());
1825 })
1826 });
1827 }
1828}