1use std::path::PathBuf;
2use std::sync::Arc;
3
4use collections::HashSet;
5use fuzzy::StringMatchCandidate;
6use git::repository::Worktree as GitWorktree;
7use gpui::{
8 AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
9 IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, rems,
10};
11use picker::{Picker, PickerDelegate, PickerEditorPosition};
12use project::Project;
13use project::git_store::RepositoryEvent;
14use ui::{Divider, HighlightedLabel, ListItem, ListItemSpacing, Tooltip, prelude::*};
15use util::ResultExt as _;
16use util::paths::PathExt;
17
18use crate::{CreateWorktree, NewWorktreeBranchTarget, SwitchWorktree};
19
20pub(crate) struct ThreadWorktreePicker {
21 picker: Entity<Picker<ThreadWorktreePickerDelegate>>,
22 focus_handle: FocusHandle,
23 _subscriptions: Vec<Subscription>,
24}
25
26impl ThreadWorktreePicker {
27 pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
28 let project_worktree_paths: HashSet<PathBuf> = project
29 .read(cx)
30 .visible_worktrees(cx)
31 .map(|wt| wt.read(cx).abs_path().to_path_buf())
32 .collect();
33
34 let has_multiple_repositories = project.read(cx).repositories(cx).len() > 1;
35
36 let current_branch_name = project.read(cx).active_repository(cx).and_then(|repo| {
37 repo.read(cx)
38 .branch
39 .as_ref()
40 .map(|branch| branch.name().to_string())
41 });
42
43 let repository = if has_multiple_repositories {
44 None
45 } else {
46 project.read(cx).active_repository(cx)
47 };
48
49 // Fetch worktrees from the git backend (includes main + all linked)
50 let all_worktrees_request = repository
51 .clone()
52 .map(|repo| repo.update(cx, |repo, _| repo.worktrees()));
53
54 let default_branch_request = repository
55 .clone()
56 .map(|repo| repo.update(cx, |repo, _| repo.default_branch(false)));
57
58 let initial_matches = vec![ThreadWorktreeEntry::CreateFromCurrentBranch];
59
60 let delegate = ThreadWorktreePickerDelegate {
61 matches: initial_matches,
62 all_worktrees: Vec::new(),
63 project_worktree_paths,
64 selected_index: 0,
65 project,
66 current_branch_name,
67 default_branch_name: None,
68 has_multiple_repositories,
69 };
70
71 let picker = cx.new(|cx| {
72 Picker::list(delegate, window, cx)
73 .list_measure_all()
74 .modal(false)
75 .max_height(Some(rems(20.).into()))
76 });
77
78 let mut subscriptions = Vec::new();
79
80 // Fetch worktrees and default branch asynchronously
81 {
82 let picker_handle = picker.downgrade();
83 cx.spawn_in(window, async move |_this, cx| {
84 let all_worktrees: Vec<_> = match all_worktrees_request {
85 Some(req) => match req.await {
86 Ok(Ok(worktrees)) => {
87 worktrees.into_iter().filter(|wt| !wt.is_bare).collect()
88 }
89 Ok(Err(err)) => {
90 log::warn!("ThreadWorktreePicker: git worktree list failed: {err}");
91 return anyhow::Ok(());
92 }
93 Err(_) => {
94 log::warn!("ThreadWorktreePicker: worktree request was cancelled");
95 return anyhow::Ok(());
96 }
97 },
98 None => Vec::new(),
99 };
100
101 let default_branch = match default_branch_request {
102 Some(req) => req.await.ok().and_then(Result::ok).flatten(),
103 None => None,
104 };
105
106 picker_handle.update_in(cx, |picker, window, cx| {
107 picker.delegate.all_worktrees = all_worktrees;
108 picker.delegate.default_branch_name =
109 default_branch.map(|branch| branch.to_string());
110 picker.refresh(window, cx);
111 })?;
112
113 anyhow::Ok(())
114 })
115 .detach_and_log_err(cx);
116 }
117
118 // Subscribe to repository events to live-update the worktree list
119 if let Some(repo) = &repository {
120 let picker_entity = picker.downgrade();
121 subscriptions.push(cx.subscribe_in(
122 repo,
123 window,
124 move |_this, repo, event: &RepositoryEvent, window, cx| {
125 if matches!(event, RepositoryEvent::GitWorktreeListChanged) {
126 let worktrees_request = repo.update(cx, |repo, _| repo.worktrees());
127 let picker = picker_entity.clone();
128 cx.spawn_in(window, async move |_, cx| {
129 let all_worktrees: Vec<_> = worktrees_request
130 .await??
131 .into_iter()
132 .filter(|wt| !wt.is_bare)
133 .collect();
134 picker.update_in(cx, |picker, window, cx| {
135 picker.delegate.all_worktrees = all_worktrees;
136 picker.refresh(window, cx);
137 })?;
138 anyhow::Ok(())
139 })
140 .detach_and_log_err(cx);
141 }
142 },
143 ));
144 }
145
146 subscriptions.push(cx.subscribe(&picker, |_, _, _, cx| {
147 cx.emit(DismissEvent);
148 }));
149
150 Self {
151 focus_handle: picker.focus_handle(cx),
152 picker,
153 _subscriptions: subscriptions,
154 }
155 }
156}
157
158impl Focusable for ThreadWorktreePicker {
159 fn focus_handle(&self, _cx: &App) -> FocusHandle {
160 self.focus_handle.clone()
161 }
162}
163
164impl EventEmitter<DismissEvent> for ThreadWorktreePicker {}
165
166impl Render for ThreadWorktreePicker {
167 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
168 v_flex()
169 .w(rems(34.))
170 .elevation_3(cx)
171 .child(self.picker.clone())
172 .on_mouse_down_out(cx.listener(|_, _, _, cx| {
173 cx.emit(DismissEvent);
174 }))
175 }
176}
177
178#[derive(Clone)]
179enum ThreadWorktreeEntry {
180 CreateFromCurrentBranch,
181 CreateFromDefaultBranch {
182 default_branch_name: String,
183 },
184 Separator,
185 Worktree {
186 worktree: GitWorktree,
187 positions: Vec<usize>,
188 },
189 CreateNamed {
190 name: String,
191 /// When Some, create from this branch name (e.g. "main"). When None, create from current branch.
192 from_branch: Option<String>,
193 disabled_reason: Option<String>,
194 },
195}
196
197pub(crate) struct ThreadWorktreePickerDelegate {
198 matches: Vec<ThreadWorktreeEntry>,
199 all_worktrees: Vec<GitWorktree>,
200 project_worktree_paths: HashSet<PathBuf>,
201 selected_index: usize,
202 project: Entity<Project>,
203 current_branch_name: Option<String>,
204 default_branch_name: Option<String>,
205 has_multiple_repositories: bool,
206}
207
208impl ThreadWorktreePickerDelegate {
209 fn build_fixed_entries(&self) -> Vec<ThreadWorktreeEntry> {
210 let mut entries = Vec::new();
211
212 entries.push(ThreadWorktreeEntry::CreateFromCurrentBranch);
213
214 if !self.has_multiple_repositories {
215 if let Some(ref default_branch) = self.default_branch_name {
216 let is_different = self
217 .current_branch_name
218 .as_ref()
219 .is_none_or(|current| current != default_branch);
220 if is_different {
221 entries.push(ThreadWorktreeEntry::CreateFromDefaultBranch {
222 default_branch_name: default_branch.clone(),
223 });
224 }
225 }
226 }
227
228 entries
229 }
230
231 fn all_repo_worktrees(&self) -> &[GitWorktree] {
232 if self.has_multiple_repositories {
233 &[]
234 } else {
235 &self.all_worktrees
236 }
237 }
238
239 fn sync_selected_index(&mut self, has_query: bool) {
240 if !has_query {
241 return;
242 }
243
244 // When filtering, prefer selecting the first worktree match
245 if let Some(index) = self
246 .matches
247 .iter()
248 .position(|entry| matches!(entry, ThreadWorktreeEntry::Worktree { .. }))
249 {
250 self.selected_index = index;
251 } else if let Some(index) = self
252 .matches
253 .iter()
254 .position(|entry| matches!(entry, ThreadWorktreeEntry::CreateNamed { .. }))
255 {
256 self.selected_index = index;
257 } else {
258 self.selected_index = 0;
259 }
260 }
261}
262
263impl PickerDelegate for ThreadWorktreePickerDelegate {
264 type ListItem = AnyElement;
265
266 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
267 "Select a worktree for this thread…".into()
268 }
269
270 fn editor_position(&self) -> PickerEditorPosition {
271 PickerEditorPosition::Start
272 }
273
274 fn match_count(&self) -> usize {
275 self.matches.len()
276 }
277
278 fn selected_index(&self) -> usize {
279 self.selected_index
280 }
281
282 fn set_selected_index(
283 &mut self,
284 ix: usize,
285 _window: &mut Window,
286 _cx: &mut Context<Picker<Self>>,
287 ) {
288 self.selected_index = ix;
289 }
290
291 fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
292 !matches!(self.matches.get(ix), Some(ThreadWorktreeEntry::Separator))
293 }
294
295 fn update_matches(
296 &mut self,
297 query: String,
298 window: &mut Window,
299 cx: &mut Context<Picker<Self>>,
300 ) -> Task<()> {
301 let repo_worktrees = self.all_repo_worktrees().to_vec();
302
303 let normalized_query = query.replace(' ', "-");
304 let main_worktree_path = self
305 .all_worktrees
306 .iter()
307 .find(|wt| wt.is_main)
308 .map(|wt| wt.path.clone());
309 let has_named_worktree = self.all_worktrees.iter().any(|worktree| {
310 worktree.directory_name(main_worktree_path.as_deref()) == normalized_query
311 });
312 let create_named_disabled_reason: Option<String> = if self.has_multiple_repositories {
313 Some("Cannot create a named worktree in a project with multiple repositories".into())
314 } else if has_named_worktree {
315 Some("A worktree with this name already exists".into())
316 } else {
317 None
318 };
319
320 let show_default_branch_create = !self.has_multiple_repositories
321 && self.default_branch_name.as_ref().is_some_and(|default| {
322 self.current_branch_name
323 .as_ref()
324 .is_none_or(|current| current != default)
325 });
326 let default_branch_name = self.default_branch_name.clone();
327
328 if query.is_empty() {
329 let mut matches = self.build_fixed_entries();
330
331 if !repo_worktrees.is_empty() {
332 let main_worktree_path = repo_worktrees
333 .iter()
334 .find(|wt| wt.is_main)
335 .map(|wt| wt.path.clone());
336
337 let mut sorted = repo_worktrees;
338 let project_paths = &self.project_worktree_paths;
339
340 sorted.sort_by(|a, b| {
341 let a_is_current = project_paths.contains(&a.path);
342 let b_is_current = project_paths.contains(&b.path);
343 b_is_current.cmp(&a_is_current).then_with(|| {
344 a.directory_name(main_worktree_path.as_deref())
345 .cmp(&b.directory_name(main_worktree_path.as_deref()))
346 })
347 });
348
349 matches.push(ThreadWorktreeEntry::Separator);
350 for worktree in sorted {
351 matches.push(ThreadWorktreeEntry::Worktree {
352 worktree,
353 positions: Vec::new(),
354 });
355 }
356 }
357
358 self.matches = matches;
359 self.sync_selected_index(false);
360 return Task::ready(());
361 }
362
363 // When the user is typing, fuzzy-match worktree names using display_name
364 let main_worktree_path = repo_worktrees
365 .iter()
366 .find(|wt| wt.is_main)
367 .map(|wt| wt.path.clone());
368 let candidates: Vec<_> = repo_worktrees
369 .iter()
370 .enumerate()
371 .map(|(ix, worktree)| {
372 StringMatchCandidate::new(
373 ix,
374 &worktree.directory_name(main_worktree_path.as_deref()),
375 )
376 })
377 .collect();
378
379 let executor = cx.background_executor().clone();
380
381 let task = cx.background_executor().spawn(async move {
382 fuzzy::match_strings(
383 &candidates,
384 &query,
385 true,
386 true,
387 10000,
388 &Default::default(),
389 executor,
390 )
391 .await
392 });
393
394 let repo_worktrees_clone = repo_worktrees;
395 cx.spawn_in(window, async move |picker, cx| {
396 let fuzzy_matches = task.await;
397
398 picker
399 .update_in(cx, |picker, _window, cx| {
400 let mut new_matches: Vec<ThreadWorktreeEntry> = Vec::new();
401
402 for candidate in &fuzzy_matches {
403 new_matches.push(ThreadWorktreeEntry::Worktree {
404 worktree: repo_worktrees_clone[candidate.candidate_id].clone(),
405 positions: candidate.positions.clone(),
406 });
407 }
408
409 if !new_matches.is_empty() {
410 new_matches.push(ThreadWorktreeEntry::Separator);
411 }
412 new_matches.push(ThreadWorktreeEntry::CreateNamed {
413 name: normalized_query.clone(),
414 from_branch: None,
415 disabled_reason: create_named_disabled_reason.clone(),
416 });
417 if show_default_branch_create {
418 if let Some(ref default_branch) = default_branch_name {
419 new_matches.push(ThreadWorktreeEntry::CreateNamed {
420 name: normalized_query.clone(),
421 from_branch: Some(default_branch.clone()),
422 disabled_reason: create_named_disabled_reason.clone(),
423 });
424 }
425 }
426
427 picker.delegate.matches = new_matches;
428 picker.delegate.sync_selected_index(true);
429
430 cx.notify();
431 })
432 .log_err();
433 })
434 }
435
436 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
437 let Some(entry) = self.matches.get(self.selected_index) else {
438 return;
439 };
440
441 match entry {
442 ThreadWorktreeEntry::Separator => return,
443
444 ThreadWorktreeEntry::CreateFromCurrentBranch => {
445 window.dispatch_action(
446 Box::new(CreateWorktree {
447 worktree_name: None,
448 branch_target: NewWorktreeBranchTarget::CurrentBranch,
449 }),
450 cx,
451 );
452 }
453
454 ThreadWorktreeEntry::CreateFromDefaultBranch {
455 default_branch_name,
456 } => {
457 window.dispatch_action(
458 Box::new(CreateWorktree {
459 worktree_name: None,
460 branch_target: NewWorktreeBranchTarget::ExistingBranch {
461 name: default_branch_name.clone(),
462 },
463 }),
464 cx,
465 );
466 }
467
468 ThreadWorktreeEntry::Worktree { worktree, .. } => {
469 let is_current = self.project_worktree_paths.contains(&worktree.path);
470
471 if is_current {
472 // Already in this worktree — just dismiss
473 } else {
474 let main_worktree_path = self
475 .all_worktrees
476 .iter()
477 .find(|wt| wt.is_main)
478 .map(|wt| wt.path.as_path());
479 window.dispatch_action(
480 Box::new(SwitchWorktree {
481 path: worktree.path.clone(),
482 display_name: worktree.directory_name(main_worktree_path),
483 }),
484 cx,
485 );
486 }
487 }
488
489 ThreadWorktreeEntry::CreateNamed {
490 name,
491 from_branch,
492 disabled_reason: None,
493 } => {
494 let branch_target = match from_branch {
495 Some(branch) => NewWorktreeBranchTarget::ExistingBranch {
496 name: branch.clone(),
497 },
498 None => NewWorktreeBranchTarget::CurrentBranch,
499 };
500 window.dispatch_action(
501 Box::new(CreateWorktree {
502 worktree_name: Some(name.clone()),
503 branch_target,
504 }),
505 cx,
506 );
507 }
508
509 ThreadWorktreeEntry::CreateNamed {
510 disabled_reason: Some(_),
511 ..
512 } => {
513 return;
514 }
515 }
516
517 cx.emit(DismissEvent);
518 }
519
520 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
521
522 fn render_match(
523 &self,
524 ix: usize,
525 selected: bool,
526 _window: &mut Window,
527 cx: &mut Context<Picker<Self>>,
528 ) -> Option<Self::ListItem> {
529 let entry = self.matches.get(ix)?;
530 let project = self.project.read(cx);
531 let is_create_disabled = project.repositories(cx).is_empty() || project.is_via_collab();
532
533 let no_git_reason: SharedString = "Requires a Git repository in the project".into();
534
535 let create_new_list_item = |id: SharedString,
536 label: SharedString,
537 disabled_tooltip: Option<SharedString>,
538 selected: bool| {
539 let is_disabled = disabled_tooltip.is_some();
540 ListItem::new(id)
541 .inset(true)
542 .spacing(ListItemSpacing::Sparse)
543 .toggle_state(selected)
544 .child(
545 h_flex()
546 .w_full()
547 .gap_2p5()
548 .child(
549 Icon::new(IconName::Plus)
550 .map(|this| {
551 if is_disabled {
552 this.color(Color::Disabled)
553 } else {
554 this.color(Color::Muted)
555 }
556 })
557 .size(IconSize::Small),
558 )
559 .child(
560 Label::new(label).when(is_disabled, |this| this.color(Color::Disabled)),
561 ),
562 )
563 .when_some(disabled_tooltip, |this, reason| {
564 this.tooltip(Tooltip::text(reason))
565 })
566 .into_any_element()
567 };
568
569 match entry {
570 ThreadWorktreeEntry::Separator => Some(
571 div()
572 .py(DynamicSpacing::Base04.rems(cx))
573 .child(Divider::horizontal())
574 .into_any_element(),
575 ),
576
577 ThreadWorktreeEntry::CreateFromCurrentBranch => {
578 let branch_label = if self.has_multiple_repositories {
579 "current branches".to_string()
580 } else {
581 self.current_branch_name
582 .clone()
583 .unwrap_or_else(|| "HEAD".to_string())
584 };
585
586 let label = format!("Create new worktree based on {branch_label}");
587
588 let disabled_tooltip = is_create_disabled.then(|| no_git_reason.clone());
589
590 let item = create_new_list_item(
591 "create-from-current".to_string().into(),
592 label.into(),
593 disabled_tooltip,
594 selected,
595 );
596
597 Some(item.into_any_element())
598 }
599
600 ThreadWorktreeEntry::CreateFromDefaultBranch {
601 default_branch_name,
602 } => {
603 let label = format!("Create new worktree based on {default_branch_name}");
604
605 let disabled_tooltip = is_create_disabled.then(|| no_git_reason.clone());
606
607 let item = create_new_list_item(
608 "create-from-main".to_string().into(),
609 label.into(),
610 disabled_tooltip,
611 selected,
612 );
613
614 Some(item.into_any_element())
615 }
616
617 ThreadWorktreeEntry::Worktree {
618 worktree,
619 positions,
620 } => {
621 let main_worktree_path = self
622 .all_worktrees
623 .iter()
624 .find(|wt| wt.is_main)
625 .map(|wt| wt.path.as_path());
626 let display_name = worktree.directory_name(main_worktree_path);
627 let first_line = display_name.lines().next().unwrap_or(&display_name);
628 let positions: Vec<_> = positions
629 .iter()
630 .copied()
631 .filter(|&pos| pos < first_line.len())
632 .collect();
633 let path = worktree.path.compact().to_string_lossy().to_string();
634 let sha = worktree.sha.chars().take(7).collect::<String>();
635
636 let is_current = self.project_worktree_paths.contains(&worktree.path);
637
638 let entry_icon = if is_current {
639 IconName::Check
640 } else {
641 IconName::GitWorktree
642 };
643
644 Some(
645 ListItem::new(SharedString::from(format!("worktree-{ix}")))
646 .inset(true)
647 .spacing(ListItemSpacing::Sparse)
648 .toggle_state(selected)
649 .child(
650 h_flex()
651 .w_full()
652 .gap_2p5()
653 .child(
654 Icon::new(entry_icon)
655 .color(if is_current {
656 Color::Accent
657 } else {
658 Color::Muted
659 })
660 .size(IconSize::Small),
661 )
662 .child(
663 v_flex()
664 .w_full()
665 .min_w_0()
666 .child(
667 HighlightedLabel::new(first_line.to_owned(), positions)
668 .truncate(),
669 )
670 .child(
671 h_flex()
672 .w_full()
673 .min_w_0()
674 .gap_1p5()
675 .when_some(
676 worktree.branch_name().map(|b| b.to_string()),
677 |this, branch| {
678 this.child(
679 Label::new(branch)
680 .size(LabelSize::Small)
681 .color(Color::Muted),
682 )
683 .child(
684 Label::new("\u{2022}")
685 .alpha(0.5)
686 .color(Color::Muted)
687 .size(LabelSize::Small),
688 )
689 },
690 )
691 .when(!sha.is_empty(), |this| {
692 this.child(
693 Label::new(sha)
694 .size(LabelSize::Small)
695 .color(Color::Muted),
696 )
697 .child(
698 Label::new("\u{2022}")
699 .alpha(0.5)
700 .color(Color::Muted)
701 .size(LabelSize::Small),
702 )
703 })
704 .child(
705 Label::new(path)
706 .truncate_start()
707 .color(Color::Muted)
708 .size(LabelSize::Small)
709 .flex_1(),
710 ),
711 ),
712 ),
713 )
714 .into_any_element(),
715 )
716 }
717
718 ThreadWorktreeEntry::CreateNamed {
719 name,
720 from_branch,
721 disabled_reason,
722 } => {
723 let branch_label = from_branch
724 .as_deref()
725 .unwrap_or(self.current_branch_name.as_deref().unwrap_or("HEAD"));
726 let label = format!("Create \"{name}\" based on {branch_label}");
727 let element_id = match from_branch {
728 Some(branch) => format!("create-named-from-{branch}"),
729 None => "create-named-from-current".to_string(),
730 };
731
732 let item = create_new_list_item(
733 element_id.into(),
734 label.into(),
735 disabled_reason.clone().map(SharedString::from),
736 selected,
737 );
738
739 Some(item.into_any_element())
740 }
741 }
742 }
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use fs::FakeFs;
749 use gpui::TestAppContext;
750 use project::Project;
751 use settings::SettingsStore;
752
753 fn init_test(cx: &mut TestAppContext) {
754 cx.update(|cx| {
755 let settings_store = SettingsStore::test(cx);
756 cx.set_global(settings_store);
757 theme_settings::init(theme::LoadThemes::JustBase, cx);
758 editor::init(cx);
759 release_channel::init("0.0.0".parse().unwrap(), cx);
760 crate::agent_panel::init(cx);
761 });
762 }
763
764 fn make_worktree(path: &str, branch: &str, is_main: bool) -> GitWorktree {
765 GitWorktree {
766 path: PathBuf::from(path),
767 ref_name: Some(format!("refs/heads/{branch}").into()),
768 sha: "abc1234".into(),
769 is_main,
770 is_bare: false,
771 }
772 }
773
774 fn build_delegate(
775 project: Entity<Project>,
776 all_worktrees: Vec<GitWorktree>,
777 project_worktree_paths: HashSet<PathBuf>,
778 current_branch_name: Option<String>,
779 default_branch_name: Option<String>,
780 has_multiple_repositories: bool,
781 ) -> ThreadWorktreePickerDelegate {
782 ThreadWorktreePickerDelegate {
783 matches: vec![ThreadWorktreeEntry::CreateFromCurrentBranch],
784 all_worktrees,
785 project_worktree_paths,
786 selected_index: 0,
787 project,
788 current_branch_name,
789 default_branch_name,
790 has_multiple_repositories,
791 }
792 }
793
794 fn entry_names(delegate: &ThreadWorktreePickerDelegate) -> Vec<String> {
795 delegate
796 .matches
797 .iter()
798 .map(|entry| match entry {
799 ThreadWorktreeEntry::CreateFromCurrentBranch => {
800 "CreateFromCurrentBranch".to_string()
801 }
802 ThreadWorktreeEntry::CreateFromDefaultBranch {
803 default_branch_name,
804 } => format!("CreateFromDefaultBranch({default_branch_name})"),
805 ThreadWorktreeEntry::Separator => "---".to_string(),
806 ThreadWorktreeEntry::Worktree { worktree, .. } => {
807 format!("Worktree({})", worktree.path.display())
808 }
809 ThreadWorktreeEntry::CreateNamed {
810 name,
811 from_branch,
812 disabled_reason,
813 } => {
814 let branch = from_branch
815 .as_deref()
816 .map(|b| format!("from {b}"))
817 .unwrap_or_else(|| "from current".to_string());
818 if disabled_reason.is_some() {
819 format!("CreateNamed({name}, {branch}, disabled)")
820 } else {
821 format!("CreateNamed({name}, {branch})")
822 }
823 }
824 })
825 .collect()
826 }
827
828 type PickerWindow = gpui::WindowHandle<Picker<ThreadWorktreePickerDelegate>>;
829
830 async fn make_picker(
831 cx: &mut TestAppContext,
832 all_worktrees: Vec<GitWorktree>,
833 project_worktree_paths: HashSet<PathBuf>,
834 current_branch_name: Option<String>,
835 default_branch_name: Option<String>,
836 has_multiple_repositories: bool,
837 ) -> PickerWindow {
838 let fs = FakeFs::new(cx.executor());
839 let project = Project::test(fs, [], cx).await;
840
841 cx.add_window(|window, cx| {
842 let delegate = build_delegate(
843 project,
844 all_worktrees,
845 project_worktree_paths,
846 current_branch_name,
847 default_branch_name,
848 has_multiple_repositories,
849 );
850 Picker::list(delegate, window, cx)
851 .list_measure_all()
852 .modal(false)
853 })
854 }
855
856 #[gpui::test]
857 async fn test_empty_query_entries(cx: &mut TestAppContext) {
858 init_test(cx);
859
860 // When on `main` with default branch also `main`, only CreateFromCurrentBranch
861 // is shown as a fixed entry. Worktrees are listed with the current one first.
862 let worktrees = vec![
863 make_worktree("/repo", "main", true),
864 make_worktree("/repo-feature", "feature", false),
865 make_worktree("/repo-bugfix", "bugfix", false),
866 ];
867 let project_paths: HashSet<PathBuf> = [PathBuf::from("/repo")].into_iter().collect();
868
869 let picker = make_picker(
870 cx,
871 worktrees,
872 project_paths,
873 Some("main".into()),
874 Some("main".into()),
875 false,
876 )
877 .await;
878
879 picker
880 .update(cx, |picker, window, cx| picker.refresh(window, cx))
881 .unwrap();
882 cx.run_until_parked();
883
884 let names = picker
885 .read_with(cx, |picker, _| entry_names(&picker.delegate))
886 .unwrap();
887
888 assert_eq!(
889 names,
890 vec![
891 "CreateFromCurrentBranch",
892 "---",
893 "Worktree(/repo)",
894 "Worktree(/repo-bugfix)",
895 "Worktree(/repo-feature)",
896 ]
897 );
898
899 // When current branch differs from default, CreateFromDefaultBranch appears.
900 picker
901 .update(cx, |picker, _window, cx| {
902 picker.delegate.current_branch_name = Some("feature".into());
903 picker.delegate.default_branch_name = Some("main".into());
904 cx.notify();
905 })
906 .unwrap();
907 picker
908 .update(cx, |picker, window, cx| picker.refresh(window, cx))
909 .unwrap();
910 cx.run_until_parked();
911
912 let names = picker
913 .read_with(cx, |picker, _| entry_names(&picker.delegate))
914 .unwrap();
915
916 assert!(names.contains(&"CreateFromDefaultBranch(main)".to_string()));
917 }
918
919 #[gpui::test]
920 async fn test_query_filtering_and_create_entries(cx: &mut TestAppContext) {
921 init_test(cx);
922
923 let picker = make_picker(
924 cx,
925 vec![
926 make_worktree("/repo", "main", true),
927 make_worktree("/repo-feature", "feature", false),
928 make_worktree("/repo-bugfix", "bugfix", false),
929 make_worktree("/my-worktree", "experiment", false),
930 ],
931 HashSet::default(),
932 Some("dev".into()),
933 Some("main".into()),
934 false,
935 )
936 .await;
937
938 // Partial match filters to matching worktrees and offers to create
939 // from both current branch and default branch.
940 picker
941 .update(cx, |picker, window, cx| {
942 picker.set_query("feat", window, cx)
943 })
944 .unwrap();
945 cx.run_until_parked();
946
947 let names = picker
948 .read_with(cx, |picker, _| entry_names(&picker.delegate))
949 .unwrap();
950 assert!(names.contains(&"Worktree(/repo-feature)".to_string()));
951 assert!(
952 names.contains(&"CreateNamed(feat, from current)".to_string()),
953 "should offer to create from current branch, got: {names:?}"
954 );
955 assert!(
956 names.contains(&"CreateNamed(feat, from main)".to_string()),
957 "should offer to create from default branch, got: {names:?}"
958 );
959 assert!(!names.contains(&"Worktree(/repo-bugfix)".to_string()));
960
961 // Exact match: both create entries appear but are disabled.
962 picker
963 .update(cx, |picker, window, cx| {
964 picker.set_query("repo-feature", window, cx)
965 })
966 .unwrap();
967 cx.run_until_parked();
968
969 let names = picker
970 .read_with(cx, |picker, _| entry_names(&picker.delegate))
971 .unwrap();
972 assert!(
973 names.contains(&"CreateNamed(repo-feature, from current, disabled)".to_string()),
974 "exact name match should show disabled create entries, got: {names:?}"
975 );
976
977 // Spaces are normalized to hyphens: "my worktree" matches "my-worktree".
978 picker
979 .update(cx, |picker, window, cx| {
980 picker.set_query("my worktree", window, cx)
981 })
982 .unwrap();
983 cx.run_until_parked();
984
985 let names = picker
986 .read_with(cx, |picker, _| entry_names(&picker.delegate))
987 .unwrap();
988 assert!(
989 names.contains(&"CreateNamed(my-worktree, from current, disabled)".to_string()),
990 "spaces should normalize to hyphens and detect existing worktree, got: {names:?}"
991 );
992 }
993
994 #[gpui::test]
995 async fn test_multi_repo_hides_worktrees_and_disables_create_named(cx: &mut TestAppContext) {
996 init_test(cx);
997
998 let picker = make_picker(
999 cx,
1000 vec![
1001 make_worktree("/repo", "main", true),
1002 make_worktree("/repo-feature", "feature", false),
1003 ],
1004 HashSet::default(),
1005 Some("main".into()),
1006 Some("main".into()),
1007 true,
1008 )
1009 .await;
1010
1011 picker
1012 .update(cx, |picker, window, cx| picker.refresh(window, cx))
1013 .unwrap();
1014 cx.run_until_parked();
1015
1016 let names = picker
1017 .read_with(cx, |picker, _| entry_names(&picker.delegate))
1018 .unwrap();
1019 assert_eq!(names, vec!["CreateFromCurrentBranch"]);
1020
1021 picker
1022 .update(cx, |picker, window, cx| {
1023 picker.set_query("new-thing", window, cx)
1024 })
1025 .unwrap();
1026 cx.run_until_parked();
1027
1028 let names = picker
1029 .read_with(cx, |picker, _| entry_names(&picker.delegate))
1030 .unwrap();
1031 assert!(
1032 names.contains(&"CreateNamed(new-thing, from current, disabled)".to_string()),
1033 "multi-repo should disable create named, got: {names:?}"
1034 );
1035 }
1036}