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 picker
724 .delegate
725 .remove_sibling_workspace(workspace_id, window, cx);
726 let query = picker.query(cx);
727 picker.update_matches(query, window, cx);
728 }
729 }
730 Some(ProjectPickerEntry::RecentProject(_)) => {
731 picker.delegate.delete_recent_project(ix, window, cx);
732 }
733 _ => {}
734 }
735 });
736 }
737
738 fn handle_add_to_workspace(
739 &mut self,
740 _: &AddToWorkspace,
741 window: &mut Window,
742 cx: &mut Context<Self>,
743 ) {
744 self.picker.update(cx, |picker, cx| {
745 let ix = picker.delegate.selected_index;
746
747 if let Some(ProjectPickerEntry::RecentProject(hit)) =
748 picker.delegate.filtered_entries.get(ix)
749 {
750 if let Some((_, location, paths, _)) =
751 picker.delegate.workspaces.get(hit.candidate_id)
752 {
753 if matches!(location, SerializedWorkspaceLocation::Local) {
754 let paths_to_add = paths.paths().to_vec();
755 picker
756 .delegate
757 .add_project_to_workspace(paths_to_add, window, cx);
758 }
759 }
760 }
761 });
762 }
763}
764
765impl EventEmitter<DismissEvent> for RecentProjects {}
766
767impl Focusable for RecentProjects {
768 fn focus_handle(&self, cx: &App) -> FocusHandle {
769 self.picker.focus_handle(cx)
770 }
771}
772
773impl Render for RecentProjects {
774 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
775 v_flex()
776 .key_context("RecentProjects")
777 .on_action(cx.listener(Self::handle_toggle_open_menu))
778 .on_action(cx.listener(Self::handle_remove_selected))
779 .on_action(cx.listener(Self::handle_add_to_workspace))
780 .w(rems(self.rem_width))
781 .child(self.picker.clone())
782 }
783}
784
785pub struct RecentProjectsDelegate {
786 workspace: WeakEntity<Workspace>,
787 open_folders: Vec<OpenFolderEntry>,
788 sibling_workspace_ids: HashSet<WorkspaceId>,
789 workspaces: Vec<(
790 WorkspaceId,
791 SerializedWorkspaceLocation,
792 PathList,
793 DateTime<Utc>,
794 )>,
795 filtered_entries: Vec<ProjectPickerEntry>,
796 selected_index: usize,
797 render_paths: bool,
798 create_new_window: bool,
799 // Flag to reset index when there is a new query vs not reset index when user delete an item
800 reset_selected_match_index: bool,
801 has_any_non_local_projects: bool,
802 project_connection_options: Option<RemoteConnectionOptions>,
803 focus_handle: FocusHandle,
804 style: ProjectPickerStyle,
805 actions_menu_handle: PopoverMenuHandle<ContextMenu>,
806}
807
808impl RecentProjectsDelegate {
809 fn new(
810 workspace: WeakEntity<Workspace>,
811 create_new_window: bool,
812 focus_handle: FocusHandle,
813 open_folders: Vec<OpenFolderEntry>,
814 sibling_workspace_ids: HashSet<WorkspaceId>,
815 project_connection_options: Option<RemoteConnectionOptions>,
816 style: ProjectPickerStyle,
817 ) -> Self {
818 let render_paths = style == ProjectPickerStyle::Modal;
819 Self {
820 workspace,
821 open_folders,
822 sibling_workspace_ids,
823 workspaces: Vec::new(),
824 filtered_entries: Vec::new(),
825 selected_index: 0,
826 create_new_window,
827 render_paths,
828 reset_selected_match_index: true,
829 has_any_non_local_projects: project_connection_options.is_some(),
830 project_connection_options,
831 focus_handle,
832 style,
833 actions_menu_handle: PopoverMenuHandle::default(),
834 }
835 }
836
837 pub fn set_workspaces(
838 &mut self,
839 workspaces: Vec<(
840 WorkspaceId,
841 SerializedWorkspaceLocation,
842 PathList,
843 DateTime<Utc>,
844 )>,
845 ) {
846 self.workspaces = workspaces;
847 let has_non_local_recent = !self
848 .workspaces
849 .iter()
850 .all(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local));
851 self.has_any_non_local_projects =
852 self.project_connection_options.is_some() || has_non_local_recent;
853 }
854}
855impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
856impl PickerDelegate for RecentProjectsDelegate {
857 type ListItem = AnyElement;
858
859 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
860 "Search projects…".into()
861 }
862
863 fn render_editor(
864 &self,
865 editor: &Arc<dyn ErasedEditor>,
866 window: &mut Window,
867 cx: &mut Context<Picker<Self>>,
868 ) -> Div {
869 h_flex()
870 .flex_none()
871 .h_9()
872 .px_2p5()
873 .justify_between()
874 .border_b_1()
875 .border_color(cx.theme().colors().border_variant)
876 .child(editor.render(window, cx))
877 }
878
879 fn match_count(&self) -> usize {
880 self.filtered_entries.len()
881 }
882
883 fn selected_index(&self) -> usize {
884 self.selected_index
885 }
886
887 fn set_selected_index(
888 &mut self,
889 ix: usize,
890 _window: &mut Window,
891 _cx: &mut Context<Picker<Self>>,
892 ) {
893 self.selected_index = ix;
894 }
895
896 fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
897 matches!(
898 self.filtered_entries.get(ix),
899 Some(
900 ProjectPickerEntry::OpenFolder { .. }
901 | ProjectPickerEntry::OpenProject(_)
902 | ProjectPickerEntry::RecentProject(_)
903 )
904 )
905 }
906
907 fn update_matches(
908 &mut self,
909 query: String,
910 _: &mut Window,
911 cx: &mut Context<Picker<Self>>,
912 ) -> gpui::Task<()> {
913 let query = query.trim_start();
914 let smart_case = query.chars().any(|c| c.is_uppercase());
915 let is_empty_query = query.is_empty();
916
917 let folder_matches = if self.open_folders.is_empty() {
918 Vec::new()
919 } else {
920 let candidates: Vec<_> = self
921 .open_folders
922 .iter()
923 .enumerate()
924 .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref()))
925 .collect();
926
927 smol::block_on(fuzzy::match_strings(
928 &candidates,
929 query,
930 smart_case,
931 true,
932 100,
933 &Default::default(),
934 cx.background_executor().clone(),
935 ))
936 };
937
938 let sibling_candidates: Vec<_> = self
939 .workspaces
940 .iter()
941 .enumerate()
942 .filter(|(_, (id, _, _, _))| self.is_sibling_workspace(*id, cx))
943 .map(|(id, (_, _, paths, _))| {
944 let combined_string = paths
945 .ordered_paths()
946 .map(|path| path.compact().to_string_lossy().into_owned())
947 .collect::<Vec<_>>()
948 .join("");
949 StringMatchCandidate::new(id, &combined_string)
950 })
951 .collect();
952
953 let mut sibling_matches = smol::block_on(fuzzy::match_strings(
954 &sibling_candidates,
955 query,
956 smart_case,
957 true,
958 100,
959 &Default::default(),
960 cx.background_executor().clone(),
961 ));
962 sibling_matches.sort_unstable_by(|a, b| {
963 b.score
964 .partial_cmp(&a.score)
965 .unwrap_or(std::cmp::Ordering::Equal)
966 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
967 });
968
969 // Build candidates for recent projects (not current, not sibling, not open folder)
970 let recent_candidates: Vec<_> = self
971 .workspaces
972 .iter()
973 .enumerate()
974 .filter(|(_, (id, _, paths, _))| self.is_valid_recent_candidate(*id, paths, cx))
975 .map(|(id, (_, _, paths, _))| {
976 let combined_string = paths
977 .ordered_paths()
978 .map(|path| path.compact().to_string_lossy().into_owned())
979 .collect::<Vec<_>>()
980 .join("");
981 StringMatchCandidate::new(id, &combined_string)
982 })
983 .collect();
984
985 let mut recent_matches = smol::block_on(fuzzy::match_strings(
986 &recent_candidates,
987 query,
988 smart_case,
989 true,
990 100,
991 &Default::default(),
992 cx.background_executor().clone(),
993 ));
994 recent_matches.sort_unstable_by(|a, b| {
995 b.score
996 .partial_cmp(&a.score)
997 .unwrap_or(std::cmp::Ordering::Equal)
998 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
999 });
1000
1001 let mut entries = Vec::new();
1002
1003 if !self.open_folders.is_empty() {
1004 let matched_folders: Vec<_> = if is_empty_query {
1005 (0..self.open_folders.len())
1006 .map(|i| (i, Vec::new()))
1007 .collect()
1008 } else {
1009 folder_matches
1010 .iter()
1011 .map(|m| (m.candidate_id, m.positions.clone()))
1012 .collect()
1013 };
1014
1015 for (index, positions) in matched_folders {
1016 entries.push(ProjectPickerEntry::OpenFolder { index, positions });
1017 }
1018 }
1019
1020 let has_siblings_to_show = if is_empty_query {
1021 !sibling_candidates.is_empty()
1022 } else {
1023 !sibling_matches.is_empty()
1024 };
1025
1026 if has_siblings_to_show {
1027 entries.push(ProjectPickerEntry::Header("This Window".into()));
1028
1029 if is_empty_query {
1030 for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() {
1031 if self.is_sibling_workspace(*workspace_id, cx) {
1032 entries.push(ProjectPickerEntry::OpenProject(StringMatch {
1033 candidate_id: id,
1034 score: 0.0,
1035 positions: Vec::new(),
1036 string: String::new(),
1037 }));
1038 }
1039 }
1040 } else {
1041 for m in sibling_matches {
1042 entries.push(ProjectPickerEntry::OpenProject(m));
1043 }
1044 }
1045 }
1046
1047 let has_recent_to_show = if is_empty_query {
1048 !recent_candidates.is_empty()
1049 } else {
1050 !recent_matches.is_empty()
1051 };
1052
1053 if has_recent_to_show {
1054 entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
1055
1056 if is_empty_query {
1057 for (id, (workspace_id, _, paths, _)) in self.workspaces.iter().enumerate() {
1058 if self.is_valid_recent_candidate(*workspace_id, paths, cx) {
1059 entries.push(ProjectPickerEntry::RecentProject(StringMatch {
1060 candidate_id: id,
1061 score: 0.0,
1062 positions: Vec::new(),
1063 string: String::new(),
1064 }));
1065 }
1066 }
1067 } else {
1068 for m in recent_matches {
1069 entries.push(ProjectPickerEntry::RecentProject(m));
1070 }
1071 }
1072 }
1073
1074 self.filtered_entries = entries;
1075
1076 if self.reset_selected_match_index {
1077 self.selected_index = self
1078 .filtered_entries
1079 .iter()
1080 .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
1081 .unwrap_or(0);
1082 }
1083 self.reset_selected_match_index = true;
1084 Task::ready(())
1085 }
1086
1087 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
1088 match self.filtered_entries.get(self.selected_index) {
1089 Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
1090 let Some(folder) = self.open_folders.get(*index) else {
1091 return;
1092 };
1093 let worktree_id = folder.worktree_id;
1094 if let Some(workspace) = self.workspace.upgrade() {
1095 workspace.update(cx, |workspace, cx| {
1096 workspace.set_active_worktree_override(Some(worktree_id), cx);
1097 });
1098 }
1099 cx.emit(DismissEvent);
1100 }
1101 Some(ProjectPickerEntry::OpenProject(selected_match)) => {
1102 let Some((workspace_id, _, _, _)) =
1103 self.workspaces.get(selected_match.candidate_id)
1104 else {
1105 return;
1106 };
1107 let workspace_id = *workspace_id;
1108
1109 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1110 cx.defer(move |cx| {
1111 handle
1112 .update(cx, |multi_workspace, window, cx| {
1113 let workspace = multi_workspace
1114 .workspaces()
1115 .find(|ws| ws.read(cx).database_id() == Some(workspace_id))
1116 .cloned();
1117 if let Some(workspace) = workspace {
1118 multi_workspace.activate(workspace, window, cx);
1119 }
1120 })
1121 .log_err();
1122 });
1123 }
1124 cx.emit(DismissEvent);
1125 }
1126 Some(ProjectPickerEntry::RecentProject(selected_match)) => {
1127 let Some(workspace) = self.workspace.upgrade() else {
1128 return;
1129 };
1130 let Some((
1131 candidate_workspace_id,
1132 candidate_workspace_location,
1133 candidate_workspace_paths,
1134 _,
1135 )) = self.workspaces.get(selected_match.candidate_id)
1136 else {
1137 return;
1138 };
1139
1140 let replace_current_window = self.create_new_window == secondary;
1141 let candidate_workspace_id = *candidate_workspace_id;
1142 let candidate_workspace_location = candidate_workspace_location.clone();
1143 let candidate_workspace_paths = candidate_workspace_paths.clone();
1144
1145 workspace.update(cx, |workspace, cx| {
1146 if workspace.database_id() == Some(candidate_workspace_id) {
1147 return;
1148 }
1149 match candidate_workspace_location {
1150 SerializedWorkspaceLocation::Local => {
1151 let paths = candidate_workspace_paths.paths().to_vec();
1152 if replace_current_window {
1153 if let Some(handle) =
1154 window.window_handle().downcast::<MultiWorkspace>()
1155 {
1156 cx.defer(move |cx| {
1157 if let Some(task) = handle
1158 .update(cx, |multi_workspace, window, cx| {
1159 multi_workspace.open_project(
1160 paths,
1161 OpenMode::Activate,
1162 window,
1163 cx,
1164 )
1165 })
1166 .log_err()
1167 {
1168 task.detach_and_log_err(cx);
1169 }
1170 });
1171 }
1172 return;
1173 } else {
1174 workspace
1175 .open_workspace_for_paths(
1176 OpenMode::NewWindow,
1177 paths,
1178 window,
1179 cx,
1180 )
1181 .detach_and_prompt_err(
1182 "Failed to open project",
1183 window,
1184 cx,
1185 |_, _, _| None,
1186 );
1187 }
1188 }
1189 SerializedWorkspaceLocation::Remote(mut connection) => {
1190 let app_state = workspace.app_state().clone();
1191 let replace_window = if replace_current_window {
1192 window.window_handle().downcast::<MultiWorkspace>()
1193 } else {
1194 None
1195 };
1196 let open_options = OpenOptions {
1197 requesting_window: replace_window,
1198 ..Default::default()
1199 };
1200 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
1201 RemoteSettings::get_global(cx)
1202 .fill_connection_options_from_settings(connection);
1203 };
1204 let paths = candidate_workspace_paths.paths().to_vec();
1205 cx.spawn_in(window, async move |_, cx| {
1206 open_remote_project(
1207 connection.clone(),
1208 paths,
1209 app_state,
1210 open_options,
1211 cx,
1212 )
1213 .await
1214 })
1215 .detach_and_prompt_err(
1216 "Failed to open project",
1217 window,
1218 cx,
1219 |_, _, _| None,
1220 );
1221 }
1222 }
1223 });
1224 cx.emit(DismissEvent);
1225 }
1226 _ => {}
1227 }
1228 }
1229
1230 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
1231
1232 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1233 let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
1234 "Recently opened projects will show up here".into()
1235 } else {
1236 "No matches".into()
1237 };
1238 Some(text)
1239 }
1240
1241 fn render_match(
1242 &self,
1243 ix: usize,
1244 selected: bool,
1245 window: &mut Window,
1246 cx: &mut Context<Picker<Self>>,
1247 ) -> Option<Self::ListItem> {
1248 match self.filtered_entries.get(ix)? {
1249 ProjectPickerEntry::Header(title) => Some(
1250 v_flex()
1251 .w_full()
1252 .gap_1()
1253 .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1254 .child(ListSubHeader::new(title.clone()).inset(true))
1255 .into_any_element(),
1256 ),
1257 ProjectPickerEntry::OpenFolder { index, positions } => {
1258 let folder = self.open_folders.get(*index)?;
1259 let name = folder.name.clone();
1260 let path = folder.path.compact();
1261 let branch = folder.branch.clone();
1262 let is_active = folder.is_active;
1263 let worktree_id = folder.worktree_id;
1264 let positions = positions.clone();
1265 let show_path = self.style == ProjectPickerStyle::Modal;
1266
1267 let secondary_actions = h_flex()
1268 .gap_1()
1269 .child(
1270 IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1271 .icon_size(IconSize::Small)
1272 .tooltip(Tooltip::text("Remove Folder from Workspace"))
1273 .on_click(cx.listener(move |picker, _, window, cx| {
1274 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1275 return;
1276 };
1277 workspace.update(cx, |workspace, cx| {
1278 let project = workspace.project().clone();
1279 project.update(cx, |project, cx| {
1280 project.remove_worktree(worktree_id, cx);
1281 });
1282 });
1283 picker.delegate.open_folders =
1284 get_open_folders(workspace.read(cx), cx);
1285 let query = picker.query(cx);
1286 picker.update_matches(query, window, cx);
1287 })),
1288 )
1289 .into_any_element();
1290
1291 let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1292
1293 Some(
1294 ListItem::new(ix)
1295 .toggle_state(selected)
1296 .inset(true)
1297 .spacing(ListItemSpacing::Sparse)
1298 .child(
1299 h_flex()
1300 .id("open_folder_item")
1301 .gap_3()
1302 .flex_grow()
1303 .when(self.has_any_non_local_projects, |this| {
1304 this.child(Icon::new(icon).color(Color::Muted))
1305 })
1306 .child(
1307 v_flex()
1308 .child(
1309 h_flex()
1310 .gap_1()
1311 .child({
1312 let highlighted = HighlightedMatch {
1313 text: name.to_string(),
1314 highlight_positions: positions,
1315 color: Color::Default,
1316 };
1317 highlighted.render(window, cx)
1318 })
1319 .when_some(branch, |this, branch| {
1320 this.child(
1321 Label::new(branch).color(Color::Muted),
1322 )
1323 })
1324 .when(is_active, |this| {
1325 this.child(
1326 Icon::new(IconName::Check)
1327 .size(IconSize::Small)
1328 .color(Color::Accent),
1329 )
1330 }),
1331 )
1332 .when(show_path, |this| {
1333 this.child(
1334 Label::new(path.to_string_lossy().to_string())
1335 .size(LabelSize::Small)
1336 .color(Color::Muted),
1337 )
1338 }),
1339 )
1340 .when(!show_path, |this| {
1341 this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1342 }),
1343 )
1344 .end_slot(secondary_actions)
1345 .show_end_slot_on_hover()
1346 .into_any_element(),
1347 )
1348 }
1349 ProjectPickerEntry::OpenProject(hit) => {
1350 let (workspace_id, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1351 let workspace_id = *workspace_id;
1352 let ordered_paths: Vec<_> = paths
1353 .ordered_paths()
1354 .map(|p| p.compact().to_string_lossy().to_string())
1355 .collect();
1356 let tooltip_path: SharedString = match &location {
1357 SerializedWorkspaceLocation::Remote(options) => {
1358 let host = options.display_name();
1359 if ordered_paths.len() == 1 {
1360 format!("{} ({})", ordered_paths[0], host).into()
1361 } else {
1362 format!("{}\n({})", ordered_paths.join("\n"), host).into()
1363 }
1364 }
1365 _ => ordered_paths.join("\n").into(),
1366 };
1367
1368 let mut path_start_offset = 0;
1369 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1370 .ordered_paths()
1371 .map(|p| p.compact())
1372 .map(|path| {
1373 let highlighted_text =
1374 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1375 path_start_offset += highlighted_text.1.text.len();
1376 highlighted_text
1377 })
1378 .unzip();
1379
1380 let prefix = match &location {
1381 SerializedWorkspaceLocation::Remote(options) => {
1382 Some(SharedString::from(options.display_name()))
1383 }
1384 _ => None,
1385 };
1386
1387 let highlighted_match = HighlightedMatchWithPaths {
1388 prefix,
1389 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1390 paths,
1391 };
1392
1393 let icon = icon_for_remote_connection(match location {
1394 SerializedWorkspaceLocation::Local => None,
1395 SerializedWorkspaceLocation::Remote(options) => Some(options),
1396 });
1397
1398 let secondary_actions = h_flex()
1399 .gap_1()
1400 .child(
1401 IconButton::new("remove_open_project", IconName::Close)
1402 .icon_size(IconSize::Small)
1403 .tooltip(Tooltip::text("Remove Project from Window"))
1404 .on_click(cx.listener(move |picker, _, window, cx| {
1405 cx.stop_propagation();
1406 window.prevent_default();
1407 picker
1408 .delegate
1409 .remove_sibling_workspace(workspace_id, window, cx);
1410 let query = picker.query(cx);
1411 picker.update_matches(query, window, cx);
1412 })),
1413 )
1414 .into_any_element();
1415
1416 Some(
1417 ListItem::new(ix)
1418 .toggle_state(selected)
1419 .inset(true)
1420 .spacing(ListItemSpacing::Sparse)
1421 .child(
1422 h_flex()
1423 .id("open_project_info_container")
1424 .gap_3()
1425 .flex_grow()
1426 .when(self.has_any_non_local_projects, |this| {
1427 this.child(Icon::new(icon).color(Color::Muted))
1428 })
1429 .child({
1430 let mut highlighted = highlighted_match;
1431 if !self.render_paths {
1432 highlighted.paths.clear();
1433 }
1434 highlighted.render(window, cx)
1435 })
1436 .tooltip(Tooltip::text(tooltip_path)),
1437 )
1438 .end_slot(secondary_actions)
1439 .show_end_slot_on_hover()
1440 .into_any_element(),
1441 )
1442 }
1443 ProjectPickerEntry::RecentProject(hit) => {
1444 let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1445 let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1446 let paths_to_add = paths.paths().to_vec();
1447 let ordered_paths: Vec<_> = paths
1448 .ordered_paths()
1449 .map(|p| p.compact().to_string_lossy().to_string())
1450 .collect();
1451 let tooltip_path: SharedString = match &location {
1452 SerializedWorkspaceLocation::Remote(options) => {
1453 let host = options.display_name();
1454 if ordered_paths.len() == 1 {
1455 format!("{} ({})", ordered_paths[0], host).into()
1456 } else {
1457 format!("{}\n({})", ordered_paths.join("\n"), host).into()
1458 }
1459 }
1460 _ => ordered_paths.join("\n").into(),
1461 };
1462
1463 let mut path_start_offset = 0;
1464 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1465 .ordered_paths()
1466 .map(|p| p.compact())
1467 .map(|path| {
1468 let highlighted_text =
1469 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1470 path_start_offset += highlighted_text.1.text.len();
1471 highlighted_text
1472 })
1473 .unzip();
1474
1475 let prefix = match &location {
1476 SerializedWorkspaceLocation::Remote(options) => {
1477 Some(SharedString::from(options.display_name()))
1478 }
1479 _ => None,
1480 };
1481
1482 let highlighted_match = HighlightedMatchWithPaths {
1483 prefix,
1484 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1485 paths,
1486 };
1487
1488 let focus_handle = self.focus_handle.clone();
1489
1490 let secondary_actions = h_flex()
1491 .gap_px()
1492 .when(is_local, |this| {
1493 this.child(
1494 IconButton::new("add_to_workspace", IconName::FolderPlus)
1495 .icon_size(IconSize::Small)
1496 .tooltip(Tooltip::text("Add Project to this Workspace"))
1497 .on_click({
1498 let paths_to_add = paths_to_add.clone();
1499 cx.listener(move |picker, _event, window, cx| {
1500 cx.stop_propagation();
1501 window.prevent_default();
1502 picker.delegate.add_project_to_workspace(
1503 paths_to_add.clone(),
1504 window,
1505 cx,
1506 );
1507 })
1508 }),
1509 )
1510 })
1511 .child(
1512 IconButton::new("open_new_window", IconName::ArrowUpRight)
1513 .icon_size(IconSize::XSmall)
1514 .tooltip({
1515 move |_, cx| {
1516 Tooltip::for_action_in(
1517 "Open Project in New Window",
1518 &menu::SecondaryConfirm,
1519 &focus_handle,
1520 cx,
1521 )
1522 }
1523 })
1524 .on_click(cx.listener(move |this, _event, window, cx| {
1525 cx.stop_propagation();
1526 window.prevent_default();
1527 this.delegate.set_selected_index(ix, window, cx);
1528 this.delegate.confirm(true, window, cx);
1529 })),
1530 )
1531 .child(
1532 IconButton::new("delete", IconName::Close)
1533 .icon_size(IconSize::Small)
1534 .tooltip(Tooltip::text("Delete from Recent Projects"))
1535 .on_click(cx.listener(move |this, _event, window, cx| {
1536 cx.stop_propagation();
1537 window.prevent_default();
1538 this.delegate.delete_recent_project(ix, window, cx)
1539 })),
1540 )
1541 .into_any_element();
1542
1543 let icon = icon_for_remote_connection(match location {
1544 SerializedWorkspaceLocation::Local => None,
1545 SerializedWorkspaceLocation::Remote(options) => Some(options),
1546 });
1547
1548 Some(
1549 ListItem::new(ix)
1550 .toggle_state(selected)
1551 .inset(true)
1552 .spacing(ListItemSpacing::Sparse)
1553 .child(
1554 h_flex()
1555 .id("project_info_container")
1556 .gap_3()
1557 .flex_grow()
1558 .when(self.has_any_non_local_projects, |this| {
1559 this.child(Icon::new(icon).color(Color::Muted))
1560 })
1561 .child({
1562 let mut highlighted = highlighted_match;
1563 if !self.render_paths {
1564 highlighted.paths.clear();
1565 }
1566 highlighted.render(window, cx)
1567 })
1568 .tooltip(Tooltip::text(tooltip_path)),
1569 )
1570 .end_slot(secondary_actions)
1571 .show_end_slot_on_hover()
1572 .into_any_element(),
1573 )
1574 }
1575 }
1576 }
1577
1578 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1579 let focus_handle = self.focus_handle.clone();
1580 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1581 let is_already_open_entry = matches!(
1582 self.filtered_entries.get(self.selected_index),
1583 Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::OpenProject(_))
1584 );
1585
1586 if popover_style {
1587 return Some(
1588 v_flex()
1589 .flex_1()
1590 .p_1p5()
1591 .gap_1()
1592 .border_t_1()
1593 .border_color(cx.theme().colors().border_variant)
1594 .child({
1595 let open_action = workspace::Open::default();
1596 Button::new("open_local_folder", "Open Local Project")
1597 .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
1598 .on_click(move |_, window, cx| {
1599 window.dispatch_action(open_action.boxed_clone(), cx)
1600 })
1601 })
1602 .child(
1603 Button::new("open_remote_folder", "Open Remote Project")
1604 .key_binding(KeyBinding::for_action(
1605 &OpenRemote {
1606 from_existing_connection: false,
1607 create_new_window: false,
1608 },
1609 cx,
1610 ))
1611 .on_click(|_, window, cx| {
1612 window.dispatch_action(
1613 OpenRemote {
1614 from_existing_connection: false,
1615 create_new_window: false,
1616 }
1617 .boxed_clone(),
1618 cx,
1619 )
1620 }),
1621 )
1622 .into_any(),
1623 );
1624 }
1625
1626 let selected_entry = self.filtered_entries.get(self.selected_index);
1627
1628 let secondary_footer_actions: Option<AnyElement> = match selected_entry {
1629 Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::OpenProject(_)) => {
1630 let label = if matches!(selected_entry, Some(ProjectPickerEntry::OpenFolder { .. }))
1631 {
1632 "Remove Folder"
1633 } else {
1634 "Remove from Window"
1635 };
1636 Some(
1637 Button::new("remove_selected", label)
1638 .key_binding(KeyBinding::for_action_in(
1639 &RemoveSelected,
1640 &focus_handle,
1641 cx,
1642 ))
1643 .on_click(|_, window, cx| {
1644 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1645 })
1646 .into_any_element(),
1647 )
1648 }
1649 Some(ProjectPickerEntry::RecentProject(_)) => Some(
1650 Button::new("delete_recent", "Delete")
1651 .key_binding(KeyBinding::for_action_in(
1652 &RemoveSelected,
1653 &focus_handle,
1654 cx,
1655 ))
1656 .on_click(|_, window, cx| {
1657 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1658 })
1659 .into_any_element(),
1660 ),
1661 _ => None,
1662 };
1663
1664 Some(
1665 h_flex()
1666 .flex_1()
1667 .p_1p5()
1668 .gap_1()
1669 .justify_end()
1670 .border_t_1()
1671 .border_color(cx.theme().colors().border_variant)
1672 .when_some(secondary_footer_actions, |this, actions| {
1673 this.child(actions)
1674 })
1675 .map(|this| {
1676 if is_already_open_entry {
1677 this.child(
1678 Button::new("activate", "Activate")
1679 .key_binding(KeyBinding::for_action_in(
1680 &menu::Confirm,
1681 &focus_handle,
1682 cx,
1683 ))
1684 .on_click(|_, window, cx| {
1685 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1686 }),
1687 )
1688 } else {
1689 this.child(
1690 Button::new("open_new_window", "New Window")
1691 .key_binding(KeyBinding::for_action_in(
1692 &menu::SecondaryConfirm,
1693 &focus_handle,
1694 cx,
1695 ))
1696 .on_click(|_, window, cx| {
1697 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1698 }),
1699 )
1700 .child(
1701 Button::new("open_here", "Open")
1702 .key_binding(KeyBinding::for_action_in(
1703 &menu::Confirm,
1704 &focus_handle,
1705 cx,
1706 ))
1707 .on_click(|_, window, cx| {
1708 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1709 }),
1710 )
1711 }
1712 })
1713 .child(Divider::vertical())
1714 .child(
1715 PopoverMenu::new("actions-menu-popover")
1716 .with_handle(self.actions_menu_handle.clone())
1717 .anchor(gpui::Corner::BottomRight)
1718 .offset(gpui::Point {
1719 x: px(0.0),
1720 y: px(-2.0),
1721 })
1722 .trigger(
1723 Button::new("actions-trigger", "Actions")
1724 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1725 .key_binding(KeyBinding::for_action_in(
1726 &ToggleActionsMenu,
1727 &focus_handle,
1728 cx,
1729 )),
1730 )
1731 .menu({
1732 let focus_handle = focus_handle.clone();
1733 let show_add_to_workspace = match selected_entry {
1734 Some(ProjectPickerEntry::RecentProject(hit)) => self
1735 .workspaces
1736 .get(hit.candidate_id)
1737 .map(|(_, loc, ..)| {
1738 matches!(loc, SerializedWorkspaceLocation::Local)
1739 })
1740 .unwrap_or(false),
1741 _ => false,
1742 };
1743
1744 move |window, cx| {
1745 Some(ContextMenu::build(window, cx, {
1746 let focus_handle = focus_handle.clone();
1747 move |menu, _, _| {
1748 menu.context(focus_handle)
1749 .when(show_add_to_workspace, |menu| {
1750 menu.action(
1751 "Add to Workspace",
1752 AddToWorkspace.boxed_clone(),
1753 )
1754 .separator()
1755 })
1756 .action(
1757 "Open Local Project",
1758 workspace::Open::default().boxed_clone(),
1759 )
1760 .action(
1761 "Open Remote Project",
1762 OpenRemote {
1763 from_existing_connection: false,
1764 create_new_window: false,
1765 }
1766 .boxed_clone(),
1767 )
1768 }
1769 }))
1770 }
1771 }),
1772 )
1773 .into_any(),
1774 )
1775 }
1776}
1777
1778pub(crate) fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1779 match options {
1780 None => IconName::Screen,
1781 Some(options) => match options {
1782 RemoteConnectionOptions::Ssh(_) => IconName::Server,
1783 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1784 RemoteConnectionOptions::Docker(_) => IconName::Box,
1785 #[cfg(any(test, feature = "test-support"))]
1786 RemoteConnectionOptions::Mock(_) => IconName::Server,
1787 },
1788 }
1789}
1790
1791// Compute the highlighted text for the name and path
1792pub(crate) fn highlights_for_path(
1793 path: &Path,
1794 match_positions: &Vec<usize>,
1795 path_start_offset: usize,
1796) -> (Option<HighlightedMatch>, HighlightedMatch) {
1797 let path_string = path.to_string_lossy();
1798 let path_text = path_string.to_string();
1799 let path_byte_len = path_text.len();
1800 // Get the subset of match highlight positions that line up with the given path.
1801 // Also adjusts them to start at the path start
1802 let path_positions = match_positions
1803 .iter()
1804 .copied()
1805 .skip_while(|position| *position < path_start_offset)
1806 .take_while(|position| *position < path_start_offset + path_byte_len)
1807 .map(|position| position - path_start_offset)
1808 .collect::<Vec<_>>();
1809
1810 // Again subset the highlight positions to just those that line up with the file_name
1811 // again adjusted to the start of the file_name
1812 let file_name_text_and_positions = path.file_name().map(|file_name| {
1813 let file_name_text = file_name.to_string_lossy().into_owned();
1814 let file_name_start_byte = path_byte_len - file_name_text.len();
1815 let highlight_positions = path_positions
1816 .iter()
1817 .copied()
1818 .skip_while(|position| *position < file_name_start_byte)
1819 .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1820 .map(|position| position - file_name_start_byte)
1821 .collect::<Vec<_>>();
1822 HighlightedMatch {
1823 text: file_name_text,
1824 highlight_positions,
1825 color: Color::Default,
1826 }
1827 });
1828
1829 (
1830 file_name_text_and_positions,
1831 HighlightedMatch {
1832 text: path_text,
1833 highlight_positions: path_positions,
1834 color: Color::Default,
1835 },
1836 )
1837}
1838impl RecentProjectsDelegate {
1839 fn add_project_to_workspace(
1840 &mut self,
1841 paths: Vec<PathBuf>,
1842 window: &mut Window,
1843 cx: &mut Context<Picker<Self>>,
1844 ) {
1845 let Some(workspace) = self.workspace.upgrade() else {
1846 return;
1847 };
1848 let open_paths_task = workspace.update(cx, |workspace, cx| {
1849 workspace.open_paths(
1850 paths,
1851 OpenOptions {
1852 visible: Some(OpenVisible::All),
1853 ..Default::default()
1854 },
1855 None,
1856 window,
1857 cx,
1858 )
1859 });
1860 cx.spawn_in(window, async move |picker, cx| {
1861 let _result = open_paths_task.await;
1862 picker
1863 .update_in(cx, |picker, window, cx| {
1864 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1865 return;
1866 };
1867 picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
1868 let query = picker.query(cx);
1869 picker.update_matches(query, window, cx);
1870 })
1871 .ok();
1872 })
1873 .detach();
1874 }
1875
1876 fn delete_recent_project(
1877 &self,
1878 ix: usize,
1879 window: &mut Window,
1880 cx: &mut Context<Picker<Self>>,
1881 ) {
1882 if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
1883 self.filtered_entries.get(ix)
1884 {
1885 let (workspace_id, _, _, _) = &self.workspaces[selected_match.candidate_id];
1886 let workspace_id = *workspace_id;
1887 let fs = self
1888 .workspace
1889 .upgrade()
1890 .map(|ws| ws.read(cx).app_state().fs.clone());
1891 let db = WorkspaceDb::global(cx);
1892 cx.spawn_in(window, async move |this, cx| {
1893 db.delete_workspace_by_id(workspace_id).await.log_err();
1894 let Some(fs) = fs else { return };
1895 let workspaces = db
1896 .recent_workspaces_on_disk(fs.as_ref())
1897 .await
1898 .unwrap_or_default();
1899 let workspaces =
1900 workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
1901 this.update_in(cx, move |picker, window, cx| {
1902 picker.delegate.set_workspaces(workspaces);
1903 picker
1904 .delegate
1905 .set_selected_index(ix.saturating_sub(1), window, cx);
1906 picker.delegate.reset_selected_match_index = false;
1907 picker.update_matches(picker.query(cx), window, cx);
1908 // After deleting a project, we want to update the history manager to reflect the change.
1909 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1910 if let Some(history_manager) = HistoryManager::global(cx) {
1911 history_manager
1912 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1913 }
1914 })
1915 .ok();
1916 })
1917 .detach();
1918 }
1919 }
1920
1921 fn remove_sibling_workspace(
1922 &mut self,
1923 workspace_id: WorkspaceId,
1924 window: &mut Window,
1925 cx: &mut Context<Picker<Self>>,
1926 ) {
1927 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1928 cx.defer(move |cx| {
1929 handle
1930 .update(cx, |multi_workspace, window, cx| {
1931 let workspace = multi_workspace
1932 .workspaces()
1933 .find(|ws| ws.read(cx).database_id() == Some(workspace_id))
1934 .cloned();
1935 if let Some(workspace) = workspace {
1936 multi_workspace.remove(&workspace, window, cx);
1937 }
1938 })
1939 .log_err();
1940 });
1941 }
1942
1943 self.sibling_workspace_ids.remove(&workspace_id);
1944 }
1945
1946 fn is_current_workspace(
1947 &self,
1948 workspace_id: WorkspaceId,
1949 cx: &mut Context<Picker<Self>>,
1950 ) -> bool {
1951 if let Some(workspace) = self.workspace.upgrade() {
1952 let workspace = workspace.read(cx);
1953 if Some(workspace_id) == workspace.database_id() {
1954 return true;
1955 }
1956 }
1957
1958 false
1959 }
1960
1961 fn is_sibling_workspace(
1962 &self,
1963 workspace_id: WorkspaceId,
1964 cx: &mut Context<Picker<Self>>,
1965 ) -> bool {
1966 self.sibling_workspace_ids.contains(&workspace_id)
1967 && !self.is_current_workspace(workspace_id, cx)
1968 }
1969
1970 fn is_open_folder(&self, paths: &PathList) -> bool {
1971 if self.open_folders.is_empty() {
1972 return false;
1973 }
1974
1975 for workspace_path in paths.paths() {
1976 for open_folder in &self.open_folders {
1977 if workspace_path == &open_folder.path {
1978 return true;
1979 }
1980 }
1981 }
1982
1983 false
1984 }
1985
1986 fn is_valid_recent_candidate(
1987 &self,
1988 workspace_id: WorkspaceId,
1989 paths: &PathList,
1990 cx: &mut Context<Picker<Self>>,
1991 ) -> bool {
1992 !self.is_current_workspace(workspace_id, cx)
1993 && !self.is_sibling_workspace(workspace_id, cx)
1994 && !self.is_open_folder(paths)
1995 }
1996}
1997
1998#[cfg(test)]
1999mod tests {
2000 use std::path::PathBuf;
2001
2002 use editor::Editor;
2003 use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle};
2004
2005 use serde_json::json;
2006 use settings::SettingsStore;
2007 use util::path;
2008 use workspace::{AppState, open_paths};
2009
2010 use super::*;
2011
2012 #[gpui::test]
2013 async fn test_dirty_workspace_replaced_when_opening_recent_project(cx: &mut TestAppContext) {
2014 let app_state = init_test(cx);
2015
2016 cx.update(|cx| {
2017 SettingsStore::update_global(cx, |store, cx| {
2018 store.update_user_settings(cx, |settings| {
2019 settings
2020 .session
2021 .get_or_insert_default()
2022 .restore_unsaved_buffers = Some(false)
2023 });
2024 });
2025 });
2026
2027 app_state
2028 .fs
2029 .as_fake()
2030 .insert_tree(
2031 path!("/dir"),
2032 json!({
2033 "main.ts": "a"
2034 }),
2035 )
2036 .await;
2037 app_state
2038 .fs
2039 .as_fake()
2040 .insert_tree(path!("/test/path"), json!({}))
2041 .await;
2042 cx.update(|cx| {
2043 open_paths(
2044 &[PathBuf::from(path!("/dir/main.ts"))],
2045 app_state,
2046 workspace::OpenOptions::default(),
2047 cx,
2048 )
2049 })
2050 .await
2051 .unwrap();
2052 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2053
2054 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2055 multi_workspace
2056 .update(cx, |multi_workspace, _, cx| {
2057 multi_workspace.open_sidebar(cx);
2058 })
2059 .unwrap();
2060 multi_workspace
2061 .update(cx, |multi_workspace, _, cx| {
2062 assert!(!multi_workspace.workspace().read(cx).is_edited())
2063 })
2064 .unwrap();
2065
2066 let editor = multi_workspace
2067 .read_with(cx, |multi_workspace, cx| {
2068 multi_workspace
2069 .workspace()
2070 .read(cx)
2071 .active_item(cx)
2072 .unwrap()
2073 .downcast::<Editor>()
2074 .unwrap()
2075 })
2076 .unwrap();
2077 multi_workspace
2078 .update(cx, |_, window, cx| {
2079 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
2080 })
2081 .unwrap();
2082 multi_workspace
2083 .update(cx, |multi_workspace, _, cx| {
2084 assert!(
2085 multi_workspace.workspace().read(cx).is_edited(),
2086 "After inserting more text into the editor without saving, we should have a dirty project"
2087 )
2088 })
2089 .unwrap();
2090
2091 let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
2092 multi_workspace
2093 .update(cx, |_, _, cx| {
2094 recent_projects_picker.update(cx, |picker, cx| {
2095 assert_eq!(picker.query(cx), "");
2096 let delegate = &mut picker.delegate;
2097 delegate.set_workspaces(vec![(
2098 WorkspaceId::default(),
2099 SerializedWorkspaceLocation::Local,
2100 PathList::new(&[path!("/test/path")]),
2101 Utc::now(),
2102 )]);
2103 delegate.filtered_entries =
2104 vec![ProjectPickerEntry::RecentProject(StringMatch {
2105 candidate_id: 0,
2106 score: 1.0,
2107 positions: Vec::new(),
2108 string: "fake candidate".to_string(),
2109 })];
2110 });
2111 })
2112 .unwrap();
2113
2114 assert!(
2115 !cx.has_pending_prompt(),
2116 "Should have no pending prompt on dirty project before opening the new recent project"
2117 );
2118 let dirty_workspace = multi_workspace
2119 .read_with(cx, |multi_workspace, _cx| {
2120 multi_workspace.workspace().clone()
2121 })
2122 .unwrap();
2123
2124 cx.dispatch_action(*multi_workspace, menu::Confirm);
2125 cx.run_until_parked();
2126
2127 // In multi-workspace mode, the dirty workspace is kept and a new one is
2128 // opened alongside it — no save prompt needed.
2129 assert!(
2130 !cx.has_pending_prompt(),
2131 "Should not prompt in multi-workspace mode — dirty workspace is kept"
2132 );
2133
2134 multi_workspace
2135 .update(cx, |multi_workspace, _, cx| {
2136 assert!(
2137 multi_workspace
2138 .workspace()
2139 .read(cx)
2140 .active_modal::<RecentProjects>(cx)
2141 .is_none(),
2142 "Should remove the modal after selecting new recent project"
2143 );
2144
2145 assert!(
2146 multi_workspace.workspaces().any(|w| w == &dirty_workspace),
2147 "The dirty workspace should still be present in multi-workspace mode"
2148 );
2149
2150 assert!(
2151 !multi_workspace.workspace().read(cx).is_edited(),
2152 "The active workspace should be the freshly opened one, not dirty"
2153 );
2154 })
2155 .unwrap();
2156 }
2157
2158 fn open_recent_projects(
2159 multi_workspace: &WindowHandle<MultiWorkspace>,
2160 cx: &mut TestAppContext,
2161 ) -> Entity<Picker<RecentProjectsDelegate>> {
2162 cx.dispatch_action(
2163 (*multi_workspace).into(),
2164 OpenRecent {
2165 create_new_window: false,
2166 },
2167 );
2168 multi_workspace
2169 .update(cx, |multi_workspace, _, cx| {
2170 multi_workspace
2171 .workspace()
2172 .read(cx)
2173 .active_modal::<RecentProjects>(cx)
2174 .unwrap()
2175 .read(cx)
2176 .picker
2177 .clone()
2178 })
2179 .unwrap()
2180 }
2181
2182 #[gpui::test]
2183 async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
2184 let app_state = init_test(cx);
2185
2186 app_state
2187 .fs
2188 .as_fake()
2189 .insert_tree(
2190 path!("/project"),
2191 json!({
2192 ".devcontainer": {
2193 "devcontainer.json": "{}"
2194 },
2195 "src": {
2196 "main.rs": "fn main() {}"
2197 }
2198 }),
2199 )
2200 .await;
2201
2202 // Open a file path (not a directory) so that the worktree root is a
2203 // file. This means `active_project_directory` returns `None`, which
2204 // causes `DevContainerContext::from_workspace` to return `None`,
2205 // preventing `open_dev_container` from spawning real I/O (docker
2206 // commands, shell environment loading) that is incompatible with the
2207 // test scheduler. The modal is still created and the re-entrancy
2208 // guard that this test validates is still exercised.
2209 cx.update(|cx| {
2210 open_paths(
2211 &[PathBuf::from(path!("/project/src/main.rs"))],
2212 app_state,
2213 workspace::OpenOptions::default(),
2214 cx,
2215 )
2216 })
2217 .await
2218 .unwrap();
2219
2220 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2221 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2222
2223 cx.run_until_parked();
2224
2225 // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
2226 // -> Workspace::update -> toggle_modal -> new_dev_container.
2227 // Before the fix, this panicked with "cannot read workspace::Workspace while
2228 // it is already being updated" because new_dev_container and open_dev_container
2229 // tried to read the Workspace entity through a WeakEntity handle while it was
2230 // already leased by the outer update.
2231 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2232
2233 multi_workspace
2234 .update(cx, |multi_workspace, _, cx| {
2235 let modal = multi_workspace
2236 .workspace()
2237 .read(cx)
2238 .active_modal::<RemoteServerProjects>(cx);
2239 assert!(
2240 modal.is_some(),
2241 "Dev container modal should be open after dispatching OpenDevContainer"
2242 );
2243 })
2244 .unwrap();
2245 }
2246
2247 #[gpui::test]
2248 async fn test_dev_container_modal_not_dismissed_on_backdrop_click(cx: &mut TestAppContext) {
2249 let app_state = init_test(cx);
2250
2251 app_state
2252 .fs
2253 .as_fake()
2254 .insert_tree(
2255 path!("/project"),
2256 json!({
2257 ".devcontainer": {
2258 "devcontainer.json": "{}"
2259 },
2260 "src": {
2261 "main.rs": "fn main() {}"
2262 }
2263 }),
2264 )
2265 .await;
2266
2267 cx.update(|cx| {
2268 open_paths(
2269 &[PathBuf::from(path!("/project"))],
2270 app_state,
2271 workspace::OpenOptions::default(),
2272 cx,
2273 )
2274 })
2275 .await
2276 .unwrap();
2277
2278 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2279 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2280
2281 cx.run_until_parked();
2282
2283 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2284
2285 multi_workspace
2286 .update(cx, |multi_workspace, _, cx| {
2287 assert!(
2288 multi_workspace
2289 .active_modal::<RemoteServerProjects>(cx)
2290 .is_some(),
2291 "Dev container modal should be open"
2292 );
2293 })
2294 .unwrap();
2295
2296 // Click outside the modal (on the backdrop) to try to dismiss it
2297 let mut vcx = VisualTestContext::from_window(*multi_workspace, cx);
2298 vcx.simulate_click(gpui::point(px(1.0), px(1.0)), gpui::Modifiers::default());
2299
2300 multi_workspace
2301 .update(cx, |multi_workspace, _, cx| {
2302 assert!(
2303 multi_workspace
2304 .active_modal::<RemoteServerProjects>(cx)
2305 .is_some(),
2306 "Dev container modal should remain open during creation"
2307 );
2308 })
2309 .unwrap();
2310 }
2311
2312 #[gpui::test]
2313 async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
2314 let app_state = init_test(cx);
2315
2316 app_state
2317 .fs
2318 .as_fake()
2319 .insert_tree(
2320 path!("/project"),
2321 json!({
2322 ".devcontainer": {
2323 "rust": {
2324 "devcontainer.json": "{}"
2325 },
2326 "python": {
2327 "devcontainer.json": "{}"
2328 }
2329 },
2330 "src": {
2331 "main.rs": "fn main() {}"
2332 }
2333 }),
2334 )
2335 .await;
2336
2337 cx.update(|cx| {
2338 open_paths(
2339 &[PathBuf::from(path!("/project"))],
2340 app_state,
2341 workspace::OpenOptions::default(),
2342 cx,
2343 )
2344 })
2345 .await
2346 .unwrap();
2347
2348 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2349 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2350
2351 cx.run_until_parked();
2352
2353 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2354
2355 multi_workspace
2356 .update(cx, |multi_workspace, _, cx| {
2357 let modal = multi_workspace
2358 .workspace()
2359 .read(cx)
2360 .active_modal::<RemoteServerProjects>(cx);
2361 assert!(
2362 modal.is_some(),
2363 "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
2364 );
2365 })
2366 .unwrap();
2367 }
2368
2369 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2370 cx.update(|cx| {
2371 let state = AppState::test(cx);
2372 crate::init(cx);
2373 editor::init(cx);
2374 state
2375 })
2376 }
2377}