1mod dev_container_suggest;
2pub mod disconnected_overlay;
3mod remote_connections;
4mod remote_servers;
5pub mod sidebar_recent_projects;
6mod ssh_config;
7
8use std::{
9 collections::HashSet,
10 path::{Path, PathBuf},
11 sync::Arc,
12};
13
14use chrono::{DateTime, Utc};
15
16use fs::Fs;
17
18#[cfg(target_os = "windows")]
19mod wsl_picker;
20
21use remote::RemoteConnectionOptions;
22pub use remote_connection::{RemoteConnectionModal, connect};
23pub use remote_connections::{navigate_to_positions, open_remote_project};
24
25use disconnected_overlay::DisconnectedOverlay;
26use fuzzy::{StringMatch, StringMatchCandidate};
27use gpui::{
28 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
29 Subscription, Task, WeakEntity, Window, actions, px,
30};
31
32use picker::{
33 Picker, PickerDelegate,
34 highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
35};
36use project::{Worktree, git_store::Repository};
37pub use remote_connections::RemoteSettings;
38pub use remote_servers::RemoteServerProjects;
39use settings::{Settings, WorktreeId};
40use ui_input::ErasedEditor;
41
42use dev_container::{DevContainerContext, find_devcontainer_configs};
43use ui::{
44 ContextMenu, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, PopoverMenu,
45 PopoverMenuHandle, TintColor, Tooltip, prelude::*,
46};
47use util::{ResultExt, paths::PathExt};
48use workspace::{
49 HistoryManager, ModalView, MultiWorkspace, OpenMode, OpenOptions, OpenVisible, PathList,
50 SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId,
51 notifications::DetachAndPromptErr, with_active_or_new_workspace,
52};
53use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
54
55actions!(
56 recent_projects,
57 [ToggleActionsMenu, RemoveSelected, AddToWorkspace,]
58);
59
60#[derive(Clone, Debug)]
61pub struct RecentProjectEntry {
62 pub name: SharedString,
63 pub full_path: SharedString,
64 pub paths: Vec<PathBuf>,
65 pub workspace_id: WorkspaceId,
66 pub timestamp: DateTime<Utc>,
67}
68
69#[derive(Clone, Debug)]
70struct OpenFolderEntry {
71 worktree_id: WorktreeId,
72 name: SharedString,
73 path: PathBuf,
74 branch: Option<SharedString>,
75 is_active: bool,
76}
77
78#[derive(Clone, Debug)]
79enum ProjectPickerEntry {
80 Header(SharedString),
81 OpenFolder { index: usize, positions: Vec<usize> },
82 OpenProject(StringMatch),
83 RecentProject(StringMatch),
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87enum ProjectPickerStyle {
88 Modal,
89 Popover,
90}
91
92pub async fn get_recent_projects(
93 current_workspace_id: Option<WorkspaceId>,
94 limit: Option<usize>,
95 fs: Arc<dyn fs::Fs>,
96 db: &WorkspaceDb,
97) -> Vec<RecentProjectEntry> {
98 let workspaces = db
99 .recent_workspaces_on_disk(fs.as_ref())
100 .await
101 .unwrap_or_default();
102
103 let entries: Vec<RecentProjectEntry> = workspaces
104 .into_iter()
105 .filter(|(id, _, _, _)| Some(*id) != current_workspace_id)
106 .filter(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local))
107 .map(|(workspace_id, _, path_list, timestamp)| {
108 let paths: Vec<PathBuf> = path_list.paths().to_vec();
109 let ordered_paths: Vec<&PathBuf> = path_list.ordered_paths().collect();
110
111 let name = if ordered_paths.len() == 1 {
112 ordered_paths[0]
113 .file_name()
114 .map(|n| n.to_string_lossy().to_string())
115 .unwrap_or_else(|| ordered_paths[0].to_string_lossy().to_string())
116 } else {
117 ordered_paths
118 .iter()
119 .filter_map(|p| p.file_name())
120 .map(|n| n.to_string_lossy().to_string())
121 .collect::<Vec<_>>()
122 .join(", ")
123 };
124
125 let full_path = ordered_paths
126 .iter()
127 .map(|p| p.to_string_lossy().to_string())
128 .collect::<Vec<_>>()
129 .join("\n");
130
131 RecentProjectEntry {
132 name: SharedString::from(name),
133 full_path: SharedString::from(full_path),
134 paths,
135 workspace_id,
136 timestamp,
137 }
138 })
139 .collect();
140
141 match limit {
142 Some(n) => entries.into_iter().take(n).collect(),
143 None => entries,
144 }
145}
146
147pub async fn delete_recent_project(workspace_id: WorkspaceId, db: &WorkspaceDb) {
148 let _ = db.delete_workspace_by_id(workspace_id).await;
149}
150
151fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec<OpenFolderEntry> {
152 let project = workspace.project().read(cx);
153 let visible_worktrees: Vec<_> = project.visible_worktrees(cx).collect();
154
155 if visible_worktrees.len() <= 1 {
156 return Vec::new();
157 }
158
159 let active_worktree_id = workspace.active_worktree_override().or_else(|| {
160 if let Some(repo) = project.active_repository(cx) {
161 let repo = repo.read(cx);
162 let repo_path = &repo.work_directory_abs_path;
163 for worktree in project.visible_worktrees(cx) {
164 let worktree_path = worktree.read(cx).abs_path();
165 if worktree_path == *repo_path || worktree_path.starts_with(repo_path.as_ref()) {
166 return Some(worktree.read(cx).id());
167 }
168 }
169 }
170 project
171 .visible_worktrees(cx)
172 .next()
173 .map(|wt| wt.read(cx).id())
174 });
175
176 let git_store = project.git_store().read(cx);
177 let repositories: Vec<_> = git_store.repositories().values().cloned().collect();
178
179 let mut entries: Vec<OpenFolderEntry> = visible_worktrees
180 .into_iter()
181 .map(|worktree| {
182 let worktree_ref = worktree.read(cx);
183 let worktree_id = worktree_ref.id();
184 let name = SharedString::from(worktree_ref.root_name().as_unix_str().to_string());
185 let path = worktree_ref.abs_path().to_path_buf();
186 let branch = get_branch_for_worktree(worktree_ref, &repositories, cx);
187 let is_active = active_worktree_id == Some(worktree_id);
188 OpenFolderEntry {
189 worktree_id,
190 name,
191 path,
192 branch,
193 is_active,
194 }
195 })
196 .collect();
197
198 entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
199 entries
200}
201
202fn get_branch_for_worktree(
203 worktree: &Worktree,
204 repositories: &[Entity<Repository>],
205 cx: &App,
206) -> Option<SharedString> {
207 let worktree_abs_path = worktree.abs_path();
208 repositories
209 .iter()
210 .filter(|repo| {
211 let repo_path = &repo.read(cx).work_directory_abs_path;
212 *repo_path == worktree_abs_path || worktree_abs_path.starts_with(repo_path.as_ref())
213 })
214 .max_by_key(|repo| repo.read(cx).work_directory_abs_path.as_os_str().len())
215 .and_then(|repo| {
216 repo.read(cx)
217 .branch
218 .as_ref()
219 .map(|branch| SharedString::from(branch.name().to_string()))
220 })
221}
222
223pub fn init(cx: &mut App) {
224 #[cfg(target_os = "windows")]
225 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {
226 let create_new_window = open_wsl.create_new_window;
227 with_active_or_new_workspace(cx, move |workspace, window, cx| {
228 use gpui::PathPromptOptions;
229 use project::DirectoryLister;
230
231 let paths = workspace.prompt_for_open_path(
232 PathPromptOptions {
233 files: true,
234 directories: true,
235 multiple: false,
236 prompt: None,
237 },
238 DirectoryLister::Local(
239 workspace.project().clone(),
240 workspace.app_state().fs.clone(),
241 ),
242 window,
243 cx,
244 );
245
246 let app_state = workspace.app_state().clone();
247 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
248
249 cx.spawn_in(window, async move |workspace, cx| {
250 use util::paths::SanitizedPath;
251
252 let Some(paths) = paths.await.log_err().flatten() else {
253 return;
254 };
255
256 let wsl_path = paths
257 .iter()
258 .find_map(util::paths::WslPath::from_path);
259
260 if let Some(util::paths::WslPath { distro, path }) = wsl_path {
261 use remote::WslConnectionOptions;
262
263 let connection_options = RemoteConnectionOptions::Wsl(WslConnectionOptions {
264 distro_name: distro.to_string(),
265 user: None,
266 });
267
268 let requesting_window = match create_new_window {
269 false => window_handle,
270 true => None,
271 };
272
273 let open_options = workspace::OpenOptions {
274 requesting_window,
275 ..Default::default()
276 };
277
278 open_remote_project(connection_options, vec![path.into()], app_state, open_options, cx).await.log_err();
279 return;
280 }
281
282 let paths = paths
283 .into_iter()
284 .filter_map(|path| SanitizedPath::new(&path).local_to_wsl())
285 .collect::<Vec<_>>();
286
287 if paths.is_empty() {
288 let message = indoc::indoc! { r#"
289 Invalid path specified when trying to open a folder inside WSL.
290
291 Please note that Zed currently does not support opening network share folders inside wsl.
292 "#};
293
294 let _ = cx.prompt(gpui::PromptLevel::Critical, "Invalid path", Some(&message), &["Ok"]).await;
295 return;
296 }
297
298 workspace.update_in(cx, |workspace, window, cx| {
299 workspace.toggle_modal(window, cx, |window, cx| {
300 crate::wsl_picker::WslOpenModal::new(paths, create_new_window, window, cx)
301 });
302 }).log_err();
303 })
304 .detach();
305 });
306 });
307
308 #[cfg(target_os = "windows")]
309 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenWsl, cx| {
310 let create_new_window = open_wsl.create_new_window;
311 with_active_or_new_workspace(cx, move |workspace, window, cx| {
312 let handle = cx.entity().downgrade();
313 let fs = workspace.project().read(cx).fs().clone();
314 workspace.toggle_modal(window, cx, |window, cx| {
315 RemoteServerProjects::wsl(create_new_window, fs, window, handle, cx)
316 });
317 });
318 });
319
320 #[cfg(target_os = "windows")]
321 cx.on_action(|open_wsl: &remote::OpenWslPath, cx| {
322 let open_wsl = open_wsl.clone();
323 with_active_or_new_workspace(cx, move |workspace, window, cx| {
324 let fs = workspace.project().read(cx).fs().clone();
325 add_wsl_distro(fs, &open_wsl.distro, cx);
326 let open_options = OpenOptions {
327 requesting_window: window.window_handle().downcast::<MultiWorkspace>(),
328 ..Default::default()
329 };
330
331 let app_state = workspace.app_state().clone();
332
333 cx.spawn_in(window, async move |_, cx| {
334 open_remote_project(
335 RemoteConnectionOptions::Wsl(open_wsl.distro.clone()),
336 open_wsl.paths,
337 app_state,
338 open_options,
339 cx,
340 )
341 .await
342 })
343 .detach();
344 });
345 });
346
347 cx.on_action(|open_recent: &OpenRecent, cx| {
348 let create_new_window = open_recent.create_new_window;
349
350 match cx
351 .active_window()
352 .and_then(|w| w.downcast::<MultiWorkspace>())
353 {
354 Some(multi_workspace) => {
355 cx.defer(move |cx| {
356 multi_workspace
357 .update(cx, |multi_workspace, window, cx| {
358 let sibling_workspace_ids: HashSet<WorkspaceId> = multi_workspace
359 .workspaces()
360 .filter_map(|ws| ws.read(cx).database_id())
361 .collect();
362
363 let workspace = multi_workspace.workspace().clone();
364 workspace.update(cx, |workspace, cx| {
365 let Some(recent_projects) =
366 workspace.active_modal::<RecentProjects>(cx)
367 else {
368 let focus_handle = workspace.focus_handle(cx);
369 RecentProjects::open(
370 workspace,
371 create_new_window,
372 sibling_workspace_ids,
373 window,
374 focus_handle,
375 cx,
376 );
377 return;
378 };
379
380 recent_projects.update(cx, |recent_projects, cx| {
381 recent_projects
382 .picker
383 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
384 });
385 });
386 })
387 .log_err();
388 });
389 }
390 None => {
391 with_active_or_new_workspace(cx, move |workspace, window, cx| {
392 let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
393 let focus_handle = workspace.focus_handle(cx);
394 RecentProjects::open(
395 workspace,
396 create_new_window,
397 HashSet::new(),
398 window,
399 focus_handle,
400 cx,
401 );
402 return;
403 };
404
405 recent_projects.update(cx, |recent_projects, cx| {
406 recent_projects
407 .picker
408 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
409 });
410 });
411 }
412 }
413 });
414 cx.on_action(|open_remote: &OpenRemote, cx| {
415 let from_existing_connection = open_remote.from_existing_connection;
416 let create_new_window = open_remote.create_new_window;
417 with_active_or_new_workspace(cx, move |workspace, window, cx| {
418 if from_existing_connection {
419 cx.propagate();
420 return;
421 }
422 let handle = cx.entity().downgrade();
423 let fs = workspace.project().read(cx).fs().clone();
424 workspace.toggle_modal(window, cx, |window, cx| {
425 RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
426 })
427 });
428 });
429
430 cx.observe_new(DisconnectedOverlay::register).detach();
431
432 cx.on_action(|_: &OpenDevContainer, cx| {
433 with_active_or_new_workspace(cx, move |workspace, window, cx| {
434 if !workspace.project().read(cx).is_local() {
435 cx.spawn_in(window, async move |_, cx| {
436 cx.prompt(
437 gpui::PromptLevel::Critical,
438 "Cannot open Dev Container from remote project",
439 None,
440 &["Ok"],
441 )
442 .await
443 .ok();
444 })
445 .detach();
446 return;
447 }
448
449 let fs = workspace.project().read(cx).fs().clone();
450 let configs = find_devcontainer_configs(workspace, cx);
451 let app_state = workspace.app_state().clone();
452 let dev_container_context = DevContainerContext::from_workspace(workspace, cx);
453 let handle = cx.entity().downgrade();
454 workspace.toggle_modal(window, cx, |window, cx| {
455 RemoteServerProjects::new_dev_container(
456 fs,
457 configs,
458 app_state,
459 dev_container_context,
460 window,
461 handle,
462 cx,
463 )
464 });
465 });
466 });
467
468 // Subscribe to worktree additions to suggest opening the project in a dev container
469 cx.observe_new(
470 |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context<Workspace>| {
471 let Some(window) = window else {
472 return;
473 };
474 cx.subscribe_in(
475 workspace.project(),
476 window,
477 move |workspace, project, event, window, cx| {
478 if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
479 event
480 {
481 dev_container_suggest::suggest_on_worktree_updated(
482 workspace,
483 *worktree_id,
484 updated_entries,
485 project,
486 window,
487 cx,
488 );
489 }
490 },
491 )
492 .detach();
493 },
494 )
495 .detach();
496}
497
498#[cfg(target_os = "windows")]
499pub fn add_wsl_distro(
500 fs: Arc<dyn project::Fs>,
501 connection_options: &remote::WslConnectionOptions,
502 cx: &App,
503) {
504 use gpui::ReadGlobal;
505 use settings::SettingsStore;
506
507 let distro_name = connection_options.distro_name.clone();
508 let user = connection_options.user.clone();
509 SettingsStore::global(cx).update_settings_file(fs, move |setting, _| {
510 let connections = setting
511 .remote
512 .wsl_connections
513 .get_or_insert(Default::default());
514
515 if !connections
516 .iter()
517 .any(|conn| conn.distro_name == distro_name && conn.user == user)
518 {
519 use std::collections::BTreeSet;
520
521 connections.push(settings::WslConnection {
522 distro_name,
523 user,
524 projects: BTreeSet::new(),
525 })
526 }
527 });
528}
529
530pub struct RecentProjects {
531 pub picker: Entity<Picker<RecentProjectsDelegate>>,
532 rem_width: f32,
533 _subscriptions: Vec<Subscription>,
534}
535
536impl ModalView for RecentProjects {
537 fn on_before_dismiss(
538 &mut self,
539 window: &mut Window,
540 cx: &mut Context<Self>,
541 ) -> workspace::DismissDecision {
542 let submenu_focused = self.picker.update(cx, |picker, cx| {
543 picker.delegate.actions_menu_handle.is_focused(window, cx)
544 });
545 workspace::DismissDecision::Dismiss(!submenu_focused)
546 }
547}
548
549impl RecentProjects {
550 fn new(
551 delegate: RecentProjectsDelegate,
552 fs: Option<Arc<dyn Fs>>,
553 rem_width: f32,
554 window: &mut Window,
555 cx: &mut Context<Self>,
556 ) -> Self {
557 let style = delegate.style;
558 let picker = cx.new(|cx| {
559 Picker::list(delegate, window, cx)
560 .list_measure_all()
561 .show_scrollbar(true)
562 });
563
564 let picker_focus_handle = picker.focus_handle(cx);
565 picker.update(cx, |picker, _| {
566 picker.delegate.focus_handle = picker_focus_handle;
567 });
568
569 let mut subscriptions = vec![cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent))];
570
571 if style == ProjectPickerStyle::Popover {
572 let picker_focus = picker.focus_handle(cx);
573 subscriptions.push(
574 cx.on_focus_out(&picker_focus, window, |this, _, window, cx| {
575 let submenu_focused = this.picker.update(cx, |picker, cx| {
576 picker.delegate.actions_menu_handle.is_focused(window, cx)
577 });
578 if !submenu_focused {
579 cx.emit(DismissEvent);
580 }
581 }),
582 );
583 }
584 // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
585 // out workspace locations once the future runs to completion.
586 let db = WorkspaceDb::global(cx);
587 cx.spawn_in(window, async move |this, cx| {
588 let Some(fs) = fs else { return };
589 let workspaces = db
590 .recent_workspaces_on_disk(fs.as_ref())
591 .await
592 .log_err()
593 .unwrap_or_default();
594 let workspaces = workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
595 this.update_in(cx, move |this, window, cx| {
596 this.picker.update(cx, move |picker, cx| {
597 picker.delegate.set_workspaces(workspaces);
598 picker.update_matches(picker.query(cx), window, cx)
599 })
600 })
601 .ok();
602 })
603 .detach();
604 Self {
605 picker,
606 rem_width,
607 _subscriptions: subscriptions,
608 }
609 }
610
611 pub fn open(
612 workspace: &mut Workspace,
613 create_new_window: bool,
614 sibling_workspace_ids: HashSet<WorkspaceId>,
615 window: &mut Window,
616 focus_handle: FocusHandle,
617 cx: &mut Context<Workspace>,
618 ) {
619 let weak = cx.entity().downgrade();
620 let open_folders = get_open_folders(workspace, cx);
621 let project_connection_options = workspace.project().read(cx).remote_connection_options(cx);
622 let fs = Some(workspace.app_state().fs.clone());
623
624 workspace.toggle_modal(window, cx, |window, cx| {
625 let delegate = RecentProjectsDelegate::new(
626 weak,
627 create_new_window,
628 focus_handle,
629 open_folders,
630 sibling_workspace_ids,
631 project_connection_options,
632 ProjectPickerStyle::Modal,
633 );
634
635 Self::new(delegate, fs, 34., window, cx)
636 })
637 }
638
639 pub fn popover(
640 workspace: WeakEntity<Workspace>,
641 sibling_workspace_ids: HashSet<WorkspaceId>,
642 create_new_window: bool,
643 focus_handle: FocusHandle,
644 window: &mut Window,
645 cx: &mut App,
646 ) -> Entity<Self> {
647 let (open_folders, project_connection_options, fs) = workspace
648 .upgrade()
649 .map(|workspace| {
650 let workspace = workspace.read(cx);
651 (
652 get_open_folders(workspace, cx),
653 workspace.project().read(cx).remote_connection_options(cx),
654 Some(workspace.app_state().fs.clone()),
655 )
656 })
657 .unwrap_or_else(|| (Vec::new(), None, None));
658
659 cx.new(|cx| {
660 let delegate = RecentProjectsDelegate::new(
661 workspace,
662 create_new_window,
663 focus_handle,
664 open_folders,
665 sibling_workspace_ids,
666 project_connection_options,
667 ProjectPickerStyle::Popover,
668 );
669 let list = Self::new(delegate, fs, 20., window, cx);
670 list.picker.focus_handle(cx).focus(window, cx);
671 list
672 })
673 }
674
675 fn handle_toggle_open_menu(
676 &mut self,
677 _: &ToggleActionsMenu,
678 window: &mut Window,
679 cx: &mut Context<Self>,
680 ) {
681 self.picker.update(cx, |picker, cx| {
682 let menu_handle = &picker.delegate.actions_menu_handle;
683 if menu_handle.is_deployed() {
684 menu_handle.hide(cx);
685 } else {
686 menu_handle.show(window, cx);
687 }
688 });
689 }
690
691 fn handle_remove_selected(
692 &mut self,
693 _: &RemoveSelected,
694 window: &mut Window,
695 cx: &mut Context<Self>,
696 ) {
697 self.picker.update(cx, |picker, cx| {
698 let ix = picker.delegate.selected_index;
699
700 match picker.delegate.filtered_entries.get(ix) {
701 Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
702 if let Some(folder) = picker.delegate.open_folders.get(*index) {
703 let worktree_id = folder.worktree_id;
704 let Some(workspace) = picker.delegate.workspace.upgrade() else {
705 return;
706 };
707 workspace.update(cx, |workspace, cx| {
708 let project = workspace.project().clone();
709 project.update(cx, |project, cx| {
710 project.remove_worktree(worktree_id, cx);
711 });
712 });
713 picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
714 let query = picker.query(cx);
715 picker.update_matches(query, window, cx);
716 }
717 }
718 Some(ProjectPickerEntry::OpenProject(hit)) => {
719 if let Some((workspace_id, ..)) =
720 picker.delegate.workspaces.get(hit.candidate_id)
721 {
722 let workspace_id = *workspace_id;
723 if picker.delegate.is_current_workspace(workspace_id, cx) {
724 return;
725 }
726 picker
727 .delegate
728 .remove_sibling_workspace(workspace_id, window, cx);
729 let query = picker.query(cx);
730 picker.update_matches(query, window, cx);
731 }
732 }
733 Some(ProjectPickerEntry::RecentProject(_)) => {
734 picker.delegate.delete_recent_project(ix, window, cx);
735 }
736 _ => {}
737 }
738 });
739 }
740
741 fn handle_add_to_workspace(
742 &mut self,
743 _: &AddToWorkspace,
744 window: &mut Window,
745 cx: &mut Context<Self>,
746 ) {
747 self.picker.update(cx, |picker, cx| {
748 let ix = picker.delegate.selected_index;
749
750 if let Some(ProjectPickerEntry::RecentProject(hit)) =
751 picker.delegate.filtered_entries.get(ix)
752 {
753 if let Some((_, location, paths, _)) =
754 picker.delegate.workspaces.get(hit.candidate_id)
755 {
756 if matches!(location, SerializedWorkspaceLocation::Local) {
757 let paths_to_add = paths.paths().to_vec();
758 picker
759 .delegate
760 .add_project_to_workspace(paths_to_add, window, cx);
761 }
762 }
763 }
764 });
765 }
766}
767
768impl EventEmitter<DismissEvent> for RecentProjects {}
769
770impl Focusable for RecentProjects {
771 fn focus_handle(&self, cx: &App) -> FocusHandle {
772 self.picker.focus_handle(cx)
773 }
774}
775
776impl Render for RecentProjects {
777 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
778 v_flex()
779 .key_context("RecentProjects")
780 .on_action(cx.listener(Self::handle_toggle_open_menu))
781 .on_action(cx.listener(Self::handle_remove_selected))
782 .on_action(cx.listener(Self::handle_add_to_workspace))
783 .w(rems(self.rem_width))
784 .child(self.picker.clone())
785 }
786}
787
788pub struct RecentProjectsDelegate {
789 workspace: WeakEntity<Workspace>,
790 open_folders: Vec<OpenFolderEntry>,
791 sibling_workspace_ids: HashSet<WorkspaceId>,
792 workspaces: Vec<(
793 WorkspaceId,
794 SerializedWorkspaceLocation,
795 PathList,
796 DateTime<Utc>,
797 )>,
798 filtered_entries: Vec<ProjectPickerEntry>,
799 selected_index: usize,
800 render_paths: bool,
801 create_new_window: bool,
802 // Flag to reset index when there is a new query vs not reset index when user delete an item
803 reset_selected_match_index: bool,
804 has_any_non_local_projects: bool,
805 project_connection_options: Option<RemoteConnectionOptions>,
806 focus_handle: FocusHandle,
807 style: ProjectPickerStyle,
808 actions_menu_handle: PopoverMenuHandle<ContextMenu>,
809}
810
811impl RecentProjectsDelegate {
812 fn new(
813 workspace: WeakEntity<Workspace>,
814 create_new_window: bool,
815 focus_handle: FocusHandle,
816 open_folders: Vec<OpenFolderEntry>,
817 sibling_workspace_ids: HashSet<WorkspaceId>,
818 project_connection_options: Option<RemoteConnectionOptions>,
819 style: ProjectPickerStyle,
820 ) -> Self {
821 let render_paths = style == ProjectPickerStyle::Modal;
822 Self {
823 workspace,
824 open_folders,
825 sibling_workspace_ids,
826 workspaces: Vec::new(),
827 filtered_entries: Vec::new(),
828 selected_index: 0,
829 create_new_window,
830 render_paths,
831 reset_selected_match_index: true,
832 has_any_non_local_projects: project_connection_options.is_some(),
833 project_connection_options,
834 focus_handle,
835 style,
836 actions_menu_handle: PopoverMenuHandle::default(),
837 }
838 }
839
840 pub fn set_workspaces(
841 &mut self,
842 workspaces: Vec<(
843 WorkspaceId,
844 SerializedWorkspaceLocation,
845 PathList,
846 DateTime<Utc>,
847 )>,
848 ) {
849 self.workspaces = workspaces;
850 let has_non_local_recent = !self
851 .workspaces
852 .iter()
853 .all(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local));
854 self.has_any_non_local_projects =
855 self.project_connection_options.is_some() || has_non_local_recent;
856 }
857}
858impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
859impl PickerDelegate for RecentProjectsDelegate {
860 type ListItem = AnyElement;
861
862 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
863 "Search projects…".into()
864 }
865
866 fn render_editor(
867 &self,
868 editor: &Arc<dyn ErasedEditor>,
869 window: &mut Window,
870 cx: &mut Context<Picker<Self>>,
871 ) -> Div {
872 h_flex()
873 .flex_none()
874 .h_9()
875 .px_2p5()
876 .justify_between()
877 .border_b_1()
878 .border_color(cx.theme().colors().border_variant)
879 .child(editor.render(window, cx))
880 }
881
882 fn match_count(&self) -> usize {
883 self.filtered_entries.len()
884 }
885
886 fn selected_index(&self) -> usize {
887 self.selected_index
888 }
889
890 fn set_selected_index(
891 &mut self,
892 ix: usize,
893 _window: &mut Window,
894 _cx: &mut Context<Picker<Self>>,
895 ) {
896 self.selected_index = ix;
897 }
898
899 fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
900 matches!(
901 self.filtered_entries.get(ix),
902 Some(
903 ProjectPickerEntry::OpenFolder { .. }
904 | ProjectPickerEntry::OpenProject(_)
905 | ProjectPickerEntry::RecentProject(_)
906 )
907 )
908 }
909
910 fn update_matches(
911 &mut self,
912 query: String,
913 _: &mut Window,
914 cx: &mut Context<Picker<Self>>,
915 ) -> gpui::Task<()> {
916 let query = query.trim_start();
917 let smart_case = query.chars().any(|c| c.is_uppercase());
918 let is_empty_query = query.is_empty();
919
920 let folder_matches = if self.open_folders.is_empty() {
921 Vec::new()
922 } else {
923 let candidates: Vec<_> = self
924 .open_folders
925 .iter()
926 .enumerate()
927 .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref()))
928 .collect();
929
930 smol::block_on(fuzzy::match_strings(
931 &candidates,
932 query,
933 smart_case,
934 true,
935 100,
936 &Default::default(),
937 cx.background_executor().clone(),
938 ))
939 };
940
941 let sibling_candidates: Vec<_> = self
942 .workspaces
943 .iter()
944 .enumerate()
945 .filter(|(_, (id, _, _, _))| self.sibling_workspace_ids.contains(id))
946 .map(|(id, (_, _, paths, _))| {
947 let combined_string = paths
948 .ordered_paths()
949 .map(|path| path.compact().to_string_lossy().into_owned())
950 .collect::<Vec<_>>()
951 .join("");
952 StringMatchCandidate::new(id, &combined_string)
953 })
954 .collect();
955
956 let mut sibling_matches = smol::block_on(fuzzy::match_strings(
957 &sibling_candidates,
958 query,
959 smart_case,
960 true,
961 100,
962 &Default::default(),
963 cx.background_executor().clone(),
964 ));
965 sibling_matches.sort_unstable_by(|a, b| {
966 b.score
967 .partial_cmp(&a.score)
968 .unwrap_or(std::cmp::Ordering::Equal)
969 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
970 });
971
972 // Build candidates for recent projects (not current, not sibling, not open folder)
973 let recent_candidates: Vec<_> = self
974 .workspaces
975 .iter()
976 .enumerate()
977 .filter(|(_, (id, _, paths, _))| self.is_valid_recent_candidate(*id, paths, cx))
978 .map(|(id, (_, _, paths, _))| {
979 let combined_string = paths
980 .ordered_paths()
981 .map(|path| path.compact().to_string_lossy().into_owned())
982 .collect::<Vec<_>>()
983 .join("");
984 StringMatchCandidate::new(id, &combined_string)
985 })
986 .collect();
987
988 let mut recent_matches = smol::block_on(fuzzy::match_strings(
989 &recent_candidates,
990 query,
991 smart_case,
992 true,
993 100,
994 &Default::default(),
995 cx.background_executor().clone(),
996 ));
997 recent_matches.sort_unstable_by(|a, b| {
998 b.score
999 .partial_cmp(&a.score)
1000 .unwrap_or(std::cmp::Ordering::Equal)
1001 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
1002 });
1003
1004 let mut entries = Vec::new();
1005
1006 if !self.open_folders.is_empty() {
1007 let matched_folders: Vec<_> = if is_empty_query {
1008 (0..self.open_folders.len())
1009 .map(|i| (i, Vec::new()))
1010 .collect()
1011 } else {
1012 folder_matches
1013 .iter()
1014 .map(|m| (m.candidate_id, m.positions.clone()))
1015 .collect()
1016 };
1017
1018 for (index, positions) in matched_folders {
1019 entries.push(ProjectPickerEntry::OpenFolder { index, positions });
1020 }
1021 }
1022
1023 let has_siblings_to_show = if is_empty_query {
1024 !sibling_candidates.is_empty()
1025 } else {
1026 !sibling_matches.is_empty()
1027 };
1028
1029 if has_siblings_to_show {
1030 entries.push(ProjectPickerEntry::Header("This Window".into()));
1031
1032 if is_empty_query {
1033 for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() {
1034 if self.sibling_workspace_ids.contains(workspace_id) {
1035 entries.push(ProjectPickerEntry::OpenProject(StringMatch {
1036 candidate_id: id,
1037 score: 0.0,
1038 positions: Vec::new(),
1039 string: String::new(),
1040 }));
1041 }
1042 }
1043 } else {
1044 for m in sibling_matches {
1045 entries.push(ProjectPickerEntry::OpenProject(m));
1046 }
1047 }
1048 }
1049
1050 let has_recent_to_show = if is_empty_query {
1051 !recent_candidates.is_empty()
1052 } else {
1053 !recent_matches.is_empty()
1054 };
1055
1056 if has_recent_to_show {
1057 entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
1058
1059 if is_empty_query {
1060 for (id, (workspace_id, _, paths, _)) in self.workspaces.iter().enumerate() {
1061 if self.is_valid_recent_candidate(*workspace_id, paths, cx) {
1062 entries.push(ProjectPickerEntry::RecentProject(StringMatch {
1063 candidate_id: id,
1064 score: 0.0,
1065 positions: Vec::new(),
1066 string: String::new(),
1067 }));
1068 }
1069 }
1070 } else {
1071 for m in recent_matches {
1072 entries.push(ProjectPickerEntry::RecentProject(m));
1073 }
1074 }
1075 }
1076
1077 self.filtered_entries = entries;
1078
1079 if self.reset_selected_match_index {
1080 self.selected_index = self
1081 .filtered_entries
1082 .iter()
1083 .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
1084 .unwrap_or(0);
1085 }
1086 self.reset_selected_match_index = true;
1087 Task::ready(())
1088 }
1089
1090 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
1091 match self.filtered_entries.get(self.selected_index) {
1092 Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
1093 let Some(folder) = self.open_folders.get(*index) else {
1094 return;
1095 };
1096 let worktree_id = folder.worktree_id;
1097 if let Some(workspace) = self.workspace.upgrade() {
1098 workspace.update(cx, |workspace, cx| {
1099 workspace.set_active_worktree_override(Some(worktree_id), cx);
1100 });
1101 }
1102 cx.emit(DismissEvent);
1103 }
1104 Some(ProjectPickerEntry::OpenProject(selected_match)) => {
1105 let Some((workspace_id, _, _, _)) =
1106 self.workspaces.get(selected_match.candidate_id)
1107 else {
1108 return;
1109 };
1110 let workspace_id = *workspace_id;
1111
1112 if self.is_current_workspace(workspace_id, cx) {
1113 cx.emit(DismissEvent);
1114 return;
1115 }
1116
1117 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1118 cx.defer(move |cx| {
1119 handle
1120 .update(cx, |multi_workspace, window, cx| {
1121 let workspace = multi_workspace
1122 .workspaces()
1123 .find(|ws| ws.read(cx).database_id() == Some(workspace_id))
1124 .cloned();
1125 if let Some(workspace) = workspace {
1126 multi_workspace.activate(workspace, window, cx);
1127 }
1128 })
1129 .log_err();
1130 });
1131 }
1132 cx.emit(DismissEvent);
1133 }
1134 Some(ProjectPickerEntry::RecentProject(selected_match)) => {
1135 let Some(workspace) = self.workspace.upgrade() else {
1136 return;
1137 };
1138 let Some((
1139 candidate_workspace_id,
1140 candidate_workspace_location,
1141 candidate_workspace_paths,
1142 _,
1143 )) = self.workspaces.get(selected_match.candidate_id)
1144 else {
1145 return;
1146 };
1147
1148 let replace_current_window = self.create_new_window == secondary;
1149 let candidate_workspace_id = *candidate_workspace_id;
1150 let candidate_workspace_location = candidate_workspace_location.clone();
1151 let candidate_workspace_paths = candidate_workspace_paths.clone();
1152
1153 workspace.update(cx, |workspace, cx| {
1154 if workspace.database_id() == Some(candidate_workspace_id) {
1155 return;
1156 }
1157 match candidate_workspace_location {
1158 SerializedWorkspaceLocation::Local => {
1159 let paths = candidate_workspace_paths.paths().to_vec();
1160 if replace_current_window {
1161 if let Some(handle) =
1162 window.window_handle().downcast::<MultiWorkspace>()
1163 {
1164 cx.defer(move |cx| {
1165 if let Some(task) = handle
1166 .update(cx, |multi_workspace, window, cx| {
1167 multi_workspace.open_project(
1168 paths,
1169 OpenMode::Activate,
1170 window,
1171 cx,
1172 )
1173 })
1174 .log_err()
1175 {
1176 task.detach_and_log_err(cx);
1177 }
1178 });
1179 }
1180 return;
1181 } else {
1182 workspace
1183 .open_workspace_for_paths(
1184 OpenMode::NewWindow,
1185 paths,
1186 window,
1187 cx,
1188 )
1189 .detach_and_prompt_err(
1190 "Failed to open project",
1191 window,
1192 cx,
1193 |_, _, _| None,
1194 );
1195 }
1196 }
1197 SerializedWorkspaceLocation::Remote(mut connection) => {
1198 let app_state = workspace.app_state().clone();
1199 let replace_window = if replace_current_window {
1200 window.window_handle().downcast::<MultiWorkspace>()
1201 } else {
1202 None
1203 };
1204 let open_options = OpenOptions {
1205 requesting_window: replace_window,
1206 ..Default::default()
1207 };
1208 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
1209 RemoteSettings::get_global(cx)
1210 .fill_connection_options_from_settings(connection);
1211 };
1212 let paths = candidate_workspace_paths.paths().to_vec();
1213 cx.spawn_in(window, async move |_, cx| {
1214 open_remote_project(
1215 connection.clone(),
1216 paths,
1217 app_state,
1218 open_options,
1219 cx,
1220 )
1221 .await
1222 })
1223 .detach_and_prompt_err(
1224 "Failed to open project",
1225 window,
1226 cx,
1227 |_, _, _| None,
1228 );
1229 }
1230 }
1231 });
1232 cx.emit(DismissEvent);
1233 }
1234 _ => {}
1235 }
1236 }
1237
1238 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
1239
1240 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1241 let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
1242 "Recently opened projects will show up here".into()
1243 } else {
1244 "No matches".into()
1245 };
1246 Some(text)
1247 }
1248
1249 fn render_match(
1250 &self,
1251 ix: usize,
1252 selected: bool,
1253 window: &mut Window,
1254 cx: &mut Context<Picker<Self>>,
1255 ) -> Option<Self::ListItem> {
1256 match self.filtered_entries.get(ix)? {
1257 ProjectPickerEntry::Header(title) => Some(
1258 v_flex()
1259 .w_full()
1260 .gap_1()
1261 .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1262 .child(ListSubHeader::new(title.clone()).inset(true))
1263 .into_any_element(),
1264 ),
1265 ProjectPickerEntry::OpenFolder { index, positions } => {
1266 let folder = self.open_folders.get(*index)?;
1267 let name = folder.name.clone();
1268 let path = folder.path.compact();
1269 let branch = folder.branch.clone();
1270 let is_active = folder.is_active;
1271 let worktree_id = folder.worktree_id;
1272 let positions = positions.clone();
1273 let show_path = self.style == ProjectPickerStyle::Modal;
1274
1275 let secondary_actions = h_flex()
1276 .gap_1()
1277 .child(
1278 IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1279 .icon_size(IconSize::Small)
1280 .tooltip(Tooltip::text("Remove Folder from Workspace"))
1281 .on_click(cx.listener(move |picker, _, window, cx| {
1282 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1283 return;
1284 };
1285 workspace.update(cx, |workspace, cx| {
1286 let project = workspace.project().clone();
1287 project.update(cx, |project, cx| {
1288 project.remove_worktree(worktree_id, cx);
1289 });
1290 });
1291 picker.delegate.open_folders =
1292 get_open_folders(workspace.read(cx), cx);
1293 let query = picker.query(cx);
1294 picker.update_matches(query, window, cx);
1295 })),
1296 )
1297 .into_any_element();
1298
1299 let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1300
1301 Some(
1302 ListItem::new(ix)
1303 .toggle_state(selected)
1304 .inset(true)
1305 .spacing(ListItemSpacing::Sparse)
1306 .child(
1307 h_flex()
1308 .id("open_folder_item")
1309 .gap_3()
1310 .flex_grow()
1311 .when(self.has_any_non_local_projects, |this| {
1312 this.child(Icon::new(icon).color(Color::Muted))
1313 })
1314 .child(
1315 v_flex()
1316 .child(
1317 h_flex()
1318 .gap_1()
1319 .child({
1320 let highlighted = HighlightedMatch {
1321 text: name.to_string(),
1322 highlight_positions: positions,
1323 color: Color::Default,
1324 };
1325 highlighted.render(window, cx)
1326 })
1327 .when_some(branch, |this, branch| {
1328 this.child(
1329 Label::new(branch).color(Color::Muted),
1330 )
1331 })
1332 .when(is_active, |this| {
1333 this.child(
1334 Icon::new(IconName::Check)
1335 .size(IconSize::Small)
1336 .color(Color::Accent),
1337 )
1338 }),
1339 )
1340 .when(show_path, |this| {
1341 this.child(
1342 Label::new(path.to_string_lossy().to_string())
1343 .size(LabelSize::Small)
1344 .color(Color::Muted),
1345 )
1346 }),
1347 )
1348 .when(!show_path, |this| {
1349 this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1350 }),
1351 )
1352 .end_slot(secondary_actions)
1353 .show_end_slot_on_hover()
1354 .into_any_element(),
1355 )
1356 }
1357 ProjectPickerEntry::OpenProject(hit) => {
1358 let (workspace_id, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1359 let workspace_id = *workspace_id;
1360 let is_current = self.is_current_workspace(workspace_id, cx);
1361 let ordered_paths: Vec<_> = paths
1362 .ordered_paths()
1363 .map(|p| p.compact().to_string_lossy().to_string())
1364 .collect();
1365 let tooltip_path: SharedString = match &location {
1366 SerializedWorkspaceLocation::Remote(options) => {
1367 let host = options.display_name();
1368 if ordered_paths.len() == 1 {
1369 format!("{} ({})", ordered_paths[0], host).into()
1370 } else {
1371 format!("{}\n({})", ordered_paths.join("\n"), host).into()
1372 }
1373 }
1374 _ => ordered_paths.join("\n").into(),
1375 };
1376
1377 let mut path_start_offset = 0;
1378 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1379 .ordered_paths()
1380 .map(|p| p.compact())
1381 .map(|path| {
1382 let highlighted_text =
1383 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1384 path_start_offset += highlighted_text.1.text.len();
1385 highlighted_text
1386 })
1387 .unzip();
1388
1389 let prefix = match &location {
1390 SerializedWorkspaceLocation::Remote(options) => {
1391 Some(SharedString::from(options.display_name()))
1392 }
1393 _ => None,
1394 };
1395
1396 let highlighted_match = HighlightedMatchWithPaths {
1397 prefix,
1398 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1399 paths,
1400 active: is_current,
1401 };
1402
1403 let icon = icon_for_remote_connection(match location {
1404 SerializedWorkspaceLocation::Local => None,
1405 SerializedWorkspaceLocation::Remote(options) => Some(options),
1406 });
1407
1408 let secondary_actions = h_flex()
1409 .gap_1()
1410 .when(!is_current, |this| {
1411 this.child(
1412 IconButton::new("remove_open_project", IconName::Close)
1413 .icon_size(IconSize::Small)
1414 .tooltip(Tooltip::text("Remove Project from Window"))
1415 .on_click(cx.listener(move |picker, _, window, cx| {
1416 cx.stop_propagation();
1417 window.prevent_default();
1418 picker.delegate.remove_sibling_workspace(
1419 workspace_id,
1420 window,
1421 cx,
1422 );
1423 let query = picker.query(cx);
1424 picker.update_matches(query, window, cx);
1425 })),
1426 )
1427 })
1428 .into_any_element();
1429
1430 Some(
1431 ListItem::new(ix)
1432 .toggle_state(selected)
1433 .inset(true)
1434 .spacing(ListItemSpacing::Sparse)
1435 .child(
1436 h_flex()
1437 .id("open_project_info_container")
1438 .gap_3()
1439 .flex_grow()
1440 .when(self.has_any_non_local_projects, |this| {
1441 this.child(Icon::new(icon).color(Color::Muted))
1442 })
1443 .child({
1444 let mut highlighted = highlighted_match;
1445 if !self.render_paths {
1446 highlighted.paths.clear();
1447 }
1448 highlighted.render(window, cx)
1449 })
1450 .tooltip(Tooltip::text(tooltip_path)),
1451 )
1452 .end_slot(secondary_actions)
1453 .show_end_slot_on_hover()
1454 .into_any_element(),
1455 )
1456 }
1457 ProjectPickerEntry::RecentProject(hit) => {
1458 let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1459 let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1460 let paths_to_add = paths.paths().to_vec();
1461 let ordered_paths: Vec<_> = paths
1462 .ordered_paths()
1463 .map(|p| p.compact().to_string_lossy().to_string())
1464 .collect();
1465 let tooltip_path: SharedString = match &location {
1466 SerializedWorkspaceLocation::Remote(options) => {
1467 let host = options.display_name();
1468 if ordered_paths.len() == 1 {
1469 format!("{} ({})", ordered_paths[0], host).into()
1470 } else {
1471 format!("{}\n({})", ordered_paths.join("\n"), host).into()
1472 }
1473 }
1474 _ => ordered_paths.join("\n").into(),
1475 };
1476
1477 let mut path_start_offset = 0;
1478 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1479 .ordered_paths()
1480 .map(|p| p.compact())
1481 .map(|path| {
1482 let highlighted_text =
1483 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1484 path_start_offset += highlighted_text.1.text.len();
1485 highlighted_text
1486 })
1487 .unzip();
1488
1489 let prefix = match &location {
1490 SerializedWorkspaceLocation::Remote(options) => {
1491 Some(SharedString::from(options.display_name()))
1492 }
1493 _ => None,
1494 };
1495
1496 let highlighted_match = HighlightedMatchWithPaths {
1497 prefix,
1498 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1499 paths,
1500 active: false,
1501 };
1502
1503 let focus_handle = self.focus_handle.clone();
1504
1505 let secondary_actions = h_flex()
1506 .gap_px()
1507 .when(is_local, |this| {
1508 this.child(
1509 IconButton::new("add_to_workspace", IconName::FolderOpenAdd)
1510 .icon_size(IconSize::Small)
1511 .tooltip(move |_, cx| {
1512 Tooltip::with_meta(
1513 "Add Project to this Workspace",
1514 None,
1515 "As a multi-root folder project",
1516 cx,
1517 )
1518 })
1519 .on_click({
1520 let paths_to_add = paths_to_add.clone();
1521 cx.listener(move |picker, _event, window, cx| {
1522 cx.stop_propagation();
1523 window.prevent_default();
1524 picker.delegate.add_project_to_workspace(
1525 paths_to_add.clone(),
1526 window,
1527 cx,
1528 );
1529 })
1530 }),
1531 )
1532 })
1533 .child(
1534 IconButton::new("open_new_window", IconName::OpenNewWindow)
1535 .icon_size(IconSize::Small)
1536 .tooltip({
1537 move |_, cx| {
1538 Tooltip::for_action_in(
1539 "Open Project in New Window",
1540 &menu::SecondaryConfirm,
1541 &focus_handle,
1542 cx,
1543 )
1544 }
1545 })
1546 .on_click(cx.listener(move |this, _event, window, cx| {
1547 cx.stop_propagation();
1548 window.prevent_default();
1549 this.delegate.set_selected_index(ix, window, cx);
1550 this.delegate.confirm(true, window, cx);
1551 })),
1552 )
1553 .child(
1554 IconButton::new("delete", IconName::Close)
1555 .icon_size(IconSize::Small)
1556 .tooltip(Tooltip::text("Delete from Recent Projects"))
1557 .on_click(cx.listener(move |this, _event, window, cx| {
1558 cx.stop_propagation();
1559 window.prevent_default();
1560 this.delegate.delete_recent_project(ix, window, cx)
1561 })),
1562 )
1563 .into_any_element();
1564
1565 let icon = icon_for_remote_connection(match location {
1566 SerializedWorkspaceLocation::Local => None,
1567 SerializedWorkspaceLocation::Remote(options) => Some(options),
1568 });
1569
1570 Some(
1571 ListItem::new(ix)
1572 .toggle_state(selected)
1573 .inset(true)
1574 .spacing(ListItemSpacing::Sparse)
1575 .child(
1576 h_flex()
1577 .id("project_info_container")
1578 .gap_3()
1579 .flex_grow()
1580 .when(self.has_any_non_local_projects, |this| {
1581 this.child(Icon::new(icon).color(Color::Muted))
1582 })
1583 .child({
1584 let mut highlighted = highlighted_match;
1585 if !self.render_paths {
1586 highlighted.paths.clear();
1587 }
1588 highlighted.render(window, cx)
1589 })
1590 .tooltip(move |_, cx| {
1591 Tooltip::with_meta(
1592 "Open Project in This Window",
1593 None,
1594 tooltip_path.clone(),
1595 cx,
1596 )
1597 }),
1598 )
1599 .end_slot(secondary_actions)
1600 .show_end_slot_on_hover()
1601 .into_any_element(),
1602 )
1603 }
1604 }
1605 }
1606
1607 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1608 let focus_handle = self.focus_handle.clone();
1609 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1610 let is_already_open_entry = matches!(
1611 self.filtered_entries.get(self.selected_index),
1612 Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::OpenProject(_))
1613 );
1614
1615 if popover_style {
1616 return Some(
1617 v_flex()
1618 .flex_1()
1619 .p_1p5()
1620 .gap_1()
1621 .border_t_1()
1622 .border_color(cx.theme().colors().border_variant)
1623 .child({
1624 let open_action = workspace::Open::default();
1625 Button::new("open_local_folder", "Open Local Project")
1626 .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
1627 .on_click(move |_, window, cx| {
1628 window.dispatch_action(open_action.boxed_clone(), cx)
1629 })
1630 })
1631 .child(
1632 Button::new("open_remote_folder", "Open Remote Project")
1633 .key_binding(KeyBinding::for_action(
1634 &OpenRemote {
1635 from_existing_connection: false,
1636 create_new_window: false,
1637 },
1638 cx,
1639 ))
1640 .on_click(|_, window, cx| {
1641 window.dispatch_action(
1642 OpenRemote {
1643 from_existing_connection: false,
1644 create_new_window: false,
1645 }
1646 .boxed_clone(),
1647 cx,
1648 )
1649 }),
1650 )
1651 .into_any(),
1652 );
1653 }
1654
1655 let selected_entry = self.filtered_entries.get(self.selected_index);
1656
1657 let is_current_workspace_entry =
1658 if let Some(ProjectPickerEntry::OpenProject(hit)) = selected_entry {
1659 self.workspaces
1660 .get(hit.candidate_id)
1661 .map(|(id, ..)| self.is_current_workspace(*id, cx))
1662 .unwrap_or(false)
1663 } else {
1664 false
1665 };
1666
1667 let secondary_footer_actions: Option<AnyElement> = match selected_entry {
1668 Some(ProjectPickerEntry::OpenFolder { .. }) => Some(
1669 Button::new("remove_selected", "Remove Folder")
1670 .key_binding(KeyBinding::for_action_in(
1671 &RemoveSelected,
1672 &focus_handle,
1673 cx,
1674 ))
1675 .on_click(|_, window, cx| {
1676 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1677 })
1678 .into_any_element(),
1679 ),
1680 Some(ProjectPickerEntry::OpenProject(_)) if !is_current_workspace_entry => Some(
1681 Button::new("remove_selected", "Remove from Window")
1682 .key_binding(KeyBinding::for_action_in(
1683 &RemoveSelected,
1684 &focus_handle,
1685 cx,
1686 ))
1687 .on_click(|_, window, cx| {
1688 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1689 })
1690 .into_any_element(),
1691 ),
1692 Some(ProjectPickerEntry::RecentProject(_)) => Some(
1693 Button::new("delete_recent", "Delete")
1694 .key_binding(KeyBinding::for_action_in(
1695 &RemoveSelected,
1696 &focus_handle,
1697 cx,
1698 ))
1699 .on_click(|_, window, cx| {
1700 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1701 })
1702 .into_any_element(),
1703 ),
1704 _ => None,
1705 };
1706
1707 Some(
1708 h_flex()
1709 .flex_1()
1710 .p_1p5()
1711 .gap_1()
1712 .justify_end()
1713 .border_t_1()
1714 .border_color(cx.theme().colors().border_variant)
1715 .when_some(secondary_footer_actions, |this, actions| {
1716 this.child(actions)
1717 })
1718 .map(|this| {
1719 if is_already_open_entry {
1720 this.child(
1721 Button::new("activate", "Activate")
1722 .key_binding(KeyBinding::for_action_in(
1723 &menu::Confirm,
1724 &focus_handle,
1725 cx,
1726 ))
1727 .on_click(|_, window, cx| {
1728 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1729 }),
1730 )
1731 } else {
1732 this.child(
1733 Button::new("open_new_window", "New Window")
1734 .key_binding(KeyBinding::for_action_in(
1735 &menu::SecondaryConfirm,
1736 &focus_handle,
1737 cx,
1738 ))
1739 .on_click(|_, window, cx| {
1740 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1741 }),
1742 )
1743 .child(
1744 Button::new("open_here", "Open")
1745 .key_binding(KeyBinding::for_action_in(
1746 &menu::Confirm,
1747 &focus_handle,
1748 cx,
1749 ))
1750 .on_click(|_, window, cx| {
1751 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1752 }),
1753 )
1754 }
1755 })
1756 .child(Divider::vertical())
1757 .child(
1758 PopoverMenu::new("actions-menu-popover")
1759 .with_handle(self.actions_menu_handle.clone())
1760 .anchor(gpui::Corner::BottomRight)
1761 .offset(gpui::Point {
1762 x: px(0.0),
1763 y: px(-2.0),
1764 })
1765 .trigger(
1766 Button::new("actions-trigger", "Actions")
1767 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1768 .key_binding(KeyBinding::for_action_in(
1769 &ToggleActionsMenu,
1770 &focus_handle,
1771 cx,
1772 )),
1773 )
1774 .menu({
1775 let focus_handle = focus_handle.clone();
1776 let show_add_to_workspace = match selected_entry {
1777 Some(ProjectPickerEntry::RecentProject(hit)) => self
1778 .workspaces
1779 .get(hit.candidate_id)
1780 .map(|(_, loc, ..)| {
1781 matches!(loc, SerializedWorkspaceLocation::Local)
1782 })
1783 .unwrap_or(false),
1784 _ => false,
1785 };
1786
1787 move |window, cx| {
1788 Some(ContextMenu::build(window, cx, {
1789 let focus_handle = focus_handle.clone();
1790 move |menu, _, _| {
1791 menu.context(focus_handle)
1792 .when(show_add_to_workspace, |menu| {
1793 menu.action(
1794 "Add to this Workspace",
1795 AddToWorkspace.boxed_clone(),
1796 )
1797 .separator()
1798 })
1799 .action(
1800 "Open Local Project",
1801 workspace::Open::default().boxed_clone(),
1802 )
1803 .action(
1804 "Open Remote Project",
1805 OpenRemote {
1806 from_existing_connection: false,
1807 create_new_window: false,
1808 }
1809 .boxed_clone(),
1810 )
1811 }
1812 }))
1813 }
1814 }),
1815 )
1816 .into_any(),
1817 )
1818 }
1819}
1820
1821pub(crate) fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1822 match options {
1823 None => IconName::Screen,
1824 Some(options) => match options {
1825 RemoteConnectionOptions::Ssh(_) => IconName::Server,
1826 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1827 RemoteConnectionOptions::Docker(_) => IconName::Box,
1828 #[cfg(any(test, feature = "test-support"))]
1829 RemoteConnectionOptions::Mock(_) => IconName::Server,
1830 },
1831 }
1832}
1833
1834// Compute the highlighted text for the name and path
1835pub(crate) fn highlights_for_path(
1836 path: &Path,
1837 match_positions: &Vec<usize>,
1838 path_start_offset: usize,
1839) -> (Option<HighlightedMatch>, HighlightedMatch) {
1840 let path_string = path.to_string_lossy();
1841 let path_text = path_string.to_string();
1842 let path_byte_len = path_text.len();
1843 // Get the subset of match highlight positions that line up with the given path.
1844 // Also adjusts them to start at the path start
1845 let path_positions = match_positions
1846 .iter()
1847 .copied()
1848 .skip_while(|position| *position < path_start_offset)
1849 .take_while(|position| *position < path_start_offset + path_byte_len)
1850 .map(|position| position - path_start_offset)
1851 .collect::<Vec<_>>();
1852
1853 // Again subset the highlight positions to just those that line up with the file_name
1854 // again adjusted to the start of the file_name
1855 let file_name_text_and_positions = path.file_name().map(|file_name| {
1856 let file_name_text = file_name.to_string_lossy().into_owned();
1857 let file_name_start_byte = path_byte_len - file_name_text.len();
1858 let highlight_positions = path_positions
1859 .iter()
1860 .copied()
1861 .skip_while(|position| *position < file_name_start_byte)
1862 .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1863 .map(|position| position - file_name_start_byte)
1864 .collect::<Vec<_>>();
1865 HighlightedMatch {
1866 text: file_name_text,
1867 highlight_positions,
1868 color: Color::Default,
1869 }
1870 });
1871
1872 (
1873 file_name_text_and_positions,
1874 HighlightedMatch {
1875 text: path_text,
1876 highlight_positions: path_positions,
1877 color: Color::Default,
1878 },
1879 )
1880}
1881impl RecentProjectsDelegate {
1882 fn add_project_to_workspace(
1883 &mut self,
1884 paths: Vec<PathBuf>,
1885 window: &mut Window,
1886 cx: &mut Context<Picker<Self>>,
1887 ) {
1888 let Some(workspace) = self.workspace.upgrade() else {
1889 return;
1890 };
1891 let open_paths_task = workspace.update(cx, |workspace, cx| {
1892 workspace.open_paths(
1893 paths,
1894 OpenOptions {
1895 visible: Some(OpenVisible::All),
1896 ..Default::default()
1897 },
1898 None,
1899 window,
1900 cx,
1901 )
1902 });
1903 cx.spawn_in(window, async move |picker, cx| {
1904 let _result = open_paths_task.await;
1905 picker
1906 .update_in(cx, |picker, window, cx| {
1907 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1908 return;
1909 };
1910 picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
1911 let query = picker.query(cx);
1912 picker.update_matches(query, window, cx);
1913 })
1914 .ok();
1915 })
1916 .detach();
1917 }
1918
1919 fn delete_recent_project(
1920 &self,
1921 ix: usize,
1922 window: &mut Window,
1923 cx: &mut Context<Picker<Self>>,
1924 ) {
1925 if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
1926 self.filtered_entries.get(ix)
1927 {
1928 let (workspace_id, _, _, _) = &self.workspaces[selected_match.candidate_id];
1929 let workspace_id = *workspace_id;
1930 let fs = self
1931 .workspace
1932 .upgrade()
1933 .map(|ws| ws.read(cx).app_state().fs.clone());
1934 let db = WorkspaceDb::global(cx);
1935 cx.spawn_in(window, async move |this, cx| {
1936 db.delete_workspace_by_id(workspace_id).await.log_err();
1937 let Some(fs) = fs else { return };
1938 let workspaces = db
1939 .recent_workspaces_on_disk(fs.as_ref())
1940 .await
1941 .unwrap_or_default();
1942 let workspaces =
1943 workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
1944 this.update_in(cx, move |picker, window, cx| {
1945 picker.delegate.set_workspaces(workspaces);
1946 picker
1947 .delegate
1948 .set_selected_index(ix.saturating_sub(1), window, cx);
1949 picker.delegate.reset_selected_match_index = false;
1950 picker.update_matches(picker.query(cx), window, cx);
1951 // After deleting a project, we want to update the history manager to reflect the change.
1952 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1953 if let Some(history_manager) = HistoryManager::global(cx) {
1954 history_manager
1955 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1956 }
1957 })
1958 .ok();
1959 })
1960 .detach();
1961 }
1962 }
1963
1964 fn remove_sibling_workspace(
1965 &mut self,
1966 workspace_id: WorkspaceId,
1967 window: &mut Window,
1968 cx: &mut Context<Picker<Self>>,
1969 ) {
1970 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1971 cx.defer(move |cx| {
1972 handle
1973 .update(cx, |multi_workspace, window, cx| {
1974 let workspace = multi_workspace
1975 .workspaces()
1976 .find(|ws| ws.read(cx).database_id() == Some(workspace_id))
1977 .cloned();
1978 if let Some(workspace) = workspace {
1979 multi_workspace.remove(&workspace, window, cx);
1980 }
1981 })
1982 .log_err();
1983 });
1984 }
1985
1986 self.sibling_workspace_ids.remove(&workspace_id);
1987 }
1988
1989 fn is_current_workspace(
1990 &self,
1991 workspace_id: WorkspaceId,
1992 cx: &mut Context<Picker<Self>>,
1993 ) -> bool {
1994 if let Some(workspace) = self.workspace.upgrade() {
1995 let workspace = workspace.read(cx);
1996 if Some(workspace_id) == workspace.database_id() {
1997 return true;
1998 }
1999 }
2000
2001 false
2002 }
2003
2004 fn is_sibling_workspace(
2005 &self,
2006 workspace_id: WorkspaceId,
2007 cx: &mut Context<Picker<Self>>,
2008 ) -> bool {
2009 self.sibling_workspace_ids.contains(&workspace_id)
2010 && !self.is_current_workspace(workspace_id, cx)
2011 }
2012
2013 fn is_open_folder(&self, paths: &PathList) -> bool {
2014 if self.open_folders.is_empty() {
2015 return false;
2016 }
2017
2018 for workspace_path in paths.paths() {
2019 for open_folder in &self.open_folders {
2020 if workspace_path == &open_folder.path {
2021 return true;
2022 }
2023 }
2024 }
2025
2026 false
2027 }
2028
2029 fn is_valid_recent_candidate(
2030 &self,
2031 workspace_id: WorkspaceId,
2032 paths: &PathList,
2033 cx: &mut Context<Picker<Self>>,
2034 ) -> bool {
2035 !self.is_current_workspace(workspace_id, cx)
2036 && !self.is_sibling_workspace(workspace_id, cx)
2037 && !self.is_open_folder(paths)
2038 }
2039}
2040
2041#[cfg(test)]
2042mod tests {
2043 use std::path::PathBuf;
2044
2045 use editor::Editor;
2046 use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle};
2047
2048 use serde_json::json;
2049 use settings::SettingsStore;
2050 use util::path;
2051 use workspace::{AppState, open_paths};
2052
2053 use super::*;
2054
2055 #[gpui::test]
2056 async fn test_dirty_workspace_replaced_when_opening_recent_project(cx: &mut TestAppContext) {
2057 let app_state = init_test(cx);
2058
2059 cx.update(|cx| {
2060 SettingsStore::update_global(cx, |store, cx| {
2061 store.update_user_settings(cx, |settings| {
2062 settings
2063 .session
2064 .get_or_insert_default()
2065 .restore_unsaved_buffers = Some(false)
2066 });
2067 });
2068 });
2069
2070 app_state
2071 .fs
2072 .as_fake()
2073 .insert_tree(
2074 path!("/dir"),
2075 json!({
2076 "main.ts": "a"
2077 }),
2078 )
2079 .await;
2080 app_state
2081 .fs
2082 .as_fake()
2083 .insert_tree(path!("/test/path"), json!({}))
2084 .await;
2085 cx.update(|cx| {
2086 open_paths(
2087 &[PathBuf::from(path!("/dir/main.ts"))],
2088 app_state,
2089 workspace::OpenOptions::default(),
2090 cx,
2091 )
2092 })
2093 .await
2094 .unwrap();
2095 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2096
2097 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2098 multi_workspace
2099 .update(cx, |multi_workspace, _, cx| {
2100 multi_workspace.open_sidebar(cx);
2101 })
2102 .unwrap();
2103 multi_workspace
2104 .update(cx, |multi_workspace, _, cx| {
2105 assert!(!multi_workspace.workspace().read(cx).is_edited())
2106 })
2107 .unwrap();
2108
2109 let editor = multi_workspace
2110 .read_with(cx, |multi_workspace, cx| {
2111 multi_workspace
2112 .workspace()
2113 .read(cx)
2114 .active_item(cx)
2115 .unwrap()
2116 .downcast::<Editor>()
2117 .unwrap()
2118 })
2119 .unwrap();
2120 multi_workspace
2121 .update(cx, |_, window, cx| {
2122 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
2123 })
2124 .unwrap();
2125 multi_workspace
2126 .update(cx, |multi_workspace, _, cx| {
2127 assert!(
2128 multi_workspace.workspace().read(cx).is_edited(),
2129 "After inserting more text into the editor without saving, we should have a dirty project"
2130 )
2131 })
2132 .unwrap();
2133
2134 let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
2135 multi_workspace
2136 .update(cx, |_, _, cx| {
2137 recent_projects_picker.update(cx, |picker, cx| {
2138 assert_eq!(picker.query(cx), "");
2139 let delegate = &mut picker.delegate;
2140 delegate.set_workspaces(vec![(
2141 WorkspaceId::default(),
2142 SerializedWorkspaceLocation::Local,
2143 PathList::new(&[path!("/test/path")]),
2144 Utc::now(),
2145 )]);
2146 delegate.filtered_entries =
2147 vec![ProjectPickerEntry::RecentProject(StringMatch {
2148 candidate_id: 0,
2149 score: 1.0,
2150 positions: Vec::new(),
2151 string: "fake candidate".to_string(),
2152 })];
2153 });
2154 })
2155 .unwrap();
2156
2157 assert!(
2158 !cx.has_pending_prompt(),
2159 "Should have no pending prompt on dirty project before opening the new recent project"
2160 );
2161 let dirty_workspace = multi_workspace
2162 .read_with(cx, |multi_workspace, _cx| {
2163 multi_workspace.workspace().clone()
2164 })
2165 .unwrap();
2166
2167 cx.dispatch_action(*multi_workspace, menu::Confirm);
2168 cx.run_until_parked();
2169
2170 // In multi-workspace mode, the dirty workspace is kept and a new one is
2171 // opened alongside it — no save prompt needed.
2172 assert!(
2173 !cx.has_pending_prompt(),
2174 "Should not prompt in multi-workspace mode — dirty workspace is kept"
2175 );
2176
2177 multi_workspace
2178 .update(cx, |multi_workspace, _, cx| {
2179 assert!(
2180 multi_workspace
2181 .workspace()
2182 .read(cx)
2183 .active_modal::<RecentProjects>(cx)
2184 .is_none(),
2185 "Should remove the modal after selecting new recent project"
2186 );
2187
2188 assert!(
2189 multi_workspace.workspaces().any(|w| w == &dirty_workspace),
2190 "The dirty workspace should still be present in multi-workspace mode"
2191 );
2192
2193 assert!(
2194 !multi_workspace.workspace().read(cx).is_edited(),
2195 "The active workspace should be the freshly opened one, not dirty"
2196 );
2197 })
2198 .unwrap();
2199 }
2200
2201 fn open_recent_projects(
2202 multi_workspace: &WindowHandle<MultiWorkspace>,
2203 cx: &mut TestAppContext,
2204 ) -> Entity<Picker<RecentProjectsDelegate>> {
2205 cx.dispatch_action(
2206 (*multi_workspace).into(),
2207 OpenRecent {
2208 create_new_window: false,
2209 },
2210 );
2211 multi_workspace
2212 .update(cx, |multi_workspace, _, cx| {
2213 multi_workspace
2214 .workspace()
2215 .read(cx)
2216 .active_modal::<RecentProjects>(cx)
2217 .unwrap()
2218 .read(cx)
2219 .picker
2220 .clone()
2221 })
2222 .unwrap()
2223 }
2224
2225 #[gpui::test]
2226 async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
2227 let app_state = init_test(cx);
2228
2229 app_state
2230 .fs
2231 .as_fake()
2232 .insert_tree(
2233 path!("/project"),
2234 json!({
2235 ".devcontainer": {
2236 "devcontainer.json": "{}"
2237 },
2238 "src": {
2239 "main.rs": "fn main() {}"
2240 }
2241 }),
2242 )
2243 .await;
2244
2245 // Open a file path (not a directory) so that the worktree root is a
2246 // file. This means `active_project_directory` returns `None`, which
2247 // causes `DevContainerContext::from_workspace` to return `None`,
2248 // preventing `open_dev_container` from spawning real I/O (docker
2249 // commands, shell environment loading) that is incompatible with the
2250 // test scheduler. The modal is still created and the re-entrancy
2251 // guard that this test validates is still exercised.
2252 cx.update(|cx| {
2253 open_paths(
2254 &[PathBuf::from(path!("/project/src/main.rs"))],
2255 app_state,
2256 workspace::OpenOptions::default(),
2257 cx,
2258 )
2259 })
2260 .await
2261 .unwrap();
2262
2263 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2264 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2265
2266 cx.run_until_parked();
2267
2268 // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
2269 // -> Workspace::update -> toggle_modal -> new_dev_container.
2270 // Before the fix, this panicked with "cannot read workspace::Workspace while
2271 // it is already being updated" because new_dev_container and open_dev_container
2272 // tried to read the Workspace entity through a WeakEntity handle while it was
2273 // already leased by the outer update.
2274 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2275
2276 multi_workspace
2277 .update(cx, |multi_workspace, _, cx| {
2278 let modal = multi_workspace
2279 .workspace()
2280 .read(cx)
2281 .active_modal::<RemoteServerProjects>(cx);
2282 assert!(
2283 modal.is_some(),
2284 "Dev container modal should be open after dispatching OpenDevContainer"
2285 );
2286 })
2287 .unwrap();
2288 }
2289
2290 #[gpui::test]
2291 async fn test_dev_container_modal_not_dismissed_on_backdrop_click(cx: &mut TestAppContext) {
2292 let app_state = init_test(cx);
2293
2294 app_state
2295 .fs
2296 .as_fake()
2297 .insert_tree(
2298 path!("/project"),
2299 json!({
2300 ".devcontainer": {
2301 "devcontainer.json": "{}"
2302 },
2303 "src": {
2304 "main.rs": "fn main() {}"
2305 }
2306 }),
2307 )
2308 .await;
2309
2310 cx.update(|cx| {
2311 open_paths(
2312 &[PathBuf::from(path!("/project"))],
2313 app_state,
2314 workspace::OpenOptions::default(),
2315 cx,
2316 )
2317 })
2318 .await
2319 .unwrap();
2320
2321 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2322 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2323
2324 cx.run_until_parked();
2325
2326 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2327
2328 multi_workspace
2329 .update(cx, |multi_workspace, _, cx| {
2330 assert!(
2331 multi_workspace
2332 .active_modal::<RemoteServerProjects>(cx)
2333 .is_some(),
2334 "Dev container modal should be open"
2335 );
2336 })
2337 .unwrap();
2338
2339 // Click outside the modal (on the backdrop) to try to dismiss it
2340 let mut vcx = VisualTestContext::from_window(*multi_workspace, cx);
2341 vcx.simulate_click(gpui::point(px(1.0), px(1.0)), gpui::Modifiers::default());
2342
2343 multi_workspace
2344 .update(cx, |multi_workspace, _, cx| {
2345 assert!(
2346 multi_workspace
2347 .active_modal::<RemoteServerProjects>(cx)
2348 .is_some(),
2349 "Dev container modal should remain open during creation"
2350 );
2351 })
2352 .unwrap();
2353 }
2354
2355 #[gpui::test]
2356 async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
2357 let app_state = init_test(cx);
2358
2359 app_state
2360 .fs
2361 .as_fake()
2362 .insert_tree(
2363 path!("/project"),
2364 json!({
2365 ".devcontainer": {
2366 "rust": {
2367 "devcontainer.json": "{}"
2368 },
2369 "python": {
2370 "devcontainer.json": "{}"
2371 }
2372 },
2373 "src": {
2374 "main.rs": "fn main() {}"
2375 }
2376 }),
2377 )
2378 .await;
2379
2380 cx.update(|cx| {
2381 open_paths(
2382 &[PathBuf::from(path!("/project"))],
2383 app_state,
2384 workspace::OpenOptions::default(),
2385 cx,
2386 )
2387 })
2388 .await
2389 .unwrap();
2390
2391 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2392 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2393
2394 cx.run_until_parked();
2395
2396 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2397
2398 multi_workspace
2399 .update(cx, |multi_workspace, _, cx| {
2400 let modal = multi_workspace
2401 .workspace()
2402 .read(cx)
2403 .active_modal::<RemoteServerProjects>(cx);
2404 assert!(
2405 modal.is_some(),
2406 "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
2407 );
2408 })
2409 .unwrap();
2410 }
2411
2412 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2413 cx.update(|cx| {
2414 let state = AppState::test(cx);
2415 crate::init(cx);
2416 editor::init(cx);
2417 state
2418 })
2419 }
2420}