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 // For the main worktree, also match against "main"
365 let main_worktree_path = repo_worktrees
366 .iter()
367 .find(|wt| wt.is_main)
368 .map(|wt| wt.path.clone());
369 let candidates: Vec<_> = repo_worktrees
370 .iter()
371 .enumerate()
372 .map(|(ix, worktree)| {
373 StringMatchCandidate::new(
374 ix,
375 &worktree.directory_name(main_worktree_path.as_deref()),
376 )
377 })
378 .collect();
379
380 let executor = cx.background_executor().clone();
381
382 let task = cx.background_executor().spawn(async move {
383 fuzzy::match_strings(
384 &candidates,
385 &query,
386 true,
387 true,
388 10000,
389 &Default::default(),
390 executor,
391 )
392 .await
393 });
394
395 let repo_worktrees_clone = repo_worktrees;
396 cx.spawn_in(window, async move |picker, cx| {
397 let fuzzy_matches = task.await;
398
399 picker
400 .update_in(cx, |picker, _window, cx| {
401 let mut new_matches: Vec<ThreadWorktreeEntry> = Vec::new();
402
403 for candidate in &fuzzy_matches {
404 new_matches.push(ThreadWorktreeEntry::Worktree {
405 worktree: repo_worktrees_clone[candidate.candidate_id].clone(),
406 positions: candidate.positions.clone(),
407 });
408 }
409
410 if !new_matches.is_empty() {
411 new_matches.push(ThreadWorktreeEntry::Separator);
412 }
413 new_matches.push(ThreadWorktreeEntry::CreateNamed {
414 name: normalized_query.clone(),
415 from_branch: None,
416 disabled_reason: create_named_disabled_reason.clone(),
417 });
418 if show_default_branch_create {
419 if let Some(ref default_branch) = default_branch_name {
420 new_matches.push(ThreadWorktreeEntry::CreateNamed {
421 name: normalized_query.clone(),
422 from_branch: Some(default_branch.clone()),
423 disabled_reason: create_named_disabled_reason.clone(),
424 });
425 }
426 }
427
428 picker.delegate.matches = new_matches;
429 picker.delegate.sync_selected_index(true);
430
431 cx.notify();
432 })
433 .log_err();
434 })
435 }
436
437 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
438 let Some(entry) = self.matches.get(self.selected_index) else {
439 return;
440 };
441
442 match entry {
443 ThreadWorktreeEntry::Separator => return,
444
445 ThreadWorktreeEntry::CreateFromCurrentBranch => {
446 window.dispatch_action(
447 Box::new(CreateWorktree {
448 worktree_name: None,
449 branch_target: NewWorktreeBranchTarget::CurrentBranch,
450 }),
451 cx,
452 );
453 }
454
455 ThreadWorktreeEntry::CreateFromDefaultBranch {
456 default_branch_name,
457 } => {
458 window.dispatch_action(
459 Box::new(CreateWorktree {
460 worktree_name: None,
461 branch_target: NewWorktreeBranchTarget::ExistingBranch {
462 name: default_branch_name.clone(),
463 },
464 }),
465 cx,
466 );
467 }
468
469 ThreadWorktreeEntry::Worktree { worktree, .. } => {
470 let is_current = self.project_worktree_paths.contains(&worktree.path);
471
472 if is_current {
473 // Already in this worktree — just dismiss
474 } else {
475 let main_worktree_path = self
476 .all_worktrees
477 .iter()
478 .find(|wt| wt.is_main)
479 .map(|wt| wt.path.as_path());
480 window.dispatch_action(
481 Box::new(SwitchWorktree {
482 path: worktree.path.clone(),
483 display_name: worktree.directory_name(main_worktree_path),
484 }),
485 cx,
486 );
487 }
488 }
489
490 ThreadWorktreeEntry::CreateNamed {
491 name,
492 from_branch,
493 disabled_reason: None,
494 } => {
495 let branch_target = match from_branch {
496 Some(branch) => NewWorktreeBranchTarget::ExistingBranch {
497 name: branch.clone(),
498 },
499 None => NewWorktreeBranchTarget::CurrentBranch,
500 };
501 window.dispatch_action(
502 Box::new(CreateWorktree {
503 worktree_name: Some(name.clone()),
504 branch_target,
505 }),
506 cx,
507 );
508 }
509
510 ThreadWorktreeEntry::CreateNamed {
511 disabled_reason: Some(_),
512 ..
513 } => {
514 return;
515 }
516 }
517
518 cx.emit(DismissEvent);
519 }
520
521 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
522
523 fn render_match(
524 &self,
525 ix: usize,
526 selected: bool,
527 _window: &mut Window,
528 cx: &mut Context<Picker<Self>>,
529 ) -> Option<Self::ListItem> {
530 let entry = self.matches.get(ix)?;
531 let project = self.project.read(cx);
532 let is_create_disabled = project.repositories(cx).is_empty() || project.is_via_collab();
533
534 let no_git_reason: SharedString = "Requires a Git repository in the project".into();
535
536 let create_new_list_item = |id: SharedString,
537 label: SharedString,
538 disabled_tooltip: Option<SharedString>,
539 selected: bool| {
540 let is_disabled = disabled_tooltip.is_some();
541 ListItem::new(id)
542 .inset(true)
543 .spacing(ListItemSpacing::Sparse)
544 .toggle_state(selected)
545 .child(
546 h_flex()
547 .w_full()
548 .gap_2p5()
549 .child(
550 Icon::new(IconName::Plus)
551 .map(|this| {
552 if is_disabled {
553 this.color(Color::Disabled)
554 } else {
555 this.color(Color::Muted)
556 }
557 })
558 .size(IconSize::Small),
559 )
560 .child(
561 Label::new(label).when(is_disabled, |this| this.color(Color::Disabled)),
562 ),
563 )
564 .when_some(disabled_tooltip, |this, reason| {
565 this.tooltip(Tooltip::text(reason))
566 })
567 .into_any_element()
568 };
569
570 match entry {
571 ThreadWorktreeEntry::Separator => Some(
572 div()
573 .py(DynamicSpacing::Base04.rems(cx))
574 .child(Divider::horizontal())
575 .into_any_element(),
576 ),
577
578 ThreadWorktreeEntry::CreateFromCurrentBranch => {
579 let branch_label = if self.has_multiple_repositories {
580 "current branches".to_string()
581 } else {
582 self.current_branch_name
583 .clone()
584 .unwrap_or_else(|| "HEAD".to_string())
585 };
586
587 let label = format!("Create new worktree based on {branch_label}");
588
589 let disabled_tooltip = is_create_disabled.then(|| no_git_reason.clone());
590
591 let item = create_new_list_item(
592 "create-from-current".to_string().into(),
593 label.into(),
594 disabled_tooltip,
595 selected,
596 );
597
598 Some(item.into_any_element())
599 }
600
601 ThreadWorktreeEntry::CreateFromDefaultBranch {
602 default_branch_name,
603 } => {
604 let label = format!("Create new worktree based on {default_branch_name}");
605
606 let disabled_tooltip = is_create_disabled.then(|| no_git_reason.clone());
607
608 let item = create_new_list_item(
609 "create-from-main".to_string().into(),
610 label.into(),
611 disabled_tooltip,
612 selected,
613 );
614
615 Some(item.into_any_element())
616 }
617
618 ThreadWorktreeEntry::Worktree {
619 worktree,
620 positions,
621 } => {
622 let main_worktree_path = self
623 .all_worktrees
624 .iter()
625 .find(|wt| wt.is_main)
626 .map(|wt| wt.path.as_path());
627 let display_name = worktree.directory_name(main_worktree_path);
628 let first_line = display_name.lines().next().unwrap_or(&display_name);
629 let positions: Vec<_> = positions
630 .iter()
631 .copied()
632 .filter(|&pos| pos < first_line.len())
633 .collect();
634 let path = worktree.path.compact().to_string_lossy().to_string();
635 let sha = worktree.sha.chars().take(7).collect::<String>();
636
637 let is_current = self.project_worktree_paths.contains(&worktree.path);
638
639 let entry_icon = if is_current {
640 IconName::Check
641 } else {
642 IconName::GitWorktree
643 };
644
645 Some(
646 ListItem::new(SharedString::from(format!("worktree-{ix}")))
647 .inset(true)
648 .spacing(ListItemSpacing::Sparse)
649 .toggle_state(selected)
650 .child(
651 h_flex()
652 .w_full()
653 .gap_2p5()
654 .child(
655 Icon::new(entry_icon)
656 .color(if is_current {
657 Color::Accent
658 } else {
659 Color::Muted
660 })
661 .size(IconSize::Small),
662 )
663 .child(
664 v_flex()
665 .w_full()
666 .min_w_0()
667 .child(
668 HighlightedLabel::new(first_line.to_owned(), positions)
669 .truncate(),
670 )
671 .child(
672 h_flex()
673 .w_full()
674 .min_w_0()
675 .gap_1p5()
676 .when_some(
677 worktree.branch_name().map(|b| b.to_string()),
678 |this, branch| {
679 this.child(
680 Label::new(branch)
681 .size(LabelSize::Small)
682 .color(Color::Muted),
683 )
684 .child(
685 Label::new("\u{2022}")
686 .alpha(0.5)
687 .color(Color::Muted)
688 .size(LabelSize::Small),
689 )
690 },
691 )
692 .when(!sha.is_empty(), |this| {
693 this.child(
694 Label::new(sha)
695 .size(LabelSize::Small)
696 .color(Color::Muted),
697 )
698 .child(
699 Label::new("\u{2022}")
700 .alpha(0.5)
701 .color(Color::Muted)
702 .size(LabelSize::Small),
703 )
704 })
705 .child(
706 Label::new(path)
707 .truncate_start()
708 .color(Color::Muted)
709 .size(LabelSize::Small)
710 .flex_1(),
711 ),
712 ),
713 ),
714 )
715 .into_any_element(),
716 )
717 }
718
719 ThreadWorktreeEntry::CreateNamed {
720 name,
721 from_branch,
722 disabled_reason,
723 } => {
724 let branch_label = from_branch
725 .as_deref()
726 .unwrap_or(self.current_branch_name.as_deref().unwrap_or("HEAD"));
727 let label = format!("Create \"{name}\" based on {branch_label}");
728 let element_id = match from_branch {
729 Some(branch) => format!("create-named-from-{branch}"),
730 None => "create-named-from-current".to_string(),
731 };
732
733 let item = create_new_list_item(
734 element_id.into(),
735 label.into(),
736 disabled_reason.clone().map(SharedString::from),
737 selected,
738 );
739
740 Some(item.into_any_element())
741 }
742 }
743 }
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749 use fs::FakeFs;
750 use gpui::TestAppContext;
751 use project::Project;
752 use settings::SettingsStore;
753
754 fn init_test(cx: &mut TestAppContext) {
755 cx.update(|cx| {
756 let settings_store = SettingsStore::test(cx);
757 cx.set_global(settings_store);
758 theme_settings::init(theme::LoadThemes::JustBase, cx);
759 editor::init(cx);
760 release_channel::init("0.0.0".parse().unwrap(), cx);
761 crate::agent_panel::init(cx);
762 });
763 }
764
765 fn make_worktree(path: &str, branch: &str, is_main: bool) -> GitWorktree {
766 GitWorktree {
767 path: PathBuf::from(path),
768 ref_name: Some(format!("refs/heads/{branch}").into()),
769 sha: "abc1234".into(),
770 is_main,
771 is_bare: false,
772 }
773 }
774
775 fn build_delegate(
776 project: Entity<Project>,
777 all_worktrees: Vec<GitWorktree>,
778 project_worktree_paths: HashSet<PathBuf>,
779 current_branch_name: Option<String>,
780 default_branch_name: Option<String>,
781 has_multiple_repositories: bool,
782 ) -> ThreadWorktreePickerDelegate {
783 ThreadWorktreePickerDelegate {
784 matches: vec![ThreadWorktreeEntry::CreateFromCurrentBranch],
785 all_worktrees,
786 project_worktree_paths,
787 selected_index: 0,
788 project,
789 current_branch_name,
790 default_branch_name,
791 has_multiple_repositories,
792 }
793 }
794
795 fn entry_names(delegate: &ThreadWorktreePickerDelegate) -> Vec<String> {
796 delegate
797 .matches
798 .iter()
799 .map(|entry| match entry {
800 ThreadWorktreeEntry::CreateFromCurrentBranch => {
801 "CreateFromCurrentBranch".to_string()
802 }
803 ThreadWorktreeEntry::CreateFromDefaultBranch {
804 default_branch_name,
805 } => format!("CreateFromDefaultBranch({default_branch_name})"),
806 ThreadWorktreeEntry::Separator => "---".to_string(),
807 ThreadWorktreeEntry::Worktree { worktree, .. } => {
808 format!("Worktree({})", worktree.path.display())
809 }
810 ThreadWorktreeEntry::CreateNamed {
811 name,
812 from_branch,
813 disabled_reason,
814 } => {
815 let branch = from_branch
816 .as_deref()
817 .map(|b| format!("from {b}"))
818 .unwrap_or_else(|| "from current".to_string());
819 if disabled_reason.is_some() {
820 format!("CreateNamed({name}, {branch}, disabled)")
821 } else {
822 format!("CreateNamed({name}, {branch})")
823 }
824 }
825 })
826 .collect()
827 }
828
829 type PickerWindow = gpui::WindowHandle<Picker<ThreadWorktreePickerDelegate>>;
830
831 async fn make_picker(
832 cx: &mut TestAppContext,
833 all_worktrees: Vec<GitWorktree>,
834 project_worktree_paths: HashSet<PathBuf>,
835 current_branch_name: Option<String>,
836 default_branch_name: Option<String>,
837 has_multiple_repositories: bool,
838 ) -> PickerWindow {
839 let fs = FakeFs::new(cx.executor());
840 let project = Project::test(fs, [], cx).await;
841
842 cx.add_window(|window, cx| {
843 let delegate = build_delegate(
844 project,
845 all_worktrees,
846 project_worktree_paths,
847 current_branch_name,
848 default_branch_name,
849 has_multiple_repositories,
850 );
851 Picker::list(delegate, window, cx)
852 .list_measure_all()
853 .modal(false)
854 })
855 }
856
857 #[gpui::test]
858 async fn test_empty_query_entries(cx: &mut TestAppContext) {
859 init_test(cx);
860
861 // When on `main` with default branch also `main`, only CreateFromCurrentBranch
862 // is shown as a fixed entry. Worktrees are listed with the current one first.
863 let worktrees = vec![
864 make_worktree("/repo", "main", true),
865 make_worktree("/repo-feature", "feature", false),
866 make_worktree("/repo-bugfix", "bugfix", false),
867 ];
868 let project_paths: HashSet<PathBuf> = [PathBuf::from("/repo")].into_iter().collect();
869
870 let picker = make_picker(
871 cx,
872 worktrees,
873 project_paths,
874 Some("main".into()),
875 Some("main".into()),
876 false,
877 )
878 .await;
879
880 picker
881 .update(cx, |picker, window, cx| picker.refresh(window, cx))
882 .unwrap();
883 cx.run_until_parked();
884
885 let names = picker
886 .read_with(cx, |picker, _| entry_names(&picker.delegate))
887 .unwrap();
888
889 assert_eq!(
890 names,
891 vec![
892 "CreateFromCurrentBranch",
893 "---",
894 "Worktree(/repo)",
895 "Worktree(/repo-bugfix)",
896 "Worktree(/repo-feature)",
897 ]
898 );
899
900 // When current branch differs from default, CreateFromDefaultBranch appears.
901 picker
902 .update(cx, |picker, _window, cx| {
903 picker.delegate.current_branch_name = Some("feature".into());
904 picker.delegate.default_branch_name = Some("main".into());
905 cx.notify();
906 })
907 .unwrap();
908 picker
909 .update(cx, |picker, window, cx| picker.refresh(window, cx))
910 .unwrap();
911 cx.run_until_parked();
912
913 let names = picker
914 .read_with(cx, |picker, _| entry_names(&picker.delegate))
915 .unwrap();
916
917 assert!(names.contains(&"CreateFromDefaultBranch(main)".to_string()));
918 }
919
920 #[gpui::test]
921 async fn test_query_filtering_and_create_entries(cx: &mut TestAppContext) {
922 init_test(cx);
923
924 let picker = make_picker(
925 cx,
926 vec![
927 make_worktree("/repo", "main", true),
928 make_worktree("/repo-feature", "feature", false),
929 make_worktree("/repo-bugfix", "bugfix", false),
930 make_worktree("/my-worktree", "experiment", false),
931 ],
932 HashSet::default(),
933 Some("dev".into()),
934 Some("main".into()),
935 false,
936 )
937 .await;
938
939 // Partial match filters to matching worktrees and offers to create
940 // from both current branch and default branch.
941 picker
942 .update(cx, |picker, window, cx| {
943 picker.set_query("feat", window, cx)
944 })
945 .unwrap();
946 cx.run_until_parked();
947
948 let names = picker
949 .read_with(cx, |picker, _| entry_names(&picker.delegate))
950 .unwrap();
951 assert!(names.contains(&"Worktree(/repo-feature)".to_string()));
952 assert!(
953 names.contains(&"CreateNamed(feat, from current)".to_string()),
954 "should offer to create from current branch, got: {names:?}"
955 );
956 assert!(
957 names.contains(&"CreateNamed(feat, from main)".to_string()),
958 "should offer to create from default branch, got: {names:?}"
959 );
960 assert!(!names.contains(&"Worktree(/repo-bugfix)".to_string()));
961
962 // Exact match: both create entries appear but are disabled.
963 picker
964 .update(cx, |picker, window, cx| {
965 picker.set_query("repo-feature", window, cx)
966 })
967 .unwrap();
968 cx.run_until_parked();
969
970 let names = picker
971 .read_with(cx, |picker, _| entry_names(&picker.delegate))
972 .unwrap();
973 assert!(
974 names.contains(&"CreateNamed(repo-feature, from current, disabled)".to_string()),
975 "exact name match should show disabled create entries, got: {names:?}"
976 );
977
978 // Spaces are normalized to hyphens: "my worktree" matches "my-worktree".
979 picker
980 .update(cx, |picker, window, cx| {
981 picker.set_query("my worktree", window, cx)
982 })
983 .unwrap();
984 cx.run_until_parked();
985
986 let names = picker
987 .read_with(cx, |picker, _| entry_names(&picker.delegate))
988 .unwrap();
989 assert!(
990 names.contains(&"CreateNamed(my-worktree, from current, disabled)".to_string()),
991 "spaces should normalize to hyphens and detect existing worktree, got: {names:?}"
992 );
993 }
994
995 #[gpui::test]
996 async fn test_multi_repo_hides_worktrees_and_disables_create_named(cx: &mut TestAppContext) {
997 init_test(cx);
998
999 let picker = make_picker(
1000 cx,
1001 vec![
1002 make_worktree("/repo", "main", true),
1003 make_worktree("/repo-feature", "feature", false),
1004 ],
1005 HashSet::default(),
1006 Some("main".into()),
1007 Some("main".into()),
1008 true,
1009 )
1010 .await;
1011
1012 picker
1013 .update(cx, |picker, window, cx| picker.refresh(window, cx))
1014 .unwrap();
1015 cx.run_until_parked();
1016
1017 let names = picker
1018 .read_with(cx, |picker, _| entry_names(&picker.delegate))
1019 .unwrap();
1020 assert_eq!(names, vec!["CreateFromCurrentBranch"]);
1021
1022 picker
1023 .update(cx, |picker, window, cx| {
1024 picker.set_query("new-thing", window, cx)
1025 })
1026 .unwrap();
1027 cx.run_until_parked();
1028
1029 let names = picker
1030 .read_with(cx, |picker, _| entry_names(&picker.delegate))
1031 .unwrap();
1032 assert!(
1033 names.contains(&"CreateNamed(new-thing, from current, disabled)".to_string()),
1034 "multi-repo should disable create named, got: {names:?}"
1035 );
1036 }
1037}