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