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