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