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