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