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