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