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