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 is_remote = branch.is_remote();
490 repo.update(cx, |repo, _| {
491 repo.delete_branch(is_remote, branch.name().to_string())
492 })
493 .await?
494 }
495 _ => {
496 log::error!("Failed to delete entry: wrong entry to delete");
497 return Ok(());
498 }
499 };
500
501 if let Err(e) = result {
502 if is_remote {
503 log::error!("Failed to delete remote branch: {}", e);
504 } else {
505 log::error!("Failed to delete branch: {}", e);
506 }
507
508 if let Some(workspace) = workspace.upgrade() {
509 cx.update(|_window, cx| {
510 if is_remote {
511 show_error_toast(
512 workspace,
513 format!("branch -dr {}", entry.name()),
514 e,
515 cx,
516 )
517 } else {
518 show_error_toast(
519 workspace,
520 format!("branch -d {}", entry.name()),
521 e,
522 cx,
523 )
524 }
525 })?;
526 }
527
528 return Ok(());
529 }
530
531 picker.update_in(cx, |picker, _, cx| {
532 picker.delegate.matches.retain(|e| e != &entry);
533
534 if let Entry::Branch { branch, .. } = &entry {
535 if let Some(all_branches) = &mut picker.delegate.all_branches {
536 all_branches.retain(|e| e.ref_name != branch.ref_name);
537 }
538 }
539
540 if picker.delegate.matches.is_empty() {
541 picker.delegate.selected_index = 0;
542 } else if picker.delegate.selected_index >= picker.delegate.matches.len() {
543 picker.delegate.selected_index = picker.delegate.matches.len() - 1;
544 }
545
546 cx.notify();
547 })?;
548
549 anyhow::Ok(())
550 })
551 .detach();
552 }
553}
554
555impl PickerDelegate for BranchListDelegate {
556 type ListItem = ListItem;
557
558 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
559 match self.state {
560 PickerState::List | PickerState::NewRemote | PickerState::NewBranch => {
561 match self.branch_filter {
562 BranchFilter::All => "Select branch or remote…",
563 BranchFilter::Remote => "Select remote…",
564 }
565 }
566 PickerState::CreateRemote(_) => "Enter a name for this remote…",
567 }
568 .into()
569 }
570
571 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
572 match self.state {
573 PickerState::CreateRemote(_) => {
574 Some(SharedString::new_static("Remote name can't be empty"))
575 }
576 _ => None,
577 }
578 }
579
580 fn render_editor(
581 &self,
582 editor: &Arc<dyn ErasedEditor>,
583 _window: &mut Window,
584 _cx: &mut Context<Picker<Self>>,
585 ) -> Div {
586 let focus_handle = self.focus_handle.clone();
587 let editor = editor.as_any().downcast_ref::<Entity<Editor>>().unwrap();
588
589 v_flex()
590 .when(
591 self.editor_position() == PickerEditorPosition::End,
592 |this| this.child(Divider::horizontal()),
593 )
594 .child(
595 h_flex()
596 .overflow_hidden()
597 .flex_none()
598 .h_9()
599 .px_2p5()
600 .child(editor.clone())
601 .when(
602 self.editor_position() == PickerEditorPosition::End,
603 |this| {
604 let tooltip_label = match self.branch_filter {
605 BranchFilter::All => "Filter Remote Branches",
606 BranchFilter::Remote => "Show All Branches",
607 };
608
609 this.gap_1().justify_between().child({
610 IconButton::new("filter-remotes", IconName::Filter)
611 .toggle_state(self.branch_filter == BranchFilter::Remote)
612 .tooltip(move |_, cx| {
613 Tooltip::for_action_in(
614 tooltip_label,
615 &branch_picker::FilterRemotes,
616 &focus_handle,
617 cx,
618 )
619 })
620 .on_click(|_click, window, cx| {
621 window.dispatch_action(
622 branch_picker::FilterRemotes.boxed_clone(),
623 cx,
624 );
625 })
626 })
627 },
628 ),
629 )
630 .when(
631 self.editor_position() == PickerEditorPosition::Start,
632 |this| this.child(Divider::horizontal()),
633 )
634 }
635
636 fn editor_position(&self) -> PickerEditorPosition {
637 match self.style {
638 BranchListStyle::Modal => PickerEditorPosition::Start,
639 BranchListStyle::Popover => PickerEditorPosition::End,
640 }
641 }
642
643 fn match_count(&self) -> usize {
644 self.matches.len()
645 }
646
647 fn selected_index(&self) -> usize {
648 self.selected_index
649 }
650
651 fn set_selected_index(
652 &mut self,
653 ix: usize,
654 _window: &mut Window,
655 _: &mut Context<Picker<Self>>,
656 ) {
657 self.selected_index = ix;
658 }
659
660 fn update_matches(
661 &mut self,
662 query: String,
663 window: &mut Window,
664 cx: &mut Context<Picker<Self>>,
665 ) -> Task<()> {
666 let Some(all_branches) = self.all_branches.clone() else {
667 return Task::ready(());
668 };
669
670 let branch_filter = self.branch_filter;
671 cx.spawn_in(window, async move |picker, cx| {
672 let branch_matches_filter = |branch: &Branch| match branch_filter {
673 BranchFilter::All => true,
674 BranchFilter::Remote => branch.is_remote(),
675 };
676
677 let mut matches: Vec<Entry> = if query.is_empty() {
678 let mut matches: Vec<Entry> = all_branches
679 .into_iter()
680 .filter(|branch| branch_matches_filter(branch))
681 .map(|branch| Entry::Branch {
682 branch,
683 positions: Vec::new(),
684 })
685 .collect();
686
687 // Keep the existing recency sort within each group, but show local branches first.
688 matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote()));
689
690 matches
691 } else {
692 let branches = all_branches
693 .iter()
694 .filter(|branch| branch_matches_filter(branch))
695 .collect::<Vec<_>>();
696 let candidates = branches
697 .iter()
698 .enumerate()
699 .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
700 .collect::<Vec<StringMatchCandidate>>();
701 let mut matches: Vec<Entry> = fuzzy::match_strings(
702 &candidates,
703 &query,
704 true,
705 true,
706 10000,
707 &Default::default(),
708 cx.background_executor().clone(),
709 )
710 .await
711 .into_iter()
712 .map(|candidate| Entry::Branch {
713 branch: branches[candidate.candidate_id].clone(),
714 positions: candidate.positions,
715 })
716 .collect();
717
718 // Keep fuzzy-relevance ordering within local/remote groups, but show locals first.
719 matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote()));
720
721 matches
722 };
723 picker
724 .update(cx, |picker, _| {
725 if let PickerState::CreateRemote(url) = &picker.delegate.state {
726 let query = query.replace(' ', "-");
727 if !query.is_empty() {
728 picker.delegate.matches = vec![Entry::NewRemoteName {
729 name: query.clone(),
730 url: url.clone(),
731 }];
732 picker.delegate.selected_index = 0;
733 } else {
734 picker.delegate.matches = Vec::new();
735 picker.delegate.selected_index = 0;
736 }
737 picker.delegate.last_query = query;
738 return;
739 }
740
741 if !query.is_empty()
742 && !matches.first().is_some_and(|entry| entry.name() == query)
743 {
744 let query = query.replace(' ', "-");
745 let is_url = query.trim_start_matches("git@").parse::<Url>().is_ok();
746 let entry = if is_url {
747 Entry::NewUrl { url: query }
748 } else {
749 Entry::NewBranch { name: query }
750 };
751 // Only transition to NewBranch/NewRemote states when we only show their list item
752 // Otherwise, stay in List state so footer buttons remain visible
753 picker.delegate.state = if matches.is_empty() {
754 if is_url {
755 PickerState::NewRemote
756 } else {
757 PickerState::NewBranch
758 }
759 } else {
760 PickerState::List
761 };
762 matches.push(entry);
763 } else {
764 picker.delegate.state = PickerState::List;
765 }
766 let delegate = &mut picker.delegate;
767 delegate.matches = matches;
768 if delegate.matches.is_empty() {
769 delegate.selected_index = 0;
770 } else {
771 delegate.selected_index =
772 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
773 }
774 delegate.last_query = query;
775 })
776 .log_err();
777 })
778 }
779
780 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
781 let Some(entry) = self.matches.get(self.selected_index()) else {
782 return;
783 };
784
785 match entry {
786 Entry::Branch { branch, .. } => {
787 let current_branch = self.repo.as_ref().map(|repo| {
788 repo.read_with(cx, |repo, _| {
789 repo.branch.as_ref().map(|branch| branch.ref_name.clone())
790 })
791 });
792
793 if current_branch
794 .flatten()
795 .is_some_and(|current_branch| current_branch == branch.ref_name)
796 {
797 cx.emit(DismissEvent);
798 return;
799 }
800
801 let Some(repo) = self.repo.clone() else {
802 return;
803 };
804
805 let branch = branch.clone();
806 cx.spawn(async move |_, cx| {
807 repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))
808 .await??;
809
810 anyhow::Ok(())
811 })
812 .detach_and_prompt_err(
813 "Failed to change branch",
814 window,
815 cx,
816 |_, _, _| None,
817 );
818 }
819 Entry::NewUrl { url } => {
820 self.state = PickerState::CreateRemote(url.clone().into());
821 self.matches = Vec::new();
822 self.selected_index = 0;
823
824 cx.defer_in(window, |picker, window, cx| {
825 picker.refresh_placeholder(window, cx);
826 picker.set_query("", window, cx);
827 cx.notify();
828 });
829
830 // returning early to prevent dismissing the modal, so a user can enter
831 // a remote name first.
832 return;
833 }
834 Entry::NewRemoteName { name, url } => {
835 self.create_remote(name.clone(), url.to_string(), window, cx);
836 }
837 Entry::NewBranch { name } => {
838 let from_branch = if secondary {
839 self.default_branch.clone()
840 } else {
841 None
842 };
843 self.create_branch(from_branch, name.into(), window, cx);
844 }
845 }
846
847 cx.emit(DismissEvent);
848 }
849
850 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
851 self.state = PickerState::List;
852 cx.emit(DismissEvent);
853 }
854
855 fn render_match(
856 &self,
857 ix: usize,
858 selected: bool,
859 _window: &mut Window,
860 cx: &mut Context<Picker<Self>>,
861 ) -> Option<Self::ListItem> {
862 let entry = &self.matches.get(ix)?;
863
864 let (commit_time, author_name, subject) = entry
865 .as_branch()
866 .and_then(|branch| {
867 branch.most_recent_commit.as_ref().map(|commit| {
868 let subject = commit.subject.clone();
869 let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
870 .unwrap_or_else(|_| OffsetDateTime::now_utc());
871 let local_offset =
872 time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
873 let formatted_time = time_format::format_localized_timestamp(
874 commit_time,
875 OffsetDateTime::now_utc(),
876 local_offset,
877 time_format::TimestampFormat::Relative,
878 );
879 let author = commit.author_name.clone();
880 (Some(formatted_time), Some(author), Some(subject))
881 })
882 })
883 .unwrap_or_else(|| (None, None, None));
884
885 let entry_icon = match entry {
886 Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => {
887 Icon::new(IconName::Plus).color(Color::Muted)
888 }
889 Entry::Branch { branch, .. } => {
890 if branch.is_remote() {
891 Icon::new(IconName::Screen).color(Color::Muted)
892 } else {
893 Icon::new(IconName::GitBranchAlt).color(Color::Muted)
894 }
895 }
896 };
897
898 let entry_title = match entry {
899 Entry::NewUrl { .. } => Label::new("Create Remote Repository")
900 .single_line()
901 .truncate()
902 .into_any_element(),
903 Entry::NewBranch { name } => Label::new(format!("Create Branch: \"{name}\"…"))
904 .single_line()
905 .truncate()
906 .into_any_element(),
907 Entry::NewRemoteName { name, .. } => Label::new(format!("Create Remote: \"{name}\""))
908 .single_line()
909 .truncate()
910 .into_any_element(),
911 Entry::Branch { branch, positions } => {
912 HighlightedLabel::new(branch.name().to_string(), positions.clone())
913 .single_line()
914 .truncate()
915 .into_any_element()
916 }
917 };
918
919 let focus_handle = self.focus_handle.clone();
920 let is_new_items = matches!(
921 entry,
922 Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. }
923 );
924
925 let deleted_branch_icon = |entry_ix: usize, is_head_branch: bool| {
926 IconButton::new(("delete", entry_ix), IconName::Trash)
927 .tooltip(move |_, cx| {
928 Tooltip::for_action_in(
929 "Delete Branch",
930 &branch_picker::DeleteBranch,
931 &focus_handle,
932 cx,
933 )
934 })
935 .disabled(is_head_branch)
936 .on_click(cx.listener(move |this, _, window, cx| {
937 this.delegate.delete_at(entry_ix, window, cx);
938 }))
939 };
940
941 let create_from_default_button = self.default_branch.as_ref().map(|default_branch| {
942 let tooltip_label: SharedString = format!("Create New From: {default_branch}").into();
943 let focus_handle = self.focus_handle.clone();
944
945 IconButton::new("create_from_default", IconName::GitBranchPlus)
946 .tooltip(move |_, cx| {
947 Tooltip::for_action_in(
948 tooltip_label.clone(),
949 &menu::SecondaryConfirm,
950 &focus_handle,
951 cx,
952 )
953 })
954 .on_click(cx.listener(|this, _, window, cx| {
955 this.delegate.confirm(true, window, cx);
956 }))
957 .into_any_element()
958 });
959
960 Some(
961 ListItem::new(format!("vcs-menu-{ix}"))
962 .inset(true)
963 .spacing(ListItemSpacing::Sparse)
964 .toggle_state(selected)
965 .child(
966 h_flex()
967 .w_full()
968 .gap_3()
969 .flex_grow()
970 .child(entry_icon)
971 .child(
972 v_flex()
973 .id("info_container")
974 .w_full()
975 .child(entry_title)
976 .child(
977 h_flex()
978 .w_full()
979 .justify_between()
980 .gap_1p5()
981 .when(self.style == BranchListStyle::Modal, |el| {
982 el.child(div().max_w_96().child({
983 let message = match entry {
984 Entry::NewUrl { url } => {
985 format!("Based off {url}")
986 }
987 Entry::NewRemoteName { url, .. } => {
988 format!("Based off {url}")
989 }
990 Entry::NewBranch { .. } => {
991 if let Some(current_branch) =
992 self.repo.as_ref().and_then(|repo| {
993 repo.read(cx)
994 .branch
995 .as_ref()
996 .map(|b| b.name())
997 })
998 {
999 format!("Based off {}", current_branch)
1000 } else {
1001 "Based off the current branch"
1002 .to_string()
1003 }
1004 }
1005 Entry::Branch { .. } => {
1006 let show_author_name =
1007 ProjectSettings::get_global(cx)
1008 .git
1009 .branch_picker
1010 .show_author_name;
1011
1012 subject.map_or(
1013 "No commits found".into(),
1014 |subject| {
1015 if show_author_name
1016 && let Some(author) =
1017 author_name
1018 {
1019 format!(
1020 "{} • {}",
1021 author, subject
1022 )
1023 } else {
1024 subject.to_string()
1025 }
1026 },
1027 )
1028 }
1029 };
1030
1031 Label::new(message)
1032 .size(LabelSize::Small)
1033 .color(Color::Muted)
1034 .truncate()
1035 }))
1036 })
1037 .when_some(commit_time, |label, commit_time| {
1038 label.child(
1039 Label::new(commit_time)
1040 .size(LabelSize::Small)
1041 .color(Color::Muted),
1042 )
1043 }),
1044 )
1045 .when_some(
1046 entry.as_branch().map(|b| b.name().to_string()),
1047 |this, branch_name| this.tooltip(Tooltip::text(branch_name)),
1048 ),
1049 ),
1050 )
1051 .when(
1052 self.editor_position() == PickerEditorPosition::End && !is_new_items,
1053 |this| {
1054 this.map(|this| {
1055 let is_head_branch =
1056 entry.as_branch().is_some_and(|branch| branch.is_head);
1057 if self.selected_index() == ix {
1058 this.end_slot(deleted_branch_icon(ix, is_head_branch))
1059 } else {
1060 this.end_hover_slot(deleted_branch_icon(ix, is_head_branch))
1061 }
1062 })
1063 },
1064 )
1065 .when_some(
1066 if self.editor_position() == PickerEditorPosition::End && is_new_items {
1067 create_from_default_button
1068 } else {
1069 None
1070 },
1071 |this, create_from_default_button| {
1072 this.map(|this| {
1073 if self.selected_index() == ix {
1074 this.end_slot(create_from_default_button)
1075 } else {
1076 this.end_hover_slot(create_from_default_button)
1077 }
1078 })
1079 },
1080 ),
1081 )
1082 }
1083
1084 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1085 if self.editor_position() == PickerEditorPosition::End {
1086 return None;
1087 }
1088 let focus_handle = self.focus_handle.clone();
1089
1090 let footer_container = || {
1091 h_flex()
1092 .w_full()
1093 .p_1p5()
1094 .border_t_1()
1095 .border_color(cx.theme().colors().border_variant)
1096 };
1097
1098 match self.state {
1099 PickerState::List => {
1100 let selected_entry = self.matches.get(self.selected_index);
1101
1102 let branch_from_default_button = self
1103 .default_branch
1104 .as_ref()
1105 .filter(|_| matches!(selected_entry, Some(Entry::NewBranch { .. })))
1106 .map(|default_branch| {
1107 let button_label = format!("Create New From: {default_branch}");
1108
1109 Button::new("branch-from-default", button_label)
1110 .key_binding(
1111 KeyBinding::for_action_in(
1112 &menu::SecondaryConfirm,
1113 &focus_handle,
1114 cx,
1115 )
1116 .map(|kb| kb.size(rems_from_px(12.))),
1117 )
1118 .on_click(cx.listener(|this, _, window, cx| {
1119 this.delegate.confirm(true, window, cx);
1120 }))
1121 });
1122
1123 let delete_and_select_btns = h_flex()
1124 .gap_1()
1125 .child(
1126 Button::new("delete-branch", "Delete")
1127 .key_binding(
1128 KeyBinding::for_action_in(
1129 &branch_picker::DeleteBranch,
1130 &focus_handle,
1131 cx,
1132 )
1133 .map(|kb| kb.size(rems_from_px(12.))),
1134 )
1135 .on_click(|_, window, cx| {
1136 window
1137 .dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx);
1138 }),
1139 )
1140 .child(
1141 Button::new("select_branch", "Select")
1142 .key_binding(
1143 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1144 .map(|kb| kb.size(rems_from_px(12.))),
1145 )
1146 .on_click(cx.listener(|this, _, window, cx| {
1147 this.delegate.confirm(false, window, cx);
1148 })),
1149 );
1150
1151 Some(
1152 footer_container()
1153 .map(|this| {
1154 if branch_from_default_button.is_some() {
1155 this.justify_end().when_some(
1156 branch_from_default_button,
1157 |this, button| {
1158 this.child(button).child(
1159 Button::new("create", "Create")
1160 .key_binding(
1161 KeyBinding::for_action_in(
1162 &menu::Confirm,
1163 &focus_handle,
1164 cx,
1165 )
1166 .map(|kb| kb.size(rems_from_px(12.))),
1167 )
1168 .on_click(cx.listener(|this, _, window, cx| {
1169 this.delegate.confirm(false, window, cx);
1170 })),
1171 )
1172 },
1173 )
1174 } else {
1175 this.justify_between()
1176 .child({
1177 let focus_handle = focus_handle.clone();
1178 let filter_label = match self.branch_filter {
1179 BranchFilter::All => "Filter Remote",
1180 BranchFilter::Remote => "Show All",
1181 };
1182 Button::new("filter-remotes", filter_label)
1183 .toggle_state(matches!(
1184 self.branch_filter,
1185 BranchFilter::Remote
1186 ))
1187 .key_binding(
1188 KeyBinding::for_action_in(
1189 &branch_picker::FilterRemotes,
1190 &focus_handle,
1191 cx,
1192 )
1193 .map(|kb| kb.size(rems_from_px(12.))),
1194 )
1195 .on_click(|_click, window, cx| {
1196 window.dispatch_action(
1197 branch_picker::FilterRemotes.boxed_clone(),
1198 cx,
1199 );
1200 })
1201 })
1202 .child(delete_and_select_btns)
1203 }
1204 })
1205 .into_any_element(),
1206 )
1207 }
1208 PickerState::NewBranch => {
1209 let branch_from_default_button =
1210 self.default_branch.as_ref().map(|default_branch| {
1211 let button_label = format!("Create New From: {default_branch}");
1212
1213 Button::new("branch-from-default", button_label)
1214 .key_binding(
1215 KeyBinding::for_action_in(
1216 &menu::SecondaryConfirm,
1217 &focus_handle,
1218 cx,
1219 )
1220 .map(|kb| kb.size(rems_from_px(12.))),
1221 )
1222 .on_click(cx.listener(|this, _, window, cx| {
1223 this.delegate.confirm(true, window, cx);
1224 }))
1225 });
1226
1227 Some(
1228 footer_container()
1229 .gap_1()
1230 .justify_end()
1231 .when_some(branch_from_default_button, |this, button| {
1232 this.child(button)
1233 })
1234 .child(
1235 Button::new("branch-from-default", "Create")
1236 .key_binding(
1237 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1238 .map(|kb| kb.size(rems_from_px(12.))),
1239 )
1240 .on_click(cx.listener(|this, _, window, cx| {
1241 this.delegate.confirm(false, window, cx);
1242 })),
1243 )
1244 .into_any_element(),
1245 )
1246 }
1247 PickerState::CreateRemote(_) => Some(
1248 footer_container()
1249 .justify_end()
1250 .child(
1251 Button::new("branch-from-default", "Confirm")
1252 .key_binding(
1253 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1254 .map(|kb| kb.size(rems_from_px(12.))),
1255 )
1256 .on_click(cx.listener(|this, _, window, cx| {
1257 this.delegate.confirm(false, window, cx);
1258 }))
1259 .disabled(self.last_query.is_empty()),
1260 )
1261 .into_any_element(),
1262 ),
1263 PickerState::NewRemote => None,
1264 }
1265 }
1266}
1267
1268#[cfg(test)]
1269mod tests {
1270 use std::collections::HashSet;
1271
1272 use super::*;
1273 use git::repository::{CommitSummary, Remote};
1274 use gpui::{AppContext, TestAppContext, VisualTestContext};
1275 use project::{FakeFs, Project};
1276 use rand::{Rng, rngs::StdRng};
1277 use serde_json::json;
1278 use settings::SettingsStore;
1279 use util::path;
1280 use workspace::MultiWorkspace;
1281
1282 fn init_test(cx: &mut TestAppContext) {
1283 cx.update(|cx| {
1284 let settings_store = SettingsStore::test(cx);
1285 cx.set_global(settings_store);
1286 theme::init(theme::LoadThemes::JustBase, cx);
1287 editor::init(cx);
1288 });
1289 }
1290
1291 fn create_test_branch(
1292 name: &str,
1293 is_head: bool,
1294 remote_name: Option<&str>,
1295 timestamp: Option<i64>,
1296 ) -> Branch {
1297 let ref_name = match remote_name {
1298 Some(remote_name) => format!("refs/remotes/{remote_name}/{name}"),
1299 None => format!("refs/heads/{name}"),
1300 };
1301
1302 Branch {
1303 is_head,
1304 ref_name: ref_name.into(),
1305 upstream: None,
1306 most_recent_commit: timestamp.map(|ts| CommitSummary {
1307 sha: "abc123".into(),
1308 commit_timestamp: ts,
1309 author_name: "Test Author".into(),
1310 subject: "Test commit".into(),
1311 has_parent: true,
1312 }),
1313 }
1314 }
1315
1316 fn create_test_branches() -> Vec<Branch> {
1317 vec![
1318 create_test_branch("main", true, None, Some(1000)),
1319 create_test_branch("feature-auth", false, None, Some(900)),
1320 create_test_branch("feature-ui", false, None, Some(800)),
1321 create_test_branch("develop", false, None, Some(700)),
1322 ]
1323 }
1324
1325 async fn init_branch_list_test(
1326 repository: Option<Entity<Repository>>,
1327 branches: Vec<Branch>,
1328 cx: &mut TestAppContext,
1329 ) -> (Entity<BranchList>, VisualTestContext) {
1330 let fs = FakeFs::new(cx.executor());
1331 let project = Project::test(fs, [], cx).await;
1332
1333 let window_handle =
1334 cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
1335 let workspace = window_handle
1336 .read_with(cx, |mw, _| mw.workspace().clone())
1337 .unwrap();
1338
1339 let branch_list = window_handle
1340 .update(cx, |_multi_workspace, window, cx| {
1341 cx.new(|cx| {
1342 let mut delegate = BranchListDelegate::new(
1343 workspace.downgrade(),
1344 repository,
1345 BranchListStyle::Modal,
1346 cx,
1347 );
1348 delegate.all_branches = Some(branches);
1349 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
1350 let picker_focus_handle = picker.focus_handle(cx);
1351 picker.update(cx, |picker, _| {
1352 picker.delegate.focus_handle = picker_focus_handle.clone();
1353 });
1354
1355 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
1356 cx.emit(DismissEvent);
1357 });
1358
1359 BranchList {
1360 picker,
1361 picker_focus_handle,
1362 width: rems(34.),
1363 _subscription: Some(_subscription),
1364 embedded: false,
1365 }
1366 })
1367 })
1368 .unwrap();
1369
1370 let cx = VisualTestContext::from_window(window_handle.into(), cx);
1371
1372 (branch_list, cx)
1373 }
1374
1375 async fn init_fake_repository(
1376 cx: &mut TestAppContext,
1377 ) -> (Entity<Project>, Entity<Repository>) {
1378 let fs = FakeFs::new(cx.executor());
1379 fs.insert_tree(
1380 path!("/dir"),
1381 json!({
1382 ".git": {},
1383 "file.txt": "buffer_text".to_string()
1384 }),
1385 )
1386 .await;
1387 fs.set_head_for_repo(
1388 path!("/dir/.git").as_ref(),
1389 &[("file.txt", "test".to_string())],
1390 "deadbeef",
1391 );
1392 fs.set_index_for_repo(
1393 path!("/dir/.git").as_ref(),
1394 &[("file.txt", "index_text".to_string())],
1395 );
1396
1397 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1398 let repository = cx.read(|cx| project.read(cx).active_repository(cx));
1399
1400 (project, repository.unwrap())
1401 }
1402
1403 #[gpui::test]
1404 async fn test_update_branch_matches_with_query(cx: &mut TestAppContext) {
1405 init_test(cx);
1406
1407 let branches = create_test_branches();
1408 let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1409 let cx = &mut ctx;
1410
1411 branch_list
1412 .update_in(cx, |branch_list, window, cx| {
1413 let query = "feature".to_string();
1414 branch_list.picker.update(cx, |picker, cx| {
1415 picker.delegate.update_matches(query, window, cx)
1416 })
1417 })
1418 .await;
1419 cx.run_until_parked();
1420
1421 branch_list.update(cx, |branch_list, cx| {
1422 branch_list.picker.update(cx, |picker, _cx| {
1423 // Should have 2 existing branches + 1 "create new branch" entry = 3 total
1424 assert_eq!(picker.delegate.matches.len(), 3);
1425 assert!(
1426 picker
1427 .delegate
1428 .matches
1429 .iter()
1430 .any(|m| m.name() == "feature-auth")
1431 );
1432 assert!(
1433 picker
1434 .delegate
1435 .matches
1436 .iter()
1437 .any(|m| m.name() == "feature-ui")
1438 );
1439 // Verify the last entry is the "create new branch" option
1440 let last_match = picker.delegate.matches.last().unwrap();
1441 assert!(last_match.is_new_branch());
1442 })
1443 });
1444 }
1445
1446 async fn update_branch_list_matches_with_empty_query(
1447 branch_list: &Entity<BranchList>,
1448 cx: &mut VisualTestContext,
1449 ) {
1450 branch_list
1451 .update_in(cx, |branch_list, window, cx| {
1452 branch_list.picker.update(cx, |picker, cx| {
1453 picker.delegate.update_matches(String::new(), window, cx)
1454 })
1455 })
1456 .await;
1457 cx.run_until_parked();
1458 }
1459
1460 #[gpui::test]
1461 async fn test_delete_branch(cx: &mut TestAppContext) {
1462 init_test(cx);
1463 let (_project, repository) = init_fake_repository(cx).await;
1464
1465 let branches = create_test_branches();
1466
1467 let branch_names = branches
1468 .iter()
1469 .map(|branch| branch.name().to_string())
1470 .collect::<Vec<String>>();
1471 let repo = repository.clone();
1472 cx.spawn(async move |mut cx| {
1473 for branch in branch_names {
1474 repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
1475 .await
1476 .unwrap()
1477 .unwrap();
1478 }
1479 })
1480 .await;
1481 cx.run_until_parked();
1482
1483 let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1484 let cx = &mut ctx;
1485
1486 update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1487
1488 let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| {
1489 branch_list.picker.update(cx, |picker, cx| {
1490 assert_eq!(picker.delegate.matches.len(), 4);
1491 let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
1492 picker.delegate.delete_at(1, window, cx);
1493 branch_to_delete
1494 })
1495 });
1496 cx.run_until_parked();
1497
1498 let expected_branches = ["main", "feature-auth", "feature-ui", "develop"]
1499 .into_iter()
1500 .filter(|name| name != &branch_to_delete)
1501 .collect::<HashSet<_>>();
1502 let repo_branches = branch_list
1503 .update(cx, |branch_list, cx| {
1504 branch_list.picker.update(cx, |picker, cx| {
1505 picker
1506 .delegate
1507 .repo
1508 .as_ref()
1509 .unwrap()
1510 .update(cx, |repo, _cx| repo.branches())
1511 })
1512 })
1513 .await
1514 .unwrap()
1515 .unwrap();
1516 let repo_branches = repo_branches
1517 .iter()
1518 .map(|b| b.name())
1519 .collect::<HashSet<_>>();
1520 assert_eq!(&repo_branches, &expected_branches);
1521
1522 branch_list.update(cx, move |branch_list, cx| {
1523 branch_list.picker.update(cx, move |picker, _cx| {
1524 assert_eq!(picker.delegate.matches.len(), 3);
1525 let branches = picker
1526 .delegate
1527 .matches
1528 .iter()
1529 .map(|be| be.name())
1530 .collect::<HashSet<_>>();
1531 assert_eq!(branches, expected_branches);
1532 })
1533 });
1534 }
1535
1536 #[gpui::test]
1537 async fn test_delete_remote_branch(cx: &mut TestAppContext) {
1538 init_test(cx);
1539 let (_project, repository) = init_fake_repository(cx).await;
1540 let branches = vec![
1541 create_test_branch("main", true, Some("origin"), Some(1000)),
1542 create_test_branch("feature-auth", false, Some("origin"), Some(900)),
1543 create_test_branch("feature-ui", false, Some("fork"), Some(800)),
1544 create_test_branch("develop", false, Some("private"), Some(700)),
1545 ];
1546
1547 let branch_names = branches
1548 .iter()
1549 .map(|branch| branch.name().to_string())
1550 .collect::<Vec<String>>();
1551 let repo = repository.clone();
1552 cx.spawn(async move |mut cx| {
1553 for branch in branch_names {
1554 repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
1555 .await
1556 .unwrap()
1557 .unwrap();
1558 }
1559 })
1560 .await;
1561 cx.run_until_parked();
1562
1563 let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1564 let cx = &mut ctx;
1565 // Enable remote filter
1566 branch_list.update(cx, |branch_list, cx| {
1567 branch_list.picker.update(cx, |picker, _cx| {
1568 picker.delegate.branch_filter = BranchFilter::Remote;
1569 });
1570 });
1571 update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1572
1573 // Check matches, it should match all existing branches and no option to create new branch
1574 let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| {
1575 branch_list.picker.update(cx, |picker, cx| {
1576 assert_eq!(picker.delegate.matches.len(), 4);
1577 let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
1578 picker.delegate.delete_at(1, window, cx);
1579 branch_to_delete
1580 })
1581 });
1582 cx.run_until_parked();
1583
1584 let expected_branches = [
1585 "origin/main",
1586 "origin/feature-auth",
1587 "fork/feature-ui",
1588 "private/develop",
1589 ]
1590 .into_iter()
1591 .filter(|name| name != &branch_to_delete)
1592 .collect::<HashSet<_>>();
1593 let repo_branches = branch_list
1594 .update(cx, |branch_list, cx| {
1595 branch_list.picker.update(cx, |picker, cx| {
1596 picker
1597 .delegate
1598 .repo
1599 .as_ref()
1600 .unwrap()
1601 .update(cx, |repo, _cx| repo.branches())
1602 })
1603 })
1604 .await
1605 .unwrap()
1606 .unwrap();
1607 let repo_branches = repo_branches
1608 .iter()
1609 .map(|b| b.name())
1610 .collect::<HashSet<_>>();
1611 assert_eq!(&repo_branches, &expected_branches);
1612
1613 // Check matches, it should match one less branch than before
1614 branch_list.update(cx, move |branch_list, cx| {
1615 branch_list.picker.update(cx, move |picker, _cx| {
1616 assert_eq!(picker.delegate.matches.len(), 3);
1617 let branches = picker
1618 .delegate
1619 .matches
1620 .iter()
1621 .map(|be| be.name())
1622 .collect::<HashSet<_>>();
1623 assert_eq!(branches, expected_branches);
1624 })
1625 });
1626 }
1627
1628 #[gpui::test]
1629 async fn test_branch_filter_shows_all_then_remotes_and_applies_query(cx: &mut TestAppContext) {
1630 init_test(cx);
1631
1632 let branches = vec![
1633 create_test_branch("main", true, Some("origin"), Some(1000)),
1634 create_test_branch("feature-auth", false, Some("fork"), Some(900)),
1635 create_test_branch("feature-ui", false, None, Some(800)),
1636 create_test_branch("develop", false, None, Some(700)),
1637 ];
1638
1639 let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1640 let cx = &mut ctx;
1641
1642 update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1643
1644 branch_list.update(cx, |branch_list, cx| {
1645 branch_list.picker.update(cx, |picker, _cx| {
1646 assert_eq!(picker.delegate.matches.len(), 4);
1647
1648 let branches = picker
1649 .delegate
1650 .matches
1651 .iter()
1652 .map(|be| be.name())
1653 .collect::<HashSet<_>>();
1654 assert_eq!(
1655 branches,
1656 ["origin/main", "fork/feature-auth", "feature-ui", "develop"]
1657 .into_iter()
1658 .collect::<HashSet<_>>()
1659 );
1660
1661 // Locals should be listed before remotes.
1662 let ordered = picker
1663 .delegate
1664 .matches
1665 .iter()
1666 .map(|be| be.name())
1667 .collect::<Vec<_>>();
1668 assert_eq!(
1669 ordered,
1670 vec!["feature-ui", "develop", "origin/main", "fork/feature-auth"]
1671 );
1672
1673 // Verify the last entry is NOT the "create new branch" option
1674 let last_match = picker.delegate.matches.last().unwrap();
1675 assert!(!last_match.is_new_branch());
1676 assert!(!last_match.is_new_url());
1677 })
1678 });
1679
1680 branch_list.update(cx, |branch_list, cx| {
1681 branch_list.picker.update(cx, |picker, _cx| {
1682 picker.delegate.branch_filter = BranchFilter::Remote;
1683 })
1684 });
1685
1686 update_branch_list_matches_with_empty_query(&branch_list, cx).await;
1687
1688 branch_list
1689 .update_in(cx, |branch_list, window, cx| {
1690 branch_list.picker.update(cx, |picker, cx| {
1691 assert_eq!(picker.delegate.matches.len(), 2);
1692 let branches = picker
1693 .delegate
1694 .matches
1695 .iter()
1696 .map(|be| be.name())
1697 .collect::<HashSet<_>>();
1698 assert_eq!(
1699 branches,
1700 ["origin/main", "fork/feature-auth"]
1701 .into_iter()
1702 .collect::<HashSet<_>>()
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_url());
1708 picker.delegate.branch_filter = BranchFilter::Remote;
1709 picker
1710 .delegate
1711 .update_matches(String::from("fork"), window, cx)
1712 })
1713 })
1714 .await;
1715 cx.run_until_parked();
1716
1717 branch_list.update(cx, |branch_list, cx| {
1718 branch_list.picker.update(cx, |picker, _cx| {
1719 // Should have 1 existing branch + 1 "create new branch" entry = 2 total
1720 assert_eq!(picker.delegate.matches.len(), 2);
1721 assert!(
1722 picker
1723 .delegate
1724 .matches
1725 .iter()
1726 .any(|m| m.name() == "fork/feature-auth")
1727 );
1728 // Verify the last entry is the "create new branch" option
1729 let last_match = picker.delegate.matches.last().unwrap();
1730 assert!(last_match.is_new_branch());
1731 })
1732 });
1733 }
1734
1735 #[gpui::test]
1736 async fn test_new_branch_creation_with_query(test_cx: &mut TestAppContext) {
1737 const MAIN_BRANCH: &str = "main";
1738 const FEATURE_BRANCH: &str = "feature";
1739 const NEW_BRANCH: &str = "new-feature-branch";
1740
1741 init_test(test_cx);
1742 let (_project, repository) = init_fake_repository(test_cx).await;
1743
1744 let branches = vec![
1745 create_test_branch(MAIN_BRANCH, true, None, Some(1000)),
1746 create_test_branch(FEATURE_BRANCH, false, None, Some(900)),
1747 ];
1748
1749 let (branch_list, mut ctx) =
1750 init_branch_list_test(repository.into(), branches, test_cx).await;
1751 let cx = &mut ctx;
1752
1753 branch_list
1754 .update_in(cx, |branch_list, window, cx| {
1755 branch_list.picker.update(cx, |picker, cx| {
1756 picker
1757 .delegate
1758 .update_matches(NEW_BRANCH.to_string(), window, cx)
1759 })
1760 })
1761 .await;
1762
1763 cx.run_until_parked();
1764
1765 branch_list.update_in(cx, |branch_list, window, cx| {
1766 branch_list.picker.update(cx, |picker, cx| {
1767 let last_match = picker.delegate.matches.last().unwrap();
1768 assert!(last_match.is_new_branch());
1769 assert_eq!(last_match.name(), NEW_BRANCH);
1770 // State is NewBranch because no existing branches fuzzy-match the query
1771 assert!(matches!(picker.delegate.state, PickerState::NewBranch));
1772 picker.delegate.confirm(false, window, cx);
1773 })
1774 });
1775 cx.run_until_parked();
1776
1777 let branches = branch_list
1778 .update(cx, |branch_list, cx| {
1779 branch_list.picker.update(cx, |picker, cx| {
1780 picker
1781 .delegate
1782 .repo
1783 .as_ref()
1784 .unwrap()
1785 .update(cx, |repo, _cx| repo.branches())
1786 })
1787 })
1788 .await
1789 .unwrap()
1790 .unwrap();
1791
1792 let new_branch = branches
1793 .into_iter()
1794 .find(|branch| branch.name() == NEW_BRANCH)
1795 .expect("new-feature-branch should exist");
1796 assert_eq!(
1797 new_branch.ref_name.as_ref(),
1798 &format!("refs/heads/{NEW_BRANCH}"),
1799 "branch ref_name should not have duplicate refs/heads/ prefix"
1800 );
1801 }
1802
1803 #[gpui::test]
1804 async fn test_remote_url_detection_https(cx: &mut TestAppContext) {
1805 init_test(cx);
1806 let (_project, repository) = init_fake_repository(cx).await;
1807 let branches = vec![create_test_branch("main", true, None, Some(1000))];
1808
1809 let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
1810 let cx = &mut ctx;
1811
1812 branch_list
1813 .update_in(cx, |branch_list, window, cx| {
1814 branch_list.picker.update(cx, |picker, cx| {
1815 let query = "https://github.com/user/repo.git".to_string();
1816 picker.delegate.update_matches(query, window, cx)
1817 })
1818 })
1819 .await;
1820
1821 cx.run_until_parked();
1822
1823 branch_list
1824 .update_in(cx, |branch_list, window, cx| {
1825 branch_list.picker.update(cx, |picker, cx| {
1826 let last_match = picker.delegate.matches.last().unwrap();
1827 assert!(last_match.is_new_url());
1828 assert!(matches!(picker.delegate.state, PickerState::NewRemote));
1829 picker.delegate.confirm(false, window, cx);
1830 assert_eq!(picker.delegate.matches.len(), 0);
1831 if let PickerState::CreateRemote(remote_url) = &picker.delegate.state
1832 && remote_url.as_ref() == "https://github.com/user/repo.git"
1833 {
1834 } else {
1835 panic!("wrong picker state");
1836 }
1837 picker
1838 .delegate
1839 .update_matches("my_new_remote".to_string(), window, cx)
1840 })
1841 })
1842 .await;
1843
1844 cx.run_until_parked();
1845
1846 branch_list.update_in(cx, |branch_list, window, cx| {
1847 branch_list.picker.update(cx, |picker, cx| {
1848 assert_eq!(picker.delegate.matches.len(), 1);
1849 assert!(matches!(
1850 picker.delegate.matches.first(),
1851 Some(Entry::NewRemoteName { name, url })
1852 if name == "my_new_remote" && url.as_ref() == "https://github.com/user/repo.git"
1853 ));
1854 picker.delegate.confirm(false, window, cx);
1855 })
1856 });
1857 cx.run_until_parked();
1858
1859 // List remotes
1860 let remotes = branch_list
1861 .update(cx, |branch_list, cx| {
1862 branch_list.picker.update(cx, |picker, cx| {
1863 picker
1864 .delegate
1865 .repo
1866 .as_ref()
1867 .unwrap()
1868 .update(cx, |repo, _cx| repo.get_remotes(None, false))
1869 })
1870 })
1871 .await
1872 .unwrap()
1873 .unwrap();
1874 assert_eq!(
1875 remotes,
1876 vec![Remote {
1877 name: SharedString::from("my_new_remote".to_string())
1878 }]
1879 );
1880 }
1881
1882 #[gpui::test]
1883 async fn test_confirm_remote_url_transitions(cx: &mut TestAppContext) {
1884 init_test(cx);
1885
1886 let branches = vec![create_test_branch("main_branch", true, None, Some(1000))];
1887 let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1888 let cx = &mut ctx;
1889
1890 branch_list
1891 .update_in(cx, |branch_list, window, cx| {
1892 branch_list.picker.update(cx, |picker, cx| {
1893 let query = "https://github.com/user/repo.git".to_string();
1894 picker.delegate.update_matches(query, window, cx)
1895 })
1896 })
1897 .await;
1898 cx.run_until_parked();
1899
1900 // Try to create a new remote but cancel in the middle of the process
1901 branch_list
1902 .update_in(cx, |branch_list, window, cx| {
1903 branch_list.picker.update(cx, |picker, cx| {
1904 picker.delegate.selected_index = picker.delegate.matches.len() - 1;
1905 picker.delegate.confirm(false, window, cx);
1906
1907 assert!(matches!(
1908 picker.delegate.state,
1909 PickerState::CreateRemote(_)
1910 ));
1911 if let PickerState::CreateRemote(ref url) = picker.delegate.state {
1912 assert_eq!(url.as_ref(), "https://github.com/user/repo.git");
1913 }
1914 assert_eq!(picker.delegate.matches.len(), 0);
1915 picker.delegate.dismissed(window, cx);
1916 assert!(matches!(picker.delegate.state, PickerState::List));
1917 let query = "main".to_string();
1918 picker.delegate.update_matches(query, window, cx)
1919 })
1920 })
1921 .await;
1922 cx.run_until_parked();
1923
1924 // Try to search a branch again to see if the state is restored properly
1925 branch_list.update(cx, |branch_list, cx| {
1926 branch_list.picker.update(cx, |picker, _cx| {
1927 // Should have 1 existing branch + 1 "create new branch" entry = 2 total
1928 assert_eq!(picker.delegate.matches.len(), 2);
1929 assert!(
1930 picker
1931 .delegate
1932 .matches
1933 .iter()
1934 .any(|m| m.name() == "main_branch")
1935 );
1936 // Verify the last entry is the "create new branch" option
1937 let last_match = picker.delegate.matches.last().unwrap();
1938 assert!(last_match.is_new_branch());
1939 })
1940 });
1941 }
1942
1943 #[gpui::test]
1944 async fn test_confirm_remote_url_does_not_dismiss(cx: &mut TestAppContext) {
1945 const REMOTE_URL: &str = "https://github.com/user/repo.git";
1946
1947 init_test(cx);
1948 let branches = vec![create_test_branch("main", true, None, Some(1000))];
1949
1950 let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
1951 let cx = &mut ctx;
1952
1953 let subscription = cx.update(|_, cx| {
1954 cx.subscribe(&branch_list, |_, _: &DismissEvent, _| {
1955 panic!("DismissEvent should not be emitted when confirming a remote URL");
1956 })
1957 });
1958
1959 branch_list
1960 .update_in(cx, |branch_list, window, cx| {
1961 window.focus(&branch_list.picker_focus_handle, cx);
1962 assert!(
1963 branch_list.picker_focus_handle.is_focused(window),
1964 "Branch picker should be focused when selecting an entry"
1965 );
1966
1967 branch_list.picker.update(cx, |picker, cx| {
1968 picker
1969 .delegate
1970 .update_matches(REMOTE_URL.to_string(), window, cx)
1971 })
1972 })
1973 .await;
1974
1975 cx.run_until_parked();
1976
1977 branch_list.update_in(cx, |branch_list, window, cx| {
1978 // Re-focus the picker since workspace initialization during run_until_parked
1979 window.focus(&branch_list.picker_focus_handle, cx);
1980
1981 branch_list.picker.update(cx, |picker, cx| {
1982 let last_match = picker.delegate.matches.last().unwrap();
1983 assert!(last_match.is_new_url());
1984 assert!(matches!(picker.delegate.state, PickerState::NewRemote));
1985
1986 picker.delegate.confirm(false, window, cx);
1987
1988 assert!(
1989 matches!(picker.delegate.state, PickerState::CreateRemote(ref url) if url.as_ref() == REMOTE_URL),
1990 "State should transition to CreateRemote with the URL"
1991 );
1992 });
1993
1994 assert!(
1995 branch_list.picker_focus_handle.is_focused(window),
1996 "Branch list picker should still be focused after confirming remote URL"
1997 );
1998 });
1999
2000 cx.run_until_parked();
2001
2002 drop(subscription);
2003 }
2004
2005 #[gpui::test(iterations = 10)]
2006 async fn test_empty_query_displays_all_branches(mut rng: StdRng, cx: &mut TestAppContext) {
2007 init_test(cx);
2008 let branch_count = rng.random_range(13..540);
2009
2010 let branches: Vec<Branch> = (0..branch_count)
2011 .map(|i| create_test_branch(&format!("branch-{:02}", i), i == 0, None, Some(i * 100)))
2012 .collect();
2013
2014 let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
2015 let cx = &mut ctx;
2016
2017 update_branch_list_matches_with_empty_query(&branch_list, cx).await;
2018
2019 branch_list.update(cx, |branch_list, cx| {
2020 branch_list.picker.update(cx, |picker, _cx| {
2021 assert_eq!(picker.delegate.matches.len(), branch_count as usize);
2022 })
2023 });
2024 }
2025}