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