Skip to repository content2302 lines · 88.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:30:26.559Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
worktree_picker.rs
1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use anyhow::Context as _;
5use collections::HashSet;
6use fuzzy::StringMatchCandidate;
7use git::repository::Worktree as GitWorktree;
8use gpui::{
9 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
10 InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, PromptLevel,
11 Render, SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions,
12};
13use picker::{Picker, PickerDelegate, PickerEditorPosition};
14use project::Project;
15use project::git_store::RepositoryEvent;
16use ui::{
17 Button, CommonAnimationExt as _, Divider, HighlightedLabel, IconButton, KeyBinding, ListItem,
18 ListItemSpacing, ListSubHeader, Tooltip, prelude::*,
19};
20use util::ResultExt as _;
21use util::paths::PathExt;
22use workspace::{
23 ModalView, MultiWorkspace, Workspace, dock::DockPosition, notifications::DetachAndPromptErr,
24};
25
26use crate::git_panel::show_error_toast;
27use crate::worktree_service::{RemoteBranchName, WorktreeCreateTarget, worktree_create_targets};
28use zed_actions::{
29 CreateWorktree, NewWorktreeBranchTarget, OpenWorktreeInNewWindow, OpenWorktreeSetupTasks,
30 SwitchWorktree,
31};
32
33actions!(
34 worktree_picker,
35 [
36 /// Deletes the selected git worktree.
37 DeleteWorktree,
38 /// Force deletes the selected git worktree.
39 ForceDeleteWorktree
40 ]
41);
42
43pub struct WorktreePicker {
44 picker: Entity<Picker<WorktreePickerDelegate>>,
45 focus_handle: FocusHandle,
46 _subscriptions: Vec<Subscription>,
47}
48
49impl WorktreePicker {
50 pub fn new(
51 project: Entity<Project>,
52 workspace: WeakEntity<Workspace>,
53 window: &mut Window,
54 cx: &mut Context<Self>,
55 ) -> Self {
56 let focused_dock = workspace
57 .upgrade()
58 .and_then(|workspace| workspace.read(cx).focused_dock_position(window, cx));
59 Self::new_inner(project, workspace, focused_dock, false, window, cx)
60 }
61
62 pub fn new_modal(
63 project: Entity<Project>,
64 workspace: WeakEntity<Workspace>,
65 focused_dock: Option<DockPosition>,
66 window: &mut Window,
67 cx: &mut Context<Self>,
68 ) -> Self {
69 Self::new_inner(project, workspace, focused_dock, true, window, cx)
70 }
71
72 fn new_inner(
73 project: Entity<Project>,
74 workspace: WeakEntity<Workspace>,
75 focused_dock: Option<DockPosition>,
76 show_footer: bool,
77 window: &mut Window,
78 cx: &mut Context<Self>,
79 ) -> Self {
80 let project_ref = project.read(cx);
81
82 let active_worktree_paths: HashSet<PathBuf> = project_ref
83 .visible_worktrees(cx)
84 .map(|wt| wt.read(cx).abs_path().to_path_buf())
85 .collect();
86
87 let project_worktree_paths = active_worktree_paths.clone();
88
89 let has_multiple_repositories = project_ref.repositories(cx).len() > 1;
90 let repository = project_ref.active_repository(cx);
91
92 let current_branch_name = repository.as_ref().and_then(|repo| {
93 repo.read(cx)
94 .branch
95 .as_ref()
96 .map(|branch| branch.name().to_string())
97 });
98
99 let all_worktrees_request = repository
100 .clone()
101 .map(|repository| repository.update(cx, |repository, _| repository.worktrees()));
102
103 let default_branch_request = repository.clone().map(|repository| {
104 repository.update(cx, |repository, _| repository.default_branch(true))
105 });
106
107 let initial_matches = vec![WorktreeEntry::CreateFromCurrentBranch];
108
109 let delegate = WorktreePickerDelegate {
110 matches: initial_matches,
111 all_worktrees: Vec::new(),
112 project_worktree_paths,
113 selected_index: 0,
114 project,
115 workspace,
116 focused_dock,
117 current_branch_name,
118 default_branch: None,
119 has_multiple_repositories,
120 focus_handle: cx.focus_handle(),
121 show_footer,
122 modifiers: Modifiers::default(),
123 active_worktree_paths,
124 hovered_delete_index: None,
125 deleting_worktree_paths: HashSet::default(),
126 };
127
128 let picker = cx.new(|cx| {
129 Picker::list(delegate, window, cx)
130 .list_measure_all()
131 .show_scrollbar(true)
132 .embedded()
133 });
134
135 let picker_focus_handle = picker.focus_handle(cx);
136 picker.update(cx, |picker, _| {
137 picker.delegate.focus_handle = picker_focus_handle;
138 });
139
140 let mut subscriptions = Vec::new();
141
142 {
143 let picker_handle = picker.downgrade();
144 cx.spawn_in(window, async move |_this, cx| {
145 let all_worktrees: Vec<_> = match all_worktrees_request {
146 Some(req) => match req.await {
147 Ok(Ok(worktrees)) => {
148 worktrees.into_iter().filter(|wt| !wt.is_bare).collect()
149 }
150 Ok(Err(err)) => {
151 log::warn!("WorktreePicker: git worktree list failed: {err}");
152 return anyhow::Ok(());
153 }
154 Err(_) => {
155 log::warn!("WorktreePicker: worktree request was cancelled");
156 return anyhow::Ok(());
157 }
158 },
159 None => Vec::new(),
160 };
161
162 let default_branch = match default_branch_request {
163 Some(req) => req.await.ok().and_then(Result::ok).flatten(),
164 None => None,
165 };
166
167 picker_handle.update_in(cx, |picker, window, cx| {
168 picker.delegate.all_worktrees = all_worktrees;
169 picker.delegate.default_branch =
170 default_branch.and_then(|branch| RemoteBranchName::parse(&branch));
171 picker.delegate.refresh_project_worktree_paths(window, cx);
172 picker.refresh(window, cx);
173 })?;
174
175 anyhow::Ok(())
176 })
177 .detach_and_log_err(cx);
178 }
179
180 if let Some(repo) = &repository {
181 let picker_entity = picker.downgrade();
182 subscriptions.push(cx.subscribe_in(
183 repo,
184 window,
185 move |_this, repo, event: &RepositoryEvent, window, cx| {
186 if matches!(event, RepositoryEvent::GitWorktreeListChanged) {
187 let worktrees_request = repo.update(cx, |repo, _| repo.worktrees());
188 let picker = picker_entity.clone();
189 cx.spawn_in(window, async move |_, cx| {
190 let all_worktrees: Vec<_> = worktrees_request
191 .await??
192 .into_iter()
193 .filter(|wt| !wt.is_bare)
194 .collect();
195 picker.update_in(cx, |picker, window, cx| {
196 picker.delegate.all_worktrees = all_worktrees;
197 picker.refresh(window, cx);
198 })?;
199 anyhow::Ok(())
200 })
201 .detach_and_log_err(cx);
202 }
203 },
204 ));
205 }
206
207 subscriptions.push(cx.subscribe(&picker, |_, _, _, cx| {
208 cx.emit(DismissEvent);
209 }));
210
211 Self {
212 focus_handle: picker.focus_handle(cx),
213 picker,
214 _subscriptions: subscriptions,
215 }
216 }
217
218 fn handle_modifiers_changed(
219 &mut self,
220 ev: &ModifiersChangedEvent,
221 _: &mut Window,
222 cx: &mut Context<Self>,
223 ) {
224 self.picker.update(cx, |picker, cx| {
225 picker.delegate.modifiers = ev.modifiers;
226 cx.notify();
227 });
228 }
229}
230
231impl Focusable for WorktreePicker {
232 fn focus_handle(&self, _cx: &App) -> FocusHandle {
233 self.focus_handle.clone()
234 }
235}
236
237impl ModalView for WorktreePicker {}
238impl EventEmitter<DismissEvent> for WorktreePicker {}
239
240impl Render for WorktreePicker {
241 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
242 v_flex()
243 .key_context("WorktreePicker")
244 .elevation_3(cx)
245 .child(self.picker.clone())
246 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
247 .on_mouse_down_out(cx.listener(|_, _, _, cx| {
248 cx.emit(DismissEvent);
249 }))
250 .on_action(cx.listener(|_, _: &OpenWorktreeSetupTasks, _, cx| {
251 cx.emit(DismissEvent);
252 cx.propagate();
253 }))
254 .on_action(cx.listener(|this, _: &DeleteWorktree, window, cx| {
255 this.picker.update(cx, |picker, cx| {
256 let ix = picker.delegate.selected_index;
257 picker.delegate.delete_worktree(ix, false, window, cx);
258 });
259 }))
260 .on_action(cx.listener(|this, _: &ForceDeleteWorktree, window, cx| {
261 this.picker.update(cx, |picker, cx| {
262 let ix = picker.delegate.selected_index;
263 picker.delegate.delete_worktree(ix, true, window, cx);
264 });
265 }))
266 }
267}
268
269#[derive(Clone)]
270enum WorktreeEntry {
271 CreateFromCurrentBranch,
272 CreateFromDefaultBranch {
273 default_branch: RemoteBranchName,
274 },
275 Separator,
276 SectionHeader(SharedString),
277 Worktree {
278 worktree: GitWorktree,
279 positions: Vec<usize>,
280 },
281 CreateNamed {
282 name: String,
283 from_branch: Option<RemoteBranchName>,
284 disabled_reason: Option<String>,
285 },
286}
287
288struct WorktreePickerDelegate {
289 matches: Vec<WorktreeEntry>,
290 all_worktrees: Vec<GitWorktree>,
291 project_worktree_paths: HashSet<PathBuf>,
292 active_worktree_paths: HashSet<PathBuf>,
293 selected_index: usize,
294 project: Entity<Project>,
295 workspace: WeakEntity<Workspace>,
296 focused_dock: Option<DockPosition>,
297 current_branch_name: Option<String>,
298 default_branch: Option<RemoteBranchName>,
299 has_multiple_repositories: bool,
300 focus_handle: FocusHandle,
301 show_footer: bool,
302 modifiers: Modifiers,
303 hovered_delete_index: Option<usize>,
304 deleting_worktree_paths: HashSet<PathBuf>,
305}
306
307fn remove_worktree_command(path: &Path, force: bool) -> String {
308 if force {
309 format!("worktree remove --force {}", path.display())
310 } else {
311 format!("worktree remove {}", path.display())
312 }
313}
314
315struct WorktreeRemoveForceDeletePrompt {
316 required_error_substrings: &'static [&'static str],
317 message: fn(&str) -> String,
318}
319
320impl WorktreeRemoveForceDeletePrompt {
321 fn matches(&self, normalized_error_message: &str) -> bool {
322 self.required_error_substrings
323 .iter()
324 .all(|substring| normalized_error_message.contains(substring))
325 }
326}
327
328const WORKTREE_REMOVE_FORCE_DELETE_PROMPTS: &[WorktreeRemoveForceDeletePrompt] =
329 &[WorktreeRemoveForceDeletePrompt {
330 required_error_substrings: &[
331 "contains modified or untracked files",
332 "use --force to delete it",
333 ],
334 message: dirty_worktree_force_delete_prompt,
335 }];
336
337fn dirty_worktree_force_delete_prompt(display_name: &str) -> String {
338 format!("Worktree \"{display_name}\" contains modified or untracked files. Force delete it?")
339}
340
341fn force_delete_prompt_for_worktree_remove_error(
342 error: &anyhow::Error,
343 display_name: &str,
344) -> Option<String> {
345 let normalized_error_message = error.to_string().to_lowercase();
346 WORKTREE_REMOVE_FORCE_DELETE_PROMPTS
347 .iter()
348 .find(|prompt| prompt.matches(&normalized_error_message))
349 .map(|prompt| (prompt.message)(display_name))
350}
351
352struct DeleteWorktreeTooltip {
353 picker: WeakEntity<Picker<WorktreePickerDelegate>>,
354 focus_handle: FocusHandle,
355 delete_index: usize,
356 _subscription: Subscription,
357}
358
359impl DeleteWorktreeTooltip {
360 fn new(
361 picker: Entity<Picker<WorktreePickerDelegate>>,
362 focus_handle: FocusHandle,
363 delete_index: usize,
364 cx: &mut Context<Self>,
365 ) -> Self {
366 let subscription = cx.observe(&picker, |_, _, cx| cx.notify());
367 Self {
368 picker: picker.downgrade(),
369 focus_handle,
370 delete_index,
371 _subscription: subscription,
372 }
373 }
374}
375
376impl Render for DeleteWorktreeTooltip {
377 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
378 let force_delete = self
379 .picker
380 .read_with(cx, |picker, _| {
381 picker
382 .delegate
383 .is_force_delete_hovering_index(self.delete_index)
384 })
385 .unwrap_or(false);
386
387 if force_delete {
388 Tooltip::for_action_in(
389 "Force Delete Worktree",
390 &ForceDeleteWorktree,
391 &self.focus_handle,
392 cx,
393 )
394 .into_any_element()
395 } else {
396 Tooltip::with_meta_in(
397 "Delete Worktree",
398 Some(&DeleteWorktree),
399 "Hold alt to force delete",
400 &self.focus_handle,
401 cx,
402 )
403 .into_any_element()
404 }
405 }
406}
407
408impl WorktreePickerDelegate {
409 fn build_fixed_entries(&self) -> Vec<WorktreeEntry> {
410 worktree_create_targets(
411 self.has_multiple_repositories,
412 self.default_branch.clone(),
413 self.current_branch_name.as_deref(),
414 )
415 .into_iter()
416 .map(|target| match target {
417 WorktreeCreateTarget::CurrentBranch => WorktreeEntry::CreateFromCurrentBranch,
418 WorktreeCreateTarget::DefaultBranch(default_branch) => {
419 WorktreeEntry::CreateFromDefaultBranch { default_branch }
420 }
421 })
422 .collect()
423 }
424
425 fn all_repo_worktrees(&self) -> &[GitWorktree] {
426 &self.all_worktrees
427 }
428
429 fn creation_blocked_reason(&self, cx: &App) -> Option<SharedString> {
430 let project = self.project.read(cx);
431 if project.is_via_collab() {
432 Some("Worktree creation is not supported in collaborative projects".into())
433 } else if project.repositories(cx).is_empty() {
434 Some("Requires a Git repository in the project".into())
435 } else {
436 None
437 }
438 }
439
440 fn can_delete_worktree(&self, worktree: &GitWorktree) -> bool {
441 !worktree.is_main && !self.project_worktree_paths.contains(&worktree.path)
442 }
443
444 fn refresh_project_worktree_paths(&mut self, window: &mut Window, cx: &mut App) {
445 let mut paths = self.active_worktree_paths.clone();
446
447 if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten()
448 && let Some(workspace) = self.workspace.upgrade()
449 {
450 let group_key = workspace.read(cx).project_group_key(cx);
451 if let Some(group_workspaces) = multi_workspace
452 .read(cx)
453 .workspaces_for_project_group(&group_key, cx)
454 {
455 for group_workspace in group_workspaces {
456 for worktree in group_workspace
457 .read(cx)
458 .project()
459 .read(cx)
460 .visible_worktrees(cx)
461 {
462 paths.insert(worktree.read(cx).abs_path().to_path_buf());
463 }
464 }
465 }
466 }
467
468 self.project_worktree_paths = paths;
469 }
470
471 fn is_force_delete_hovering_index(&self, index: usize) -> bool {
472 self.modifiers.alt && self.hovered_delete_index == Some(index)
473 }
474
475 fn delete_worktree(
476 &mut self,
477 ix: usize,
478 force: bool,
479 window: &mut Window,
480 cx: &mut Context<Picker<Self>>,
481 ) {
482 let Some(entry) = self.matches.get(ix) else {
483 return;
484 };
485 let WorktreeEntry::Worktree { worktree, .. } = entry else {
486 return;
487 };
488 if !self.can_delete_worktree(worktree)
489 || self.deleting_worktree_paths.contains(&worktree.path)
490 {
491 return;
492 }
493
494 let repo = self.project.read(cx).active_repository(cx);
495 let Some(repo) = repo else {
496 return;
497 };
498 let path = worktree.path.clone();
499 let display_name = worktree.directory_name(
500 self.all_worktrees
501 .iter()
502 .find(|worktree| worktree.is_main)
503 .map(|worktree| worktree.path.as_path()),
504 );
505 let workspace = self.workspace.clone();
506
507 self.deleting_worktree_paths.insert(path.clone());
508 if self.hovered_delete_index == Some(ix) {
509 self.hovered_delete_index = None;
510 }
511 cx.notify();
512
513 cx.spawn_in(window, async move |picker, cx| {
514 let initial_result = match repo
515 .update(cx, |repo, _| repo.remove_worktree(path.clone(), force))
516 .await
517 {
518 Ok(result) => result,
519 Err(error) => {
520 picker.update_in(cx, |picker, _window, cx| {
521 if picker.delegate.deleting_worktree_paths.remove(&path) {
522 cx.notify();
523 }
524 })?;
525 return Err(error.into());
526 }
527 };
528
529 let (result, attempted_force) = match initial_result {
530 Ok(()) => (Ok(()), force),
531 Err(error) => {
532 log::error!("Failed to remove worktree: {}", error);
533
534 let force_delete_prompt = (!force)
535 .then(|| {
536 force_delete_prompt_for_worktree_remove_error(&error, &display_name)
537 })
538 .flatten();
539
540 if let Some(prompt_message) = force_delete_prompt {
541 picker.update_in(cx, |picker, _window, cx| {
542 if picker.delegate.deleting_worktree_paths.remove(&path) {
543 cx.notify();
544 }
545 })?;
546
547 let answer = cx.update(|window, cx| {
548 window.prompt(
549 PromptLevel::Warning,
550 &prompt_message,
551 None,
552 &["Force Delete", "Cancel"],
553 cx,
554 )
555 })?;
556
557 if answer.await != Ok(0) {
558 return Ok(());
559 }
560
561 let should_retry = picker.update_in(cx, |picker, _window, cx| {
562 let worktree_still_exists = picker
563 .delegate
564 .all_worktrees
565 .iter()
566 .any(|worktree| worktree.path == path);
567 if !worktree_still_exists
568 || !picker.delegate.deleting_worktree_paths.insert(path.clone())
569 {
570 return false;
571 }
572 cx.notify();
573 true
574 })?;
575
576 if !should_retry {
577 return Ok(());
578 }
579
580 let retry = match repo
581 .update(cx, |repo, _| repo.remove_worktree(path.clone(), true))
582 .await
583 {
584 Ok(result) => result,
585 Err(error) => {
586 picker.update_in(cx, |picker, _window, cx| {
587 if picker.delegate.deleting_worktree_paths.remove(&path) {
588 cx.notify();
589 }
590 })?;
591 return Err(error.into());
592 }
593 };
594
595 if let Err(error) = &retry {
596 log::error!("Failed to force remove worktree: {error}");
597 }
598
599 (retry, true)
600 } else {
601 (Err(error), force)
602 }
603 }
604 };
605
606 if let Err(error) = result {
607 picker.update_in(cx, |picker, _window, cx| {
608 if picker.delegate.deleting_worktree_paths.remove(&path) {
609 cx.notify();
610 }
611 })?;
612
613 if let Some(workspace) = workspace.upgrade() {
614 cx.update(|_window, cx| {
615 show_error_toast(
616 workspace,
617 remove_worktree_command(&path, attempted_force),
618 error,
619 cx,
620 )
621 })?;
622 }
623
624 return Ok(());
625 }
626
627 picker.update_in(cx, |picker, _window, cx| {
628 picker.delegate.deleting_worktree_paths.remove(&path);
629 picker.delegate.matches.retain(|e| {
630 !matches!(e, WorktreeEntry::Worktree { worktree, .. } if worktree.path == path)
631 });
632 picker.delegate.all_worktrees.retain(|w| w.path != path);
633 if picker.delegate.matches.is_empty() {
634 picker.delegate.selected_index = 0;
635 } else if picker.delegate.selected_index >= picker.delegate.matches.len() {
636 picker.delegate.selected_index = picker.delegate.matches.len() - 1;
637 }
638 picker.delegate.hovered_delete_index = None;
639 cx.notify();
640 })?;
641
642 anyhow::Ok(())
643 })
644 .detach_and_log_err(cx);
645 }
646
647 /// Finds the workspace in this window (other than the picker's own
648 /// workspace) that has `worktree_path` open as a visible worktree.
649 fn workspace_for_open_worktree(
650 &self,
651 worktree_path: &Path,
652 window: &Window,
653 cx: &App,
654 ) -> Option<Entity<Workspace>> {
655 if self.active_worktree_paths.contains(worktree_path) {
656 return None;
657 }
658 let multi_workspace = window.root::<MultiWorkspace>().flatten()?;
659 let workspace = self.workspace.upgrade()?;
660 let group_key = workspace.read(cx).project_group_key(cx);
661 multi_workspace
662 .read(cx)
663 .workspaces_for_project_group(&group_key, cx)?
664 .into_iter()
665 .find(|group_workspace| {
666 *group_workspace != workspace
667 && group_workspace
668 .read(cx)
669 .project()
670 .read(cx)
671 .visible_worktrees(cx)
672 .any(|worktree| worktree.read(cx).abs_path().as_ref() == worktree_path)
673 })
674 }
675
676 fn remove_worktree_from_window(
677 &mut self,
678 worktree_path: &Path,
679 window: &mut Window,
680 cx: &mut Context<Picker<Self>>,
681 ) {
682 if self.deleting_worktree_paths.contains(worktree_path) {
683 return;
684 }
685 let Some(workspace_to_remove) = self.workspace_for_open_worktree(worktree_path, window, cx)
686 else {
687 return;
688 };
689 let Some(window_handle) = window.window_handle().downcast::<MultiWorkspace>() else {
690 return;
691 };
692
693 cx.spawn_in(window, async move |picker, cx| {
694 let removed = window_handle
695 .update(cx, |multi_workspace, window, cx| {
696 multi_workspace.close_workspace(&workspace_to_remove, window, cx)
697 })?
698 .await?;
699
700 if removed {
701 picker.update_in(cx, |picker, window, cx| {
702 picker.delegate.refresh_project_worktree_paths(window, cx);
703 picker.refresh(window, cx);
704 })?;
705 }
706
707 anyhow::Ok(())
708 })
709 .detach_and_log_err(cx);
710 }
711
712 fn sync_selected_index(&mut self, has_query: bool) {
713 if !has_query {
714 return;
715 }
716
717 if let Some(index) = self
718 .matches
719 .iter()
720 .position(|entry| matches!(entry, WorktreeEntry::Worktree { .. }))
721 {
722 self.selected_index = index;
723 } else if let Some(index) = self
724 .matches
725 .iter()
726 .position(|entry| matches!(entry, WorktreeEntry::CreateNamed { .. }))
727 {
728 self.selected_index = index;
729 } else {
730 self.selected_index = 0;
731 }
732 }
733}
734
735impl PickerDelegate for WorktreePickerDelegate {
736 type ListItem = AnyElement;
737
738 fn name() -> &'static str {
739 "worktree picker"
740 }
741
742 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
743 "Select or type to create a worktree…".into()
744 }
745
746 fn editor_position(&self) -> PickerEditorPosition {
747 PickerEditorPosition::Start
748 }
749
750 fn match_count(&self) -> usize {
751 self.matches.len()
752 }
753
754 fn selected_index(&self) -> usize {
755 self.selected_index
756 }
757
758 fn set_selected_index(
759 &mut self,
760 ix: usize,
761 _window: &mut Window,
762 _cx: &mut Context<Picker<Self>>,
763 ) {
764 self.selected_index = ix;
765 }
766
767 fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
768 !matches!(
769 self.matches.get(ix),
770 Some(WorktreeEntry::Separator | WorktreeEntry::SectionHeader(_))
771 )
772 }
773
774 fn update_matches(
775 &mut self,
776 query: String,
777 window: &mut Window,
778 cx: &mut Context<Picker<Self>>,
779 ) -> Task<()> {
780 let repo_worktrees = self.all_repo_worktrees().to_vec();
781
782 let normalized_query = query.replace(' ', "-");
783 let main_worktree_path = self
784 .all_worktrees
785 .iter()
786 .find(|wt| wt.is_main)
787 .map(|wt| wt.path.clone());
788 let has_named_worktree = self.all_worktrees.iter().any(|worktree| {
789 worktree.directory_name(main_worktree_path.as_deref()) == normalized_query
790 });
791 let create_named_disabled_reason: Option<String> = if self.has_multiple_repositories {
792 Some("Cannot create a named worktree in a project with multiple repositories".into())
793 } else if has_named_worktree {
794 Some("A worktree with this name already exists".into())
795 } else {
796 None
797 };
798
799 let show_default_branch_create =
800 !self.has_multiple_repositories && self.default_branch.is_some();
801 let default_branch = self.default_branch.clone();
802
803 if query.is_empty() {
804 let mut matches = self.build_fixed_entries();
805
806 if !repo_worktrees.is_empty() {
807 let main_worktree_path = repo_worktrees
808 .iter()
809 .find(|wt| wt.is_main)
810 .map(|wt| wt.path.clone());
811
812 let project_paths = &self.project_worktree_paths;
813
814 let sort_by_name = |a: &GitWorktree, b: &GitWorktree| {
815 a.directory_name(main_worktree_path.as_deref())
816 .cmp(&b.directory_name(main_worktree_path.as_deref()))
817 };
818
819 let (mut open_here, mut others): (Vec<_>, Vec<_>) = repo_worktrees
820 .into_iter()
821 .partition(|worktree| project_paths.contains(&worktree.path));
822 open_here.sort_by(sort_by_name);
823 others.sort_by(sort_by_name);
824
825 matches.push(WorktreeEntry::Separator);
826
827 if open_here.len() > 1 {
828 matches.push(WorktreeEntry::SectionHeader("This Window".into()));
829 for worktree in open_here {
830 matches.push(WorktreeEntry::Worktree {
831 worktree,
832 positions: Vec::new(),
833 });
834 }
835
836 if !others.is_empty() {
837 matches.push(WorktreeEntry::Separator);
838 }
839
840 for worktree in others {
841 matches.push(WorktreeEntry::Worktree {
842 worktree,
843 positions: Vec::new(),
844 });
845 }
846 } else {
847 for worktree in open_here.into_iter().chain(others) {
848 matches.push(WorktreeEntry::Worktree {
849 worktree,
850 positions: Vec::new(),
851 });
852 }
853 }
854 }
855
856 self.matches = matches;
857 self.sync_selected_index(false);
858 return Task::ready(());
859 }
860
861 let main_worktree_path = repo_worktrees
862 .iter()
863 .find(|wt| wt.is_main)
864 .map(|wt| wt.path.clone());
865 let candidates: Vec<_> = repo_worktrees
866 .iter()
867 .enumerate()
868 .map(|(ix, worktree)| {
869 StringMatchCandidate::new(
870 ix,
871 &worktree.directory_name(main_worktree_path.as_deref()),
872 )
873 })
874 .collect();
875
876 let executor = cx.background_executor().clone();
877
878 let task = cx.background_executor().spawn(async move {
879 fuzzy::match_strings(
880 &candidates,
881 &query,
882 true,
883 true,
884 10000,
885 &Default::default(),
886 executor,
887 )
888 .await
889 });
890
891 let repo_worktrees_clone = repo_worktrees;
892 cx.spawn_in(window, async move |picker, cx| {
893 let fuzzy_matches = task.await;
894
895 picker
896 .update_in(cx, |picker, _window, cx| {
897 let mut new_matches: Vec<WorktreeEntry> = Vec::new();
898
899 for candidate in &fuzzy_matches {
900 new_matches.push(WorktreeEntry::Worktree {
901 worktree: repo_worktrees_clone[candidate.candidate_id].clone(),
902 positions: candidate.positions.clone(),
903 });
904 }
905
906 if !new_matches.is_empty() {
907 new_matches.push(WorktreeEntry::Separator);
908 }
909 if show_default_branch_create {
910 if let Some(ref default_branch) = default_branch {
911 new_matches.push(WorktreeEntry::CreateNamed {
912 name: normalized_query.clone(),
913 from_branch: Some(default_branch.clone()),
914 disabled_reason: create_named_disabled_reason.clone(),
915 });
916 }
917 } else {
918 new_matches.push(WorktreeEntry::CreateNamed {
919 name: normalized_query.clone(),
920 from_branch: None,
921 disabled_reason: create_named_disabled_reason.clone(),
922 });
923 }
924
925 picker.delegate.matches = new_matches;
926 picker.delegate.sync_selected_index(true);
927
928 cx.notify();
929 })
930 .log_err();
931 })
932 }
933
934 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
935 let Some(entry) = self.matches.get(self.selected_index) else {
936 return;
937 };
938
939 match entry {
940 WorktreeEntry::Separator | WorktreeEntry::SectionHeader(_) => return,
941 WorktreeEntry::CreateFromCurrentBranch => {
942 if self.creation_blocked_reason(cx).is_some() {
943 return;
944 }
945 if let Some(workspace) = self.workspace.upgrade() {
946 workspace.update(cx, |workspace, cx| {
947 crate::worktree_service::handle_create_worktree(
948 workspace,
949 &CreateWorktree {
950 worktree_name: None,
951 branch_target: NewWorktreeBranchTarget::CurrentBranch,
952 },
953 window,
954 self.focused_dock,
955 cx,
956 );
957 });
958 }
959 }
960 WorktreeEntry::CreateFromDefaultBranch { default_branch } => {
961 if self.creation_blocked_reason(cx).is_some() {
962 return;
963 }
964 if let Some(workspace) = self.workspace.upgrade() {
965 workspace.update(cx, |workspace, cx| {
966 crate::worktree_service::handle_create_worktree(
967 workspace,
968 &CreateWorktree {
969 worktree_name: None,
970 branch_target: NewWorktreeBranchTarget::RemoteBranch {
971 remote_name: default_branch.remote_name.clone(),
972 branch_name: default_branch.branch_name.clone(),
973 },
974 },
975 window,
976 self.focused_dock,
977 cx,
978 );
979 });
980 }
981 }
982 WorktreeEntry::Worktree { worktree, .. } => {
983 if self.deleting_worktree_paths.contains(&worktree.path) {
984 return;
985 }
986
987 let is_current = self.active_worktree_paths.contains(&worktree.path);
988
989 if !is_current {
990 if secondary {
991 window.dispatch_action(
992 Box::new(OpenWorktreeInNewWindow {
993 path: worktree.path.clone(),
994 }),
995 cx,
996 );
997 } else {
998 let main_worktree_path = self
999 .all_worktrees
1000 .iter()
1001 .find(|wt| wt.is_main)
1002 .map(|wt| wt.path.as_path());
1003 if let Some(workspace) = self.workspace.upgrade() {
1004 workspace.update(cx, |workspace, cx| {
1005 crate::worktree_service::handle_switch_worktree(
1006 workspace,
1007 &SwitchWorktree {
1008 path: worktree.path.clone(),
1009 display_name: worktree.directory_name(main_worktree_path),
1010 },
1011 window,
1012 self.focused_dock,
1013 cx,
1014 );
1015 });
1016 }
1017 }
1018 }
1019 }
1020 WorktreeEntry::CreateNamed {
1021 name,
1022 from_branch,
1023 disabled_reason: None,
1024 } => {
1025 let branch_target = match from_branch {
1026 Some(branch) => NewWorktreeBranchTarget::RemoteBranch {
1027 remote_name: branch.remote_name.clone(),
1028 branch_name: branch.branch_name.clone(),
1029 },
1030 None => NewWorktreeBranchTarget::CurrentBranch,
1031 };
1032 if let Some(workspace) = self.workspace.upgrade() {
1033 workspace.update(cx, |workspace, cx| {
1034 crate::worktree_service::handle_create_worktree(
1035 workspace,
1036 &CreateWorktree {
1037 worktree_name: Some(name.clone()),
1038 branch_target,
1039 },
1040 window,
1041 self.focused_dock,
1042 cx,
1043 );
1044 });
1045 }
1046 }
1047 WorktreeEntry::CreateNamed {
1048 disabled_reason: Some(_),
1049 ..
1050 } => {
1051 return;
1052 }
1053 }
1054
1055 cx.emit(DismissEvent);
1056 }
1057
1058 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
1059
1060 fn render_match(
1061 &self,
1062 ix: usize,
1063 selected: bool,
1064 _window: &mut Window,
1065 cx: &mut Context<Picker<Self>>,
1066 ) -> Option<Self::ListItem> {
1067 let entry = self.matches.get(ix)?;
1068
1069 match entry {
1070 WorktreeEntry::Separator => Some(
1071 div()
1072 .py(DynamicSpacing::Base04.rems(cx))
1073 .child(Divider::horizontal())
1074 .into_any_element(),
1075 ),
1076 WorktreeEntry::SectionHeader(label) => Some(
1077 ListSubHeader::new(label.clone())
1078 .inset(true)
1079 .into_any_element(),
1080 ),
1081 WorktreeEntry::CreateFromCurrentBranch => {
1082 let branch_label = WorktreeCreateTarget::CurrentBranch.branch_label(
1083 self.has_multiple_repositories,
1084 self.current_branch_name.as_deref(),
1085 );
1086
1087 let label = format!("Create new worktree based on {branch_label}");
1088
1089 let item = create_new_list_item(
1090 "create-from-current".to_string().into(),
1091 label.into(),
1092 self.creation_blocked_reason(cx),
1093 selected,
1094 );
1095
1096 Some(item.into_any_element())
1097 }
1098 WorktreeEntry::CreateFromDefaultBranch { default_branch } => {
1099 let branch_label = WorktreeCreateTarget::DefaultBranch(default_branch.clone())
1100 .branch_label(
1101 self.has_multiple_repositories,
1102 self.current_branch_name.as_deref(),
1103 );
1104 let label = format!("Create new worktree based on {branch_label}");
1105
1106 let item = create_new_list_item(
1107 "create-from-main".to_string().into(),
1108 label.into(),
1109 self.creation_blocked_reason(cx),
1110 selected,
1111 );
1112
1113 Some(item.into_any_element())
1114 }
1115 WorktreeEntry::Worktree {
1116 worktree,
1117 positions,
1118 } => {
1119 let main_worktree_path = self
1120 .all_worktrees
1121 .iter()
1122 .find(|wt| wt.is_main)
1123 .map(|wt| wt.path.as_path());
1124 let display_name = worktree.directory_name(main_worktree_path);
1125 let first_line = display_name.lines().next().unwrap_or(&display_name);
1126 let positions: Vec<_> = positions
1127 .iter()
1128 .copied()
1129 .filter(|&pos| pos < first_line.len())
1130 .collect();
1131 let path = worktree.path.compact().to_string_lossy().to_string();
1132 let sha = worktree.sha.chars().take(7).collect::<String>();
1133
1134 let is_current = self.active_worktree_paths.contains(&worktree.path);
1135 let is_deleting = self.deleting_worktree_paths.contains(&worktree.path);
1136 let can_delete = self.can_delete_worktree(worktree);
1137 let can_remove_from_window =
1138 !is_current && self.project_worktree_paths.contains(&worktree.path);
1139
1140 let entry_icon = if is_current {
1141 IconName::Check
1142 } else {
1143 IconName::GitWorktree
1144 };
1145 let picker = cx.entity();
1146
1147 Some(
1148 ListItem::new(SharedString::from(format!("worktree-{ix}")))
1149 .inset(true)
1150 .spacing(ListItemSpacing::Sparse)
1151 .toggle_state(selected)
1152 .child(
1153 h_flex()
1154 .w_full()
1155 .gap_2p5()
1156 .child(
1157 Icon::new(entry_icon)
1158 .color(if is_current {
1159 Color::Accent
1160 } else {
1161 Color::Muted
1162 })
1163 .size(IconSize::Small),
1164 )
1165 .child(
1166 v_flex()
1167 .w_full()
1168 .min_w_0()
1169 .child(
1170 HighlightedLabel::new(first_line.to_owned(), positions)
1171 .truncate(),
1172 )
1173 .child(
1174 h_flex()
1175 .w_full()
1176 .min_w_0()
1177 .gap_1p5()
1178 .when_some(
1179 worktree.branch_name().map(|b| b.to_string()),
1180 |this, branch| {
1181 this.child(
1182 Label::new(branch)
1183 .size(LabelSize::Small)
1184 .color(Color::Muted),
1185 )
1186 .child(
1187 Label::new("\u{2022}")
1188 .alpha(0.5)
1189 .color(Color::Muted)
1190 .size(LabelSize::Small),
1191 )
1192 },
1193 )
1194 .when(!sha.is_empty(), |this| {
1195 this.child(
1196 Label::new(sha)
1197 .size(LabelSize::Small)
1198 .color(Color::Muted),
1199 )
1200 .child(
1201 Label::new("\u{2022}")
1202 .alpha(0.5)
1203 .color(Color::Muted)
1204 .size(LabelSize::Small),
1205 )
1206 })
1207 .child(
1208 Label::new(path)
1209 .truncate_start()
1210 .color(Color::Muted)
1211 .size(LabelSize::Small)
1212 .flex_1(),
1213 ),
1214 ),
1215 ),
1216 )
1217 .when(is_deleting, |this| {
1218 this.end_slot(
1219 h_flex()
1220 .gap_1()
1221 .child(
1222 Icon::new(IconName::LoadCircle)
1223 .size(IconSize::Small)
1224 .color(Color::Muted)
1225 .with_rotate_animation(2),
1226 )
1227 .child(
1228 Label::new("Deleting…")
1229 .size(LabelSize::Small)
1230 .color(Color::Muted),
1231 ),
1232 )
1233 })
1234 .when(!is_deleting && !is_current, |this| {
1235 let open_in_new_window_button =
1236 IconButton::new(("open-new-window", ix), IconName::ArrowUpRight)
1237 .icon_size(IconSize::Small)
1238 .tooltip(Tooltip::text("Open in New Window"))
1239 .on_click(cx.listener(move |picker, _, window, cx| {
1240 let Some(entry) = picker.delegate.matches.get(ix) else {
1241 return;
1242 };
1243 if let WorktreeEntry::Worktree { worktree, .. } = entry {
1244 if picker
1245 .delegate
1246 .deleting_worktree_paths
1247 .contains(&worktree.path)
1248 {
1249 return;
1250 }
1251 window.dispatch_action(
1252 Box::new(OpenWorktreeInNewWindow {
1253 path: worktree.path.clone(),
1254 }),
1255 cx,
1256 );
1257 cx.emit(DismissEvent);
1258 }
1259 }));
1260
1261 let focus_handle_delete = self.focus_handle.clone();
1262 let force_delete = self.is_force_delete_hovering_index(ix);
1263 let delete_button = div()
1264 .id(("delete-worktree-hover", ix))
1265 .on_hover(cx.listener(move |picker, hovered: &bool, _, cx| {
1266 if *hovered {
1267 picker.delegate.hovered_delete_index = Some(ix);
1268 } else if picker.delegate.hovered_delete_index == Some(ix) {
1269 picker.delegate.hovered_delete_index = None;
1270 }
1271 cx.notify();
1272 }))
1273 .child(
1274 IconButton::new(("delete-worktree", ix), IconName::Trash)
1275 .icon_size(IconSize::Small)
1276 .when(force_delete, |this| this.icon_color(Color::Error))
1277 .tooltip(move |_, cx| {
1278 cx.new(|cx| {
1279 DeleteWorktreeTooltip::new(
1280 picker.clone(),
1281 focus_handle_delete.clone(),
1282 ix,
1283 cx,
1284 )
1285 })
1286 .into()
1287 })
1288 .on_click(cx.listener(move |picker, _, window, cx| {
1289 let force = picker.delegate.modifiers.alt;
1290 picker.delegate.delete_worktree(ix, force, window, cx);
1291 })),
1292 );
1293
1294 this.end_slot(
1295 h_flex()
1296 .gap_0p5()
1297 .child(open_in_new_window_button)
1298 .when(can_remove_from_window, |this| {
1299 let worktree_path = worktree.path.clone();
1300 this.child(
1301 IconButton::new(
1302 ("remove-worktree-from-window", ix),
1303 IconName::Close,
1304 )
1305 .icon_size(IconSize::Small)
1306 .tooltip(Tooltip::text("Remove Worktree from Window"))
1307 .on_click(
1308 cx.listener(move |picker, _, window, cx| {
1309 picker.delegate.remove_worktree_from_window(
1310 &worktree_path,
1311 window,
1312 cx,
1313 );
1314 }),
1315 ),
1316 )
1317 })
1318 .when(can_delete, |this| this.child(delete_button)),
1319 )
1320 .show_end_slot_on_hover()
1321 })
1322 .into_any_element(),
1323 )
1324 }
1325 WorktreeEntry::CreateNamed {
1326 name,
1327 from_branch,
1328 disabled_reason,
1329 } => {
1330 let branch_label = from_branch
1331 .as_ref()
1332 .map(RemoteBranchName::display_name)
1333 .unwrap_or_else(|| {
1334 self.current_branch_name
1335 .clone()
1336 .unwrap_or_else(|| "HEAD".to_string())
1337 });
1338 let label = format!("Create \"{name}\" based on {branch_label}");
1339 let element_id = match from_branch {
1340 Some(branch) => format!("create-named-from-{}", branch.display_name()),
1341 None => "create-named-from-current".to_string(),
1342 };
1343
1344 let item = create_new_list_item(
1345 element_id.into(),
1346 label.into(),
1347 disabled_reason.clone().map(SharedString::from),
1348 selected,
1349 );
1350
1351 Some(item.into_any_element())
1352 }
1353 }
1354 }
1355
1356 fn searchbar_trailer(
1357 &self,
1358 _window: &mut Window,
1359 _cx: &mut Context<Picker<Self>>,
1360 ) -> Option<AnyElement> {
1361 if self.show_footer {
1362 return None;
1363 }
1364
1365 let focus_handle = self.focus_handle.clone();
1366
1367 Some(
1368 IconButton::new("configure-worktree-tasks", IconName::Settings)
1369 .icon_size(IconSize::Small)
1370 .tooltip(move |_window, cx| {
1371 Tooltip::for_action_in(
1372 "Automate Worktree Setup",
1373 &OpenWorktreeSetupTasks,
1374 &focus_handle,
1375 cx,
1376 )
1377 })
1378 .on_click(|_, window, cx| {
1379 window.dispatch_action(OpenWorktreeSetupTasks.boxed_clone(), cx)
1380 })
1381 .into_any_element(),
1382 )
1383 }
1384
1385 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1386 if !self.show_footer {
1387 return None;
1388 }
1389
1390 let focus_handle = self.focus_handle.clone();
1391 let selected_entry = self.matches.get(self.selected_index);
1392
1393 let is_creating = selected_entry.is_some_and(|e| {
1394 matches!(
1395 e,
1396 WorktreeEntry::CreateFromCurrentBranch
1397 | WorktreeEntry::CreateFromDefaultBranch { .. }
1398 | WorktreeEntry::CreateNamed { .. }
1399 )
1400 });
1401
1402 let is_existing_worktree =
1403 selected_entry.is_some_and(|e| matches!(e, WorktreeEntry::Worktree { .. }));
1404
1405 let can_delete = selected_entry.is_some_and(|e| {
1406 matches!(e, WorktreeEntry::Worktree { worktree, .. } if self.can_delete_worktree(worktree))
1407 });
1408
1409 let is_current = selected_entry.is_some_and(|e| {
1410 matches!(e, WorktreeEntry::Worktree { worktree, .. } if self.project_worktree_paths.contains(&worktree.path))
1411 });
1412
1413 let is_deleting = selected_entry.is_some_and(|e| {
1414 matches!(e, WorktreeEntry::Worktree { worktree, .. } if self.deleting_worktree_paths.contains(&worktree.path))
1415 });
1416
1417 let footer = h_flex()
1418 .w_full()
1419 .p_1p5()
1420 .gap_0p5()
1421 .justify_between()
1422 .border_t_1()
1423 .border_color(cx.theme().colors().border_variant)
1424 .child(
1425 Button::new("configure-worktree-tasks", "Automate Setup")
1426 .key_binding(
1427 KeyBinding::for_action_in(&OpenWorktreeSetupTasks, &focus_handle, cx)
1428 .map(|kb| kb.size(rems_from_px(12.))),
1429 )
1430 .on_click(|_, window, cx| {
1431 window.dispatch_action(OpenWorktreeSetupTasks.boxed_clone(), cx)
1432 }),
1433 );
1434
1435 if is_creating {
1436 Some(
1437 footer
1438 .child(
1439 Button::new("create-worktree", "Create")
1440 .key_binding(
1441 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1442 .map(|kb| kb.size(rems_from_px(12.))),
1443 )
1444 .on_click(|_, window, cx| {
1445 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1446 }),
1447 )
1448 .into_any(),
1449 )
1450 } else if is_existing_worktree {
1451 Some(
1452 footer
1453 .child(
1454 h_flex()
1455 .gap_0p5()
1456 .when(is_deleting, |this| {
1457 this.child(
1458 Button::new("delete-worktree", "Deleting…")
1459 .loading(true)
1460 .disabled(true),
1461 )
1462 })
1463 .when(!is_deleting && can_delete, |this| {
1464 let focus_handle = focus_handle.clone();
1465 this.child(
1466 Button::new("delete-worktree", "Delete")
1467 .key_binding(
1468 KeyBinding::for_action_in(
1469 &DeleteWorktree,
1470 &focus_handle,
1471 cx,
1472 )
1473 .map(|kb| kb.size(rems_from_px(12.))),
1474 )
1475 .on_click(|_, window, cx| {
1476 window.dispatch_action(DeleteWorktree.boxed_clone(), cx)
1477 }),
1478 )
1479 })
1480 .when(!is_deleting && !is_current, |this| {
1481 let focus_handle = focus_handle.clone();
1482 this.child(
1483 Button::new("open-in-new-window", "Open in New Window")
1484 .key_binding(
1485 KeyBinding::for_action_in(
1486 &menu::SecondaryConfirm,
1487 &focus_handle,
1488 cx,
1489 )
1490 .map(|kb| kb.size(rems_from_px(12.))),
1491 )
1492 .on_click(|_, window, cx| {
1493 window.dispatch_action(
1494 menu::SecondaryConfirm.boxed_clone(),
1495 cx,
1496 )
1497 }),
1498 )
1499 })
1500 .when(!is_deleting, |this| {
1501 this.child(
1502 Button::new("open-worktree", "Open")
1503 .key_binding(
1504 KeyBinding::for_action_in(
1505 &menu::Confirm,
1506 &focus_handle,
1507 cx,
1508 )
1509 .map(|kb| kb.size(rems_from_px(12.))),
1510 )
1511 .on_click(|_, window, cx| {
1512 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1513 }),
1514 )
1515 }),
1516 )
1517 .into_any(),
1518 )
1519 } else {
1520 None
1521 }
1522 }
1523}
1524
1525fn create_new_list_item(
1526 id: SharedString,
1527 label: SharedString,
1528 disabled_tooltip: Option<SharedString>,
1529 selected: bool,
1530) -> AnyElement {
1531 let is_disabled = disabled_tooltip.is_some();
1532
1533 ListItem::new(id)
1534 .inset(true)
1535 .spacing(ListItemSpacing::Sparse)
1536 .toggle_state(selected)
1537 .child(
1538 h_flex()
1539 .w_full()
1540 .gap_2p5()
1541 .child(
1542 Icon::new(IconName::Plus)
1543 .map(|this| {
1544 if is_disabled {
1545 this.color(Color::Disabled)
1546 } else {
1547 this.color(Color::Muted)
1548 }
1549 })
1550 .size(IconSize::Small),
1551 )
1552 .child(Label::new(label).when(is_disabled, |this| this.color(Color::Disabled))),
1553 )
1554 .when_some(disabled_tooltip, |this, reason| {
1555 this.tooltip(Tooltip::text(reason))
1556 })
1557 .into_any_element()
1558}
1559
1560pub async fn open_remote_worktree(
1561 connection_options: remote::RemoteConnectionOptions,
1562 paths: Vec<PathBuf>,
1563 app_state: Arc<workspace::AppState>,
1564 workspace: gpui::WeakEntity<Workspace>,
1565 cx: &mut gpui::AsyncWindowContext,
1566) -> anyhow::Result<()> {
1567 let connect_task = workspace.update_in(cx, |workspace, window, cx| {
1568 workspace.toggle_modal(window, cx, |window, cx| {
1569 remote_connection::RemoteConnectionModal::new(
1570 &connection_options,
1571 Vec::new(),
1572 window,
1573 cx,
1574 )
1575 });
1576
1577 let prompt = workspace
1578 .active_modal::<remote_connection::RemoteConnectionModal>(cx)
1579 .expect("Modal just created")
1580 .read(cx)
1581 .prompt
1582 .clone();
1583
1584 remote_connection::connect(
1585 remote::remote_client::ConnectionIdentifier::setup(),
1586 connection_options.clone(),
1587 prompt,
1588 window,
1589 cx,
1590 )
1591 .prompt_err("Failed to connect", window, cx, |_, _, _| None)
1592 })?;
1593
1594 let session = connect_task.await;
1595
1596 workspace
1597 .update_in(cx, |workspace, _window, cx| {
1598 if let Some(prompt) =
1599 workspace.active_modal::<remote_connection::RemoteConnectionModal>(cx)
1600 {
1601 prompt.update(cx, |prompt, cx| prompt.finished(cx))
1602 }
1603 })
1604 .ok();
1605
1606 let Some(Some(session)) = session else {
1607 return Ok(());
1608 };
1609
1610 let new_project = cx.update(|_, cx| {
1611 project::Project::remote(
1612 session,
1613 app_state.client.clone(),
1614 app_state.node_runtime.clone(),
1615 app_state.user_store.clone(),
1616 app_state.languages.clone(),
1617 app_state.fs.clone(),
1618 true,
1619 cx,
1620 )
1621 })?;
1622
1623 let workspace_position = cx
1624 .update(|_, cx| {
1625 workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
1626 })?
1627 .await
1628 .context("fetching workspace position from db")?;
1629
1630 let mut options =
1631 cx.update(|_, cx| (app_state.build_window_options)(workspace_position.display, cx))?;
1632 options.window_bounds = workspace_position.window_bounds;
1633
1634 let new_window = cx.open_window(options, |window, cx| {
1635 let workspace = cx.new(|cx| {
1636 let mut workspace =
1637 Workspace::new(None, new_project.clone(), app_state.clone(), window, cx);
1638 workspace.centered_layout = workspace_position.centered_layout;
1639 workspace
1640 });
1641 cx.new(|cx| MultiWorkspace::new(workspace, window, cx))
1642 })?;
1643
1644 workspace::open_remote_project_with_existing_connection(
1645 connection_options,
1646 new_project,
1647 paths,
1648 app_state,
1649 new_window,
1650 None,
1651 None,
1652 cx,
1653 )
1654 .await?;
1655
1656 Ok(())
1657}
1658
1659#[cfg(test)]
1660mod tests {
1661 use super::*;
1662 use fs::FakeFs;
1663 use gpui::{AppContext, TestAppContext, VisualTestContext};
1664 use project::project_settings::ProjectSettings;
1665 use project::{Project, WorktreeSettings};
1666 use serde_json::json;
1667 use settings::Settings as _;
1668 use settings::SettingsStore;
1669 use util::path;
1670 use workspace::MultiWorkspace;
1671
1672 fn init_test(cx: &mut TestAppContext) {
1673 cx.update(|cx| {
1674 let settings_store = SettingsStore::test(cx);
1675 cx.set_global(settings_store);
1676 theme_settings::init(theme::LoadThemes::JustBase, cx);
1677 editor::init(cx);
1678 ProjectSettings::register(cx);
1679 WorktreeSettings::register(cx);
1680 });
1681 }
1682
1683 async fn init_worktree_picker_test(
1684 cx: &mut TestAppContext,
1685 ) -> (
1686 Arc<FakeFs>,
1687 Entity<WorktreePicker>,
1688 Entity<project::git_store::Repository>,
1689 PathBuf,
1690 VisualTestContext,
1691 ) {
1692 init_test(cx);
1693
1694 let fs = FakeFs::new(cx.executor());
1695 fs.insert_tree(
1696 path!("/root"),
1697 json!({
1698 "project": {
1699 ".git": {},
1700 "file.txt": "buffer_text",
1701 },
1702 "worktrees": {},
1703 }),
1704 )
1705 .await;
1706 fs.set_head_for_repo(
1707 path!("/root/project/.git").as_ref(),
1708 &[("file.txt", "buffer_text".to_string())],
1709 "deadbeef",
1710 );
1711
1712 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1713 cx.executor().run_until_parked();
1714
1715 let repository = project.read_with(cx, |project, cx| {
1716 project.repositories(cx).values().next().unwrap().clone()
1717 });
1718 let worktree_path = PathBuf::from(path!("/root/worktrees/dirty-wt"));
1719
1720 cx.update(|cx| {
1721 repository.update(cx, |repository, _| {
1722 repository.create_worktree(
1723 git::repository::CreateWorktreeTarget::NewBranch {
1724 branch_name: "dirty-wt".to_string(),
1725 base_sha: Some("deadbeef".to_string()),
1726 },
1727 worktree_path.clone(),
1728 )
1729 })
1730 })
1731 .await
1732 .unwrap()
1733 .unwrap();
1734
1735 let window_handle =
1736 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1737 let workspace = window_handle
1738 .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
1739 .unwrap();
1740
1741 let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
1742
1743 let worktree_picker = cx.update(|window, cx| {
1744 cx.new(|cx| WorktreePicker::new(project, workspace.downgrade(), window, cx))
1745 });
1746
1747 cx.run_until_parked();
1748
1749 (fs, worktree_picker, repository, worktree_path, cx)
1750 }
1751
1752 fn worktree_index(
1753 worktree_picker: &Entity<WorktreePicker>,
1754 worktree_path: &Path,
1755 cx: &mut VisualTestContext,
1756 ) -> usize {
1757 worktree_picker.update(cx, |worktree_picker, cx| {
1758 worktree_picker.picker.update(cx, |picker, _| {
1759 picker
1760 .delegate
1761 .matches
1762 .iter()
1763 .position(|entry| {
1764 matches!(entry, WorktreeEntry::Worktree { worktree, .. } if worktree.path == *worktree_path)
1765 })
1766 .expect("worktree should appear in picker")
1767 })
1768 })
1769 }
1770
1771 fn picker_contains_worktree(
1772 worktree_picker: &Entity<WorktreePicker>,
1773 worktree_path: &Path,
1774 cx: &mut VisualTestContext,
1775 ) -> bool {
1776 worktree_picker.update(cx, |worktree_picker, cx| {
1777 worktree_picker.picker.update(cx, |picker, _| {
1778 picker.delegate.all_worktrees.iter().any(|worktree| {
1779 worktree.path == *worktree_path
1780 }) && picker.delegate.matches.iter().any(|entry| {
1781 matches!(entry, WorktreeEntry::Worktree { worktree, .. } if worktree.path == *worktree_path)
1782 })
1783 })
1784 })
1785 }
1786
1787 fn deleting_worktree_paths(
1788 worktree_picker: &Entity<WorktreePicker>,
1789 cx: &mut VisualTestContext,
1790 ) -> HashSet<PathBuf> {
1791 worktree_picker.update(cx, |worktree_picker, cx| {
1792 worktree_picker.picker.update(cx, |picker, _| {
1793 picker.delegate.deleting_worktree_paths.clone()
1794 })
1795 })
1796 }
1797
1798 async fn repo_contains_worktree(
1799 repository: &Entity<project::git_store::Repository>,
1800 worktree_path: &Path,
1801 cx: &mut VisualTestContext,
1802 ) -> bool {
1803 let worktrees = repository
1804 .update(cx, |repository, _| repository.worktrees())
1805 .await
1806 .unwrap()
1807 .unwrap();
1808 worktrees
1809 .iter()
1810 .any(|worktree| worktree.path == *worktree_path)
1811 }
1812
1813 #[gpui::test]
1814 async fn test_delete_worktree_marks_row_pending_immediately(cx: &mut TestAppContext) {
1815 let (_, worktree_picker, _repository, worktree_path, mut cx) =
1816 init_worktree_picker_test(cx).await;
1817
1818 let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
1819 worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
1820 worktree_picker.picker.update(cx, |picker, cx| {
1821 picker.delegate.delete_worktree(index, false, window, cx);
1822 })
1823 });
1824
1825 let pending_paths = deleting_worktree_paths(&worktree_picker, &mut cx);
1826 assert_eq!(pending_paths.len(), 1);
1827 assert!(pending_paths.contains(&worktree_path));
1828
1829 cx.run_until_parked();
1830 }
1831
1832 #[gpui::test]
1833 async fn test_delete_worktree_clears_pending_and_removes_row_on_success(
1834 cx: &mut TestAppContext,
1835 ) {
1836 let (_, worktree_picker, repository, worktree_path, mut cx) =
1837 init_worktree_picker_test(cx).await;
1838
1839 let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
1840 worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
1841 worktree_picker.picker.update(cx, |picker, cx| {
1842 picker.delegate.delete_worktree(index, false, window, cx);
1843 })
1844 });
1845 assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path));
1846
1847 cx.run_until_parked();
1848
1849 assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty());
1850 assert!(!picker_contains_worktree(
1851 &worktree_picker,
1852 &worktree_path,
1853 &mut cx
1854 ));
1855 assert!(
1856 !repo_contains_worktree(&repository, &worktree_path, &mut cx).await,
1857 "worktree should be removed after successful delete"
1858 );
1859 }
1860
1861 #[gpui::test]
1862 async fn test_remote_default_branch_is_preferred_create_target(cx: &mut TestAppContext) {
1863 let (_fs, worktree_picker, _repository, _worktree_path, mut cx) =
1864 init_worktree_picker_test(cx).await;
1865
1866 worktree_picker.update(&mut cx, |worktree_picker, cx| {
1867 worktree_picker.picker.update(cx, |picker, _| {
1868 assert_eq!(picker.delegate.selected_index, 0);
1869 match picker.delegate.matches.first() {
1870 Some(WorktreeEntry::CreateFromDefaultBranch { default_branch }) => {
1871 assert_eq!(default_branch.display_name(), "origin/main");
1872 }
1873 _ => panic!("remote default branch should be the first create target"),
1874 }
1875 })
1876 });
1877
1878 let update_matches = worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
1879 worktree_picker.picker.update(cx, |picker, cx| {
1880 picker
1881 .delegate
1882 .update_matches("feature".to_string(), window, cx)
1883 })
1884 });
1885 update_matches.await;
1886 cx.run_until_parked();
1887
1888 worktree_picker.update(&mut cx, |worktree_picker, cx| {
1889 worktree_picker
1890 .picker
1891 .update(cx, |picker, _| match picker.delegate.matches.first() {
1892 Some(WorktreeEntry::CreateNamed {
1893 from_branch: Some(default_branch),
1894 ..
1895 }) => {
1896 assert_eq!(default_branch.display_name(), "origin/main");
1897 }
1898 _ => panic!("named worktree creation should prefer the remote default branch"),
1899 })
1900 });
1901 }
1902
1903 #[gpui::test]
1904 async fn test_current_branch_create_target_is_shown_without_default_branch(
1905 cx: &mut TestAppContext,
1906 ) {
1907 let (_fs, worktree_picker, _repository, _worktree_path, mut cx) =
1908 init_worktree_picker_test(cx).await;
1909
1910 worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
1911 worktree_picker.picker.update(cx, |picker, cx| {
1912 picker.delegate.default_branch = None;
1913 picker.refresh(window, cx);
1914 });
1915 });
1916 cx.run_until_parked();
1917
1918 worktree_picker.update(&mut cx, |worktree_picker, cx| {
1919 worktree_picker.picker.update(cx, |picker, _| {
1920 assert!(matches!(
1921 picker.delegate.matches.first(),
1922 Some(WorktreeEntry::CreateFromCurrentBranch)
1923 ));
1924 assert!(
1925 !picker.delegate.matches.iter().any(|entry| matches!(
1926 entry,
1927 WorktreeEntry::CreateFromDefaultBranch { .. }
1928 ))
1929 );
1930 });
1931 });
1932 }
1933
1934 #[gpui::test]
1935 async fn test_delete_dirty_worktree_prompts_for_force_delete(cx: &mut TestAppContext) {
1936 let (fs, worktree_picker, repository, worktree_path, mut cx) =
1937 init_worktree_picker_test(cx).await;
1938
1939 fs.with_git_state(path!("/root/project/.git").as_ref(), true, |state| {
1940 state
1941 .worktrees_requiring_force_delete
1942 .insert(worktree_path.clone());
1943 })
1944 .expect("failed to mark test worktree as requiring force delete");
1945
1946 let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
1947 worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
1948 worktree_picker.picker.update(cx, |picker, cx| {
1949 picker.delegate.delete_worktree(index, false, window, cx);
1950 })
1951 });
1952 assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path));
1953
1954 cx.run_until_parked();
1955 assert!(cx.has_pending_prompt());
1956 assert!(
1957 !deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path),
1958 "pending delete state should clear while waiting for force-delete confirmation"
1959 );
1960
1961 cx.simulate_prompt_answer("Force Delete");
1962 cx.run_until_parked();
1963
1964 assert!(!cx.has_pending_prompt());
1965 assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty());
1966 assert!(!picker_contains_worktree(
1967 &worktree_picker,
1968 &worktree_path,
1969 &mut cx
1970 ));
1971 assert!(
1972 !repo_contains_worktree(&repository, &worktree_path, &mut cx).await,
1973 "worktree should be removed after confirming force delete"
1974 );
1975 }
1976
1977 #[gpui::test]
1978 async fn test_duplicate_delete_worktree_is_ignored_while_pending(cx: &mut TestAppContext) {
1979 let (fs, worktree_picker, _repository, worktree_path, mut cx) =
1980 init_worktree_picker_test(cx).await;
1981
1982 fs.with_git_state(path!("/root/project/.git").as_ref(), true, |state| {
1983 state
1984 .worktrees_requiring_force_delete
1985 .insert(worktree_path.clone());
1986 })
1987 .expect("failed to mark test worktree as requiring force delete");
1988
1989 let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
1990 worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
1991 worktree_picker.picker.update(cx, |picker, cx| {
1992 picker.delegate.delete_worktree(index, false, window, cx);
1993 picker.delegate.delete_worktree(index, false, window, cx);
1994 })
1995 });
1996
1997 let pending_paths = deleting_worktree_paths(&worktree_picker, &mut cx);
1998 assert_eq!(pending_paths.len(), 1);
1999 assert!(pending_paths.contains(&worktree_path));
2000
2001 cx.run_until_parked();
2002 assert!(cx.has_pending_prompt());
2003 assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty());
2004
2005 cx.simulate_prompt_answer("Cancel");
2006 cx.run_until_parked();
2007
2008 assert!(!cx.has_pending_prompt());
2009 assert!(picker_contains_worktree(
2010 &worktree_picker,
2011 &worktree_path,
2012 &mut cx
2013 ));
2014 }
2015
2016 #[gpui::test]
2017 async fn test_selected_deleting_worktree_cannot_be_opened(cx: &mut TestAppContext) {
2018 let (_, worktree_picker, _repository, worktree_path, mut cx) =
2019 init_worktree_picker_test(cx).await;
2020
2021 let subscription = cx.update(|_, cx| {
2022 cx.subscribe(&worktree_picker, |_, _: &DismissEvent, _| {
2023 panic!("DismissEvent should not be emitted for a deleting worktree");
2024 })
2025 });
2026
2027 let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
2028 worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
2029 worktree_picker.picker.update(cx, |picker, cx| {
2030 picker.delegate.selected_index = index;
2031 picker.delegate.delete_worktree(index, false, window, cx);
2032 picker.delegate.confirm(false, window, cx);
2033 })
2034 });
2035
2036 assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path));
2037
2038 drop(subscription);
2039 cx.run_until_parked();
2040 }
2041
2042 #[gpui::test]
2043 async fn test_force_delete_worktree_deletes_without_prompt(cx: &mut TestAppContext) {
2044 let (fs, worktree_picker, repository, worktree_path, mut cx) =
2045 init_worktree_picker_test(cx).await;
2046
2047 fs.with_git_state(path!("/root/project/.git").as_ref(), true, |state| {
2048 state
2049 .worktrees_requiring_force_delete
2050 .insert(worktree_path.clone());
2051 })
2052 .expect("failed to mark test worktree as requiring force delete");
2053
2054 let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
2055 worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
2056 worktree_picker.picker.update(cx, |picker, cx| {
2057 picker.delegate.modifiers = Modifiers::alt();
2058 picker.delegate.delete_worktree(index, true, window, cx);
2059 })
2060 });
2061 assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path));
2062
2063 cx.run_until_parked();
2064
2065 assert!(!cx.has_pending_prompt());
2066 assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty());
2067 assert!(!picker_contains_worktree(
2068 &worktree_picker,
2069 &worktree_path,
2070 &mut cx
2071 ));
2072 assert!(
2073 !repo_contains_worktree(&repository, &worktree_path, &mut cx).await,
2074 "worktree should be removed by explicit force delete"
2075 );
2076 }
2077
2078 #[gpui::test]
2079 async fn test_open_worktrees_are_grouped_under_section_header(cx: &mut TestAppContext) {
2080 init_test(cx);
2081
2082 let fs = FakeFs::new(cx.executor());
2083 fs.insert_tree(
2084 path!("/root"),
2085 json!({
2086 "project": {
2087 ".git": {},
2088 "file.txt": "buffer_text",
2089 },
2090 "worktrees": {},
2091 }),
2092 )
2093 .await;
2094 fs.set_head_for_repo(
2095 path!("/root/project/.git").as_ref(),
2096 &[("file.txt", "buffer_text".to_string())],
2097 "deadbeef",
2098 );
2099
2100 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
2101 cx.executor().run_until_parked();
2102
2103 let repository = project.read_with(cx, |project, cx| {
2104 project.repositories(cx).values().next().unwrap().clone()
2105 });
2106 let second_worktree_path = PathBuf::from(path!("/root/worktrees/second-wt"));
2107
2108 cx.update(|cx| {
2109 repository.update(cx, |repository, _| {
2110 repository.create_worktree(
2111 git::repository::CreateWorktreeTarget::NewBranch {
2112 branch_name: "second-wt".to_string(),
2113 base_sha: Some("deadbeef".to_string()),
2114 },
2115 second_worktree_path.clone(),
2116 )
2117 })
2118 })
2119 .await
2120 .unwrap()
2121 .unwrap();
2122
2123 // Open the second worktree as a visible worktree of the active project so
2124 // that two worktrees of the same repo are open in this window.
2125 project
2126 .update(cx, |project, cx| {
2127 project.create_worktree(&second_worktree_path, true, cx)
2128 })
2129 .await
2130 .unwrap();
2131 cx.executor().run_until_parked();
2132
2133 let window_handle =
2134 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2135 let workspace = window_handle
2136 .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
2137 .unwrap();
2138
2139 let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
2140 let worktree_picker = cx.update(|window, cx| {
2141 cx.new(|cx| WorktreePicker::new(project, workspace.downgrade(), window, cx))
2142 });
2143 cx.run_until_parked();
2144
2145 let project_path = PathBuf::from(path!("/root/project"));
2146 worktree_picker.update(&mut cx, |worktree_picker, cx| {
2147 worktree_picker.picker.update(cx, |picker, _| {
2148 let matches = &picker.delegate.matches;
2149
2150 let header_index = matches
2151 .iter()
2152 .position(|entry| {
2153 matches!(entry, WorktreeEntry::SectionHeader(label) if label.as_ref() == "This Window")
2154 })
2155 .expect("section header should be present when multiple worktrees are open");
2156
2157 let grouped_paths: Vec<&Path> = matches[header_index + 1..]
2158 .iter()
2159 .map_while(|entry| match entry {
2160 WorktreeEntry::Worktree { worktree, .. } => Some(worktree.path.as_path()),
2161 _ => None,
2162 })
2163 .collect();
2164
2165 assert!(
2166 grouped_paths.contains(&project_path.as_path()),
2167 "main worktree should be grouped under the header"
2168 );
2169 assert!(
2170 grouped_paths.contains(&second_worktree_path.as_path()),
2171 "second open worktree should be grouped under the header"
2172 );
2173 })
2174 });
2175 }
2176
2177 #[gpui::test]
2178 async fn test_remove_open_worktree_workspace_from_window(cx: &mut TestAppContext) {
2179 init_test(cx);
2180
2181 let fs = FakeFs::new(cx.executor());
2182 fs.insert_tree(
2183 path!("/root"),
2184 json!({
2185 "project": {
2186 ".git": {},
2187 "file.txt": "buffer_text",
2188 },
2189 "worktrees": {},
2190 }),
2191 )
2192 .await;
2193 fs.set_head_for_repo(
2194 path!("/root/project/.git").as_ref(),
2195 &[("file.txt", "buffer_text".to_string())],
2196 "deadbeef",
2197 );
2198
2199 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
2200 cx.executor().run_until_parked();
2201
2202 let repository = project.read_with(cx, |project, cx| {
2203 project.repositories(cx).values().next().unwrap().clone()
2204 });
2205 let worktree_path = PathBuf::from(path!("/root/worktrees/open-wt"));
2206 cx.update(|cx| {
2207 repository.update(cx, |repository, _| {
2208 repository.create_worktree(
2209 git::repository::CreateWorktreeTarget::NewBranch {
2210 branch_name: "open-wt".to_string(),
2211 base_sha: Some("deadbeef".to_string()),
2212 },
2213 worktree_path.clone(),
2214 )
2215 })
2216 })
2217 .await
2218 .unwrap()
2219 .unwrap();
2220
2221 let worktree_project = Project::test(fs.clone(), [worktree_path.as_path()], cx).await;
2222 cx.executor().run_until_parked();
2223
2224 let main_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
2225 let worktree_group_key =
2226 worktree_project.read_with(cx, |project, cx| project.project_group_key(cx));
2227 assert_eq!(
2228 main_group_key, worktree_group_key,
2229 "the worktree workspace should belong to the same project group as the main repo"
2230 );
2231
2232 let window_handle =
2233 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2234 let workspace = window_handle
2235 .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
2236 .unwrap();
2237 let worktree_workspace = window_handle
2238 .update(cx, |multi_workspace, window, cx| {
2239 let worktree_workspace =
2240 cx.new(|cx| Workspace::test_new(worktree_project.clone(), window, cx));
2241 multi_workspace.add(worktree_workspace.clone(), window, cx);
2242 worktree_workspace
2243 })
2244 .unwrap();
2245
2246 let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
2247 let worktree_picker = cx.update(|window, cx| {
2248 cx.new(|cx| WorktreePicker::new(project, workspace.downgrade(), window, cx))
2249 });
2250 cx.run_until_parked();
2251
2252 worktree_picker.update(&mut cx, |worktree_picker, cx| {
2253 worktree_picker.picker.update(cx, |picker, _| {
2254 assert!(
2255 picker
2256 .delegate
2257 .project_worktree_paths
2258 .contains(&worktree_path),
2259 "the worktree should be considered open in this window"
2260 );
2261 })
2262 });
2263
2264 worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
2265 worktree_picker.picker.update(cx, |picker, cx| {
2266 picker
2267 .delegate
2268 .remove_worktree_from_window(&worktree_path, window, cx);
2269 })
2270 });
2271 cx.run_until_parked();
2272
2273 window_handle
2274 .read_with(&cx, |multi_workspace, _| {
2275 assert!(
2276 multi_workspace
2277 .workspaces()
2278 .all(|workspace| *workspace != worktree_workspace),
2279 "the worktree workspace should be removed from the window"
2280 );
2281 })
2282 .unwrap();
2283
2284 worktree_picker.update(&mut cx, |worktree_picker, cx| {
2285 worktree_picker.picker.update(cx, |picker, _| {
2286 assert!(
2287 !picker
2288 .delegate
2289 .project_worktree_paths
2290 .contains(&worktree_path),
2291 "the worktree should no longer be considered open in this window"
2292 );
2293 })
2294 });
2295
2296 assert!(
2297 repo_contains_worktree(&repository, &worktree_path, &mut cx).await,
2298 "removing the worktree from the window should not delete the git worktree"
2299 );
2300 }
2301}
2302