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