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