1mod dev_container_suggest;
2pub mod disconnected_overlay;
3mod remote_connections;
4mod remote_servers;
5mod ssh_config;
6
7use std::{
8 path::{Path, PathBuf},
9 sync::Arc,
10};
11
12use fs::Fs;
13
14#[cfg(target_os = "windows")]
15mod wsl_picker;
16
17use remote::RemoteConnectionOptions;
18pub use remote_connection::{RemoteConnectionModal, connect};
19pub use remote_connections::open_remote_project;
20
21use disconnected_overlay::DisconnectedOverlay;
22use fuzzy::{StringMatch, StringMatchCandidate};
23use gpui::{
24 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
25 Subscription, Task, WeakEntity, Window, actions, px,
26};
27
28use picker::{
29 Picker, PickerDelegate,
30 highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
31};
32use project::{Worktree, git_store::Repository};
33pub use remote_connections::RemoteSettings;
34pub use remote_servers::RemoteServerProjects;
35use settings::{Settings, WorktreeId};
36
37use dev_container::{DevContainerContext, find_devcontainer_configs};
38use ui::{
39 ContextMenu, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, PopoverMenu,
40 PopoverMenuHandle, TintColor, Tooltip, prelude::*,
41};
42use util::{ResultExt, paths::PathExt};
43use workspace::{
44 HistoryManager, ModalView, MultiWorkspace, OpenOptions, PathList, SerializedWorkspaceLocation,
45 WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr,
46 with_active_or_new_workspace,
47};
48use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
49
50actions!(recent_projects, [ToggleActionsMenu]);
51
52#[derive(Clone, Debug)]
53pub struct RecentProjectEntry {
54 pub name: SharedString,
55 pub full_path: SharedString,
56 pub paths: Vec<PathBuf>,
57 pub workspace_id: WorkspaceId,
58}
59
60#[derive(Clone, Debug)]
61struct OpenFolderEntry {
62 worktree_id: WorktreeId,
63 name: SharedString,
64 path: PathBuf,
65 branch: Option<SharedString>,
66 is_active: bool,
67}
68
69#[derive(Clone, Debug)]
70enum ProjectPickerEntry {
71 Header(SharedString),
72 OpenFolder { index: usize, positions: Vec<usize> },
73 RecentProject(StringMatch),
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum ProjectPickerStyle {
78 Modal,
79 Popover,
80}
81
82pub async fn get_recent_projects(
83 current_workspace_id: Option<WorkspaceId>,
84 limit: Option<usize>,
85 fs: Arc<dyn fs::Fs>,
86) -> Vec<RecentProjectEntry> {
87 let workspaces = WORKSPACE_DB
88 .recent_workspaces_on_disk(fs.as_ref())
89 .await
90 .unwrap_or_default();
91
92 let entries: Vec<RecentProjectEntry> = workspaces
93 .into_iter()
94 .filter(|(id, _, _)| Some(*id) != current_workspace_id)
95 .filter(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local))
96 .map(|(workspace_id, _, path_list)| {
97 let paths: Vec<PathBuf> = path_list.paths().to_vec();
98 let ordered_paths: Vec<&PathBuf> = path_list.ordered_paths().collect();
99
100 let name = if ordered_paths.len() == 1 {
101 ordered_paths[0]
102 .file_name()
103 .map(|n| n.to_string_lossy().to_string())
104 .unwrap_or_else(|| ordered_paths[0].to_string_lossy().to_string())
105 } else {
106 ordered_paths
107 .iter()
108 .filter_map(|p| p.file_name())
109 .map(|n| n.to_string_lossy().to_string())
110 .collect::<Vec<_>>()
111 .join(", ")
112 };
113
114 let full_path = ordered_paths
115 .iter()
116 .map(|p| p.to_string_lossy().to_string())
117 .collect::<Vec<_>>()
118 .join("\n");
119
120 RecentProjectEntry {
121 name: SharedString::from(name),
122 full_path: SharedString::from(full_path),
123 paths,
124 workspace_id,
125 }
126 })
127 .collect();
128
129 match limit {
130 Some(n) => entries.into_iter().take(n).collect(),
131 None => entries,
132 }
133}
134
135pub async fn delete_recent_project(workspace_id: WorkspaceId) {
136 let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
137}
138
139fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec<OpenFolderEntry> {
140 let project = workspace.project().read(cx);
141 let visible_worktrees: Vec<_> = project.visible_worktrees(cx).collect();
142
143 if visible_worktrees.len() <= 1 {
144 return Vec::new();
145 }
146
147 let active_worktree_id = workspace.active_worktree_override().or_else(|| {
148 if let Some(repo) = project.active_repository(cx) {
149 let repo = repo.read(cx);
150 let repo_path = &repo.work_directory_abs_path;
151 for worktree in project.visible_worktrees(cx) {
152 let worktree_path = worktree.read(cx).abs_path();
153 if worktree_path == *repo_path || worktree_path.starts_with(repo_path.as_ref()) {
154 return Some(worktree.read(cx).id());
155 }
156 }
157 }
158 project
159 .visible_worktrees(cx)
160 .next()
161 .map(|wt| wt.read(cx).id())
162 });
163
164 let git_store = project.git_store().read(cx);
165 let repositories: Vec<_> = git_store.repositories().values().cloned().collect();
166
167 let mut entries: Vec<OpenFolderEntry> = visible_worktrees
168 .into_iter()
169 .map(|worktree| {
170 let worktree_ref = worktree.read(cx);
171 let worktree_id = worktree_ref.id();
172 let name = SharedString::from(worktree_ref.root_name().as_unix_str().to_string());
173 let path = worktree_ref.abs_path().to_path_buf();
174 let branch = get_branch_for_worktree(worktree_ref, &repositories, cx);
175 let is_active = active_worktree_id == Some(worktree_id);
176 OpenFolderEntry {
177 worktree_id,
178 name,
179 path,
180 branch,
181 is_active,
182 }
183 })
184 .collect();
185
186 entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
187 entries
188}
189
190fn get_branch_for_worktree(
191 worktree: &Worktree,
192 repositories: &[Entity<Repository>],
193 cx: &App,
194) -> Option<SharedString> {
195 let worktree_abs_path = worktree.abs_path();
196 for repo in repositories {
197 let repo = repo.read(cx);
198 if repo.work_directory_abs_path == worktree_abs_path
199 || worktree_abs_path.starts_with(&*repo.work_directory_abs_path)
200 {
201 if let Some(branch) = &repo.branch {
202 return Some(SharedString::from(branch.name().to_string()));
203 }
204 }
205 }
206 None
207}
208
209pub fn init(cx: &mut App) {
210 #[cfg(target_os = "windows")]
211 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {
212 let create_new_window = open_wsl.create_new_window;
213 with_active_or_new_workspace(cx, move |workspace, window, cx| {
214 use gpui::PathPromptOptions;
215 use project::DirectoryLister;
216
217 let paths = workspace.prompt_for_open_path(
218 PathPromptOptions {
219 files: true,
220 directories: true,
221 multiple: false,
222 prompt: None,
223 },
224 DirectoryLister::Local(
225 workspace.project().clone(),
226 workspace.app_state().fs.clone(),
227 ),
228 window,
229 cx,
230 );
231
232 cx.spawn_in(window, async move |workspace, cx| {
233 use util::paths::SanitizedPath;
234
235 let Some(paths) = paths.await.log_err().flatten() else {
236 return;
237 };
238
239 let paths = paths
240 .into_iter()
241 .filter_map(|path| SanitizedPath::new(&path).local_to_wsl())
242 .collect::<Vec<_>>();
243
244 if paths.is_empty() {
245 let message = indoc::indoc! { r#"
246 Invalid path specified when trying to open a folder inside WSL.
247
248 Please note that Zed currently does not support opening network share folders inside wsl.
249 "#};
250
251 let _ = cx.prompt(gpui::PromptLevel::Critical, "Invalid path", Some(&message), &["Ok"]).await;
252 return;
253 }
254
255 workspace.update_in(cx, |workspace, window, cx| {
256 workspace.toggle_modal(window, cx, |window, cx| {
257 crate::wsl_picker::WslOpenModal::new(paths, create_new_window, window, cx)
258 });
259 }).log_err();
260 })
261 .detach();
262 });
263 });
264
265 #[cfg(target_os = "windows")]
266 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenWsl, cx| {
267 let create_new_window = open_wsl.create_new_window;
268 with_active_or_new_workspace(cx, move |workspace, window, cx| {
269 let handle = cx.entity().downgrade();
270 let fs = workspace.project().read(cx).fs().clone();
271 workspace.toggle_modal(window, cx, |window, cx| {
272 RemoteServerProjects::wsl(create_new_window, fs, window, handle, cx)
273 });
274 });
275 });
276
277 #[cfg(target_os = "windows")]
278 cx.on_action(|open_wsl: &remote::OpenWslPath, cx| {
279 let open_wsl = open_wsl.clone();
280 with_active_or_new_workspace(cx, move |workspace, window, cx| {
281 let fs = workspace.project().read(cx).fs().clone();
282 add_wsl_distro(fs, &open_wsl.distro, cx);
283 let open_options = OpenOptions {
284 replace_window: window.window_handle().downcast::<MultiWorkspace>(),
285 ..Default::default()
286 };
287
288 let app_state = workspace.app_state().clone();
289
290 cx.spawn_in(window, async move |_, cx| {
291 open_remote_project(
292 RemoteConnectionOptions::Wsl(open_wsl.distro.clone()),
293 open_wsl.paths,
294 app_state,
295 open_options,
296 cx,
297 )
298 .await
299 })
300 .detach();
301 });
302 });
303
304 cx.on_action(|open_recent: &OpenRecent, cx| {
305 let create_new_window = open_recent.create_new_window;
306 with_active_or_new_workspace(cx, move |workspace, window, cx| {
307 let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
308 let focus_handle = workspace.focus_handle(cx);
309 RecentProjects::open(workspace, create_new_window, window, focus_handle, cx);
310 return;
311 };
312
313 recent_projects.update(cx, |recent_projects, cx| {
314 recent_projects
315 .picker
316 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
317 });
318 });
319 });
320 cx.on_action(|open_remote: &OpenRemote, cx| {
321 let from_existing_connection = open_remote.from_existing_connection;
322 let create_new_window = open_remote.create_new_window;
323 with_active_or_new_workspace(cx, move |workspace, window, cx| {
324 if from_existing_connection {
325 cx.propagate();
326 return;
327 }
328 let handle = cx.entity().downgrade();
329 let fs = workspace.project().read(cx).fs().clone();
330 workspace.toggle_modal(window, cx, |window, cx| {
331 RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
332 })
333 });
334 });
335
336 cx.observe_new(DisconnectedOverlay::register).detach();
337
338 cx.on_action(|_: &OpenDevContainer, cx| {
339 with_active_or_new_workspace(cx, move |workspace, window, cx| {
340 if !workspace.project().read(cx).is_local() {
341 cx.spawn_in(window, async move |_, cx| {
342 cx.prompt(
343 gpui::PromptLevel::Critical,
344 "Cannot open Dev Container from remote project",
345 None,
346 &["Ok"],
347 )
348 .await
349 .ok();
350 })
351 .detach();
352 return;
353 }
354
355 let fs = workspace.project().read(cx).fs().clone();
356 let configs = find_devcontainer_configs(workspace, cx);
357 let app_state = workspace.app_state().clone();
358 let dev_container_context = DevContainerContext::from_workspace(workspace, cx);
359 let handle = cx.entity().downgrade();
360 workspace.toggle_modal(window, cx, |window, cx| {
361 RemoteServerProjects::new_dev_container(
362 fs,
363 configs,
364 app_state,
365 dev_container_context,
366 window,
367 handle,
368 cx,
369 )
370 });
371 });
372 });
373
374 // Subscribe to worktree additions to suggest opening the project in a dev container
375 cx.observe_new(
376 |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context<Workspace>| {
377 let Some(window) = window else {
378 return;
379 };
380 cx.subscribe_in(
381 workspace.project(),
382 window,
383 move |_, project, event, window, cx| {
384 if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
385 event
386 {
387 dev_container_suggest::suggest_on_worktree_updated(
388 *worktree_id,
389 updated_entries,
390 project,
391 window,
392 cx,
393 );
394 }
395 },
396 )
397 .detach();
398 },
399 )
400 .detach();
401}
402
403#[cfg(target_os = "windows")]
404pub fn add_wsl_distro(
405 fs: Arc<dyn project::Fs>,
406 connection_options: &remote::WslConnectionOptions,
407 cx: &App,
408) {
409 use gpui::ReadGlobal;
410 use settings::SettingsStore;
411
412 let distro_name = connection_options.distro_name.clone();
413 let user = connection_options.user.clone();
414 SettingsStore::global(cx).update_settings_file(fs, move |setting, _| {
415 let connections = setting
416 .remote
417 .wsl_connections
418 .get_or_insert(Default::default());
419
420 if !connections
421 .iter()
422 .any(|conn| conn.distro_name == distro_name && conn.user == user)
423 {
424 use std::collections::BTreeSet;
425
426 connections.push(settings::WslConnection {
427 distro_name,
428 user,
429 projects: BTreeSet::new(),
430 })
431 }
432 });
433}
434
435pub struct RecentProjects {
436 pub picker: Entity<Picker<RecentProjectsDelegate>>,
437 rem_width: f32,
438 _subscription: Subscription,
439}
440
441impl ModalView for RecentProjects {
442 fn on_before_dismiss(
443 &mut self,
444 window: &mut Window,
445 cx: &mut Context<Self>,
446 ) -> workspace::DismissDecision {
447 let submenu_focused = self.picker.update(cx, |picker, cx| {
448 picker.delegate.actions_menu_handle.is_focused(window, cx)
449 });
450 workspace::DismissDecision::Dismiss(!submenu_focused)
451 }
452}
453
454impl RecentProjects {
455 fn new(
456 delegate: RecentProjectsDelegate,
457 fs: Option<Arc<dyn Fs>>,
458 rem_width: f32,
459 window: &mut Window,
460 cx: &mut Context<Self>,
461 ) -> Self {
462 let picker = cx.new(|cx| {
463 Picker::list(delegate, window, cx)
464 .list_measure_all()
465 .show_scrollbar(true)
466 });
467
468 let picker_focus_handle = picker.focus_handle(cx);
469 picker.update(cx, |picker, _| {
470 picker.delegate.focus_handle = picker_focus_handle;
471 });
472
473 let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
474 // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
475 // out workspace locations once the future runs to completion.
476 cx.spawn_in(window, async move |this, cx| {
477 let Some(fs) = fs else { return };
478 let workspaces = WORKSPACE_DB
479 .recent_workspaces_on_disk(fs.as_ref())
480 .await
481 .log_err()
482 .unwrap_or_default();
483 this.update_in(cx, move |this, window, cx| {
484 this.picker.update(cx, move |picker, cx| {
485 picker.delegate.set_workspaces(workspaces);
486 picker.update_matches(picker.query(cx), window, cx)
487 })
488 })
489 .ok();
490 })
491 .detach();
492 Self {
493 picker,
494 rem_width,
495 _subscription,
496 }
497 }
498
499 pub fn open(
500 workspace: &mut Workspace,
501 create_new_window: bool,
502 window: &mut Window,
503 focus_handle: FocusHandle,
504 cx: &mut Context<Workspace>,
505 ) {
506 let weak = cx.entity().downgrade();
507 let open_folders = get_open_folders(workspace, cx);
508 let project_connection_options = workspace.project().read(cx).remote_connection_options(cx);
509 let fs = Some(workspace.app_state().fs.clone());
510 workspace.toggle_modal(window, cx, |window, cx| {
511 let delegate = RecentProjectsDelegate::new(
512 weak,
513 create_new_window,
514 focus_handle,
515 open_folders,
516 project_connection_options,
517 ProjectPickerStyle::Modal,
518 );
519
520 Self::new(delegate, fs, 34., window, cx)
521 })
522 }
523
524 pub fn popover(
525 workspace: WeakEntity<Workspace>,
526 create_new_window: bool,
527 focus_handle: FocusHandle,
528 window: &mut Window,
529 cx: &mut App,
530 ) -> Entity<Self> {
531 let (open_folders, project_connection_options, fs) = workspace
532 .upgrade()
533 .map(|workspace| {
534 let workspace = workspace.read(cx);
535 (
536 get_open_folders(workspace, cx),
537 workspace.project().read(cx).remote_connection_options(cx),
538 Some(workspace.app_state().fs.clone()),
539 )
540 })
541 .unwrap_or_else(|| (Vec::new(), None, None));
542
543 cx.new(|cx| {
544 let delegate = RecentProjectsDelegate::new(
545 workspace,
546 create_new_window,
547 focus_handle,
548 open_folders,
549 project_connection_options,
550 ProjectPickerStyle::Popover,
551 );
552 let list = Self::new(delegate, fs, 20., window, cx);
553 list.picker.focus_handle(cx).focus(window, cx);
554 list
555 })
556 }
557
558 fn handle_toggle_open_menu(
559 &mut self,
560 _: &ToggleActionsMenu,
561 window: &mut Window,
562 cx: &mut Context<Self>,
563 ) {
564 self.picker.update(cx, |picker, cx| {
565 let menu_handle = &picker.delegate.actions_menu_handle;
566 if menu_handle.is_deployed() {
567 menu_handle.hide(cx);
568 } else {
569 menu_handle.show(window, cx);
570 }
571 });
572 }
573}
574
575impl EventEmitter<DismissEvent> for RecentProjects {}
576
577impl Focusable for RecentProjects {
578 fn focus_handle(&self, cx: &App) -> FocusHandle {
579 self.picker.focus_handle(cx)
580 }
581}
582
583impl Render for RecentProjects {
584 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
585 v_flex()
586 .key_context("RecentProjects")
587 .on_action(cx.listener(Self::handle_toggle_open_menu))
588 .w(rems(self.rem_width))
589 .child(self.picker.clone())
590 }
591}
592
593pub struct RecentProjectsDelegate {
594 workspace: WeakEntity<Workspace>,
595 open_folders: Vec<OpenFolderEntry>,
596 workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
597 filtered_entries: Vec<ProjectPickerEntry>,
598 selected_index: usize,
599 render_paths: bool,
600 create_new_window: bool,
601 // Flag to reset index when there is a new query vs not reset index when user delete an item
602 reset_selected_match_index: bool,
603 has_any_non_local_projects: bool,
604 project_connection_options: Option<RemoteConnectionOptions>,
605 focus_handle: FocusHandle,
606 style: ProjectPickerStyle,
607 actions_menu_handle: PopoverMenuHandle<ContextMenu>,
608}
609
610impl RecentProjectsDelegate {
611 fn new(
612 workspace: WeakEntity<Workspace>,
613 create_new_window: bool,
614 focus_handle: FocusHandle,
615 open_folders: Vec<OpenFolderEntry>,
616 project_connection_options: Option<RemoteConnectionOptions>,
617 style: ProjectPickerStyle,
618 ) -> Self {
619 let render_paths = style == ProjectPickerStyle::Modal;
620 Self {
621 workspace,
622 open_folders,
623 workspaces: Vec::new(),
624 filtered_entries: Vec::new(),
625 selected_index: 0,
626 create_new_window,
627 render_paths,
628 reset_selected_match_index: true,
629 has_any_non_local_projects: project_connection_options.is_some(),
630 project_connection_options,
631 focus_handle,
632 style,
633 actions_menu_handle: PopoverMenuHandle::default(),
634 }
635 }
636
637 pub fn set_workspaces(
638 &mut self,
639 workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
640 ) {
641 self.workspaces = workspaces;
642 let has_non_local_recent = !self
643 .workspaces
644 .iter()
645 .all(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local));
646 self.has_any_non_local_projects =
647 self.project_connection_options.is_some() || has_non_local_recent;
648 }
649}
650impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
651impl PickerDelegate for RecentProjectsDelegate {
652 type ListItem = AnyElement;
653
654 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
655 "Search projects…".into()
656 }
657
658 fn match_count(&self) -> usize {
659 self.filtered_entries.len()
660 }
661
662 fn selected_index(&self) -> usize {
663 self.selected_index
664 }
665
666 fn set_selected_index(
667 &mut self,
668 ix: usize,
669 _window: &mut Window,
670 _cx: &mut Context<Picker<Self>>,
671 ) {
672 self.selected_index = ix;
673 }
674
675 fn can_select(
676 &mut self,
677 ix: usize,
678 _window: &mut Window,
679 _cx: &mut Context<Picker<Self>>,
680 ) -> bool {
681 matches!(
682 self.filtered_entries.get(ix),
683 Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::RecentProject(_))
684 )
685 }
686
687 fn update_matches(
688 &mut self,
689 query: String,
690 _: &mut Window,
691 cx: &mut Context<Picker<Self>>,
692 ) -> gpui::Task<()> {
693 let query = query.trim_start();
694 let smart_case = query.chars().any(|c| c.is_uppercase());
695 let is_empty_query = query.is_empty();
696
697 let folder_matches = if self.open_folders.is_empty() {
698 Vec::new()
699 } else {
700 let candidates: Vec<_> = self
701 .open_folders
702 .iter()
703 .enumerate()
704 .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref()))
705 .collect();
706
707 smol::block_on(fuzzy::match_strings(
708 &candidates,
709 query,
710 smart_case,
711 true,
712 100,
713 &Default::default(),
714 cx.background_executor().clone(),
715 ))
716 };
717
718 let recent_candidates: Vec<_> = self
719 .workspaces
720 .iter()
721 .enumerate()
722 .filter(|(_, (id, _, paths))| self.is_valid_recent_candidate(*id, paths, cx))
723 .map(|(id, (_, _, paths))| {
724 let combined_string = paths
725 .ordered_paths()
726 .map(|path| path.compact().to_string_lossy().into_owned())
727 .collect::<Vec<_>>()
728 .join("");
729 StringMatchCandidate::new(id, &combined_string)
730 })
731 .collect();
732
733 let mut recent_matches = smol::block_on(fuzzy::match_strings(
734 &recent_candidates,
735 query,
736 smart_case,
737 true,
738 100,
739 &Default::default(),
740 cx.background_executor().clone(),
741 ));
742 recent_matches.sort_unstable_by(|a, b| {
743 b.score
744 .partial_cmp(&a.score)
745 .unwrap_or(std::cmp::Ordering::Equal)
746 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
747 });
748
749 let mut entries = Vec::new();
750
751 if !self.open_folders.is_empty() {
752 let matched_folders: Vec<_> = if is_empty_query {
753 (0..self.open_folders.len())
754 .map(|i| (i, Vec::new()))
755 .collect()
756 } else {
757 folder_matches
758 .iter()
759 .map(|m| (m.candidate_id, m.positions.clone()))
760 .collect()
761 };
762
763 for (index, positions) in matched_folders {
764 entries.push(ProjectPickerEntry::OpenFolder { index, positions });
765 }
766 }
767
768 let has_recent_to_show = if is_empty_query {
769 !recent_candidates.is_empty()
770 } else {
771 !recent_matches.is_empty()
772 };
773
774 if has_recent_to_show {
775 entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
776
777 if is_empty_query {
778 for (id, (workspace_id, _, paths)) in self.workspaces.iter().enumerate() {
779 if self.is_valid_recent_candidate(*workspace_id, paths, cx) {
780 entries.push(ProjectPickerEntry::RecentProject(StringMatch {
781 candidate_id: id,
782 score: 0.0,
783 positions: Vec::new(),
784 string: String::new(),
785 }));
786 }
787 }
788 } else {
789 for m in recent_matches {
790 entries.push(ProjectPickerEntry::RecentProject(m));
791 }
792 }
793 }
794
795 self.filtered_entries = entries;
796
797 if self.reset_selected_match_index {
798 self.selected_index = self
799 .filtered_entries
800 .iter()
801 .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
802 .unwrap_or(0);
803 }
804 self.reset_selected_match_index = true;
805 Task::ready(())
806 }
807
808 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
809 match self.filtered_entries.get(self.selected_index) {
810 Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
811 let Some(folder) = self.open_folders.get(*index) else {
812 return;
813 };
814 let worktree_id = folder.worktree_id;
815 if let Some(workspace) = self.workspace.upgrade() {
816 workspace.update(cx, |workspace, cx| {
817 workspace.set_active_worktree_override(Some(worktree_id), cx);
818 });
819 }
820 cx.emit(DismissEvent);
821 }
822 Some(ProjectPickerEntry::RecentProject(selected_match)) => {
823 let Some(workspace) = self.workspace.upgrade() else {
824 return;
825 };
826 let Some((
827 candidate_workspace_id,
828 candidate_workspace_location,
829 candidate_workspace_paths,
830 )) = self.workspaces.get(selected_match.candidate_id)
831 else {
832 return;
833 };
834
835 let replace_current_window = self.create_new_window == secondary;
836 let candidate_workspace_id = *candidate_workspace_id;
837 let candidate_workspace_location = candidate_workspace_location.clone();
838 let candidate_workspace_paths = candidate_workspace_paths.clone();
839
840 workspace.update(cx, |workspace, cx| {
841 if workspace.database_id() == Some(candidate_workspace_id) {
842 return;
843 }
844 match candidate_workspace_location {
845 SerializedWorkspaceLocation::Local => {
846 let paths = candidate_workspace_paths.paths().to_vec();
847 if replace_current_window {
848 if let Some(handle) =
849 window.window_handle().downcast::<MultiWorkspace>()
850 {
851 cx.defer(move |cx| {
852 if let Some(task) = handle
853 .update(cx, |multi_workspace, window, cx| {
854 multi_workspace.open_project(paths, window, cx)
855 })
856 .log_err()
857 {
858 task.detach_and_log_err(cx);
859 }
860 });
861 }
862 return;
863 } else {
864 workspace.open_workspace_for_paths(false, paths, window, cx)
865 }
866 }
867 SerializedWorkspaceLocation::Remote(mut connection) => {
868 let app_state = workspace.app_state().clone();
869 let replace_window = if replace_current_window {
870 window.window_handle().downcast::<MultiWorkspace>()
871 } else {
872 None
873 };
874 let open_options = OpenOptions {
875 replace_window,
876 ..Default::default()
877 };
878 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
879 RemoteSettings::get_global(cx)
880 .fill_connection_options_from_settings(connection);
881 };
882 let paths = candidate_workspace_paths.paths().to_vec();
883 cx.spawn_in(window, async move |_, cx| {
884 open_remote_project(
885 connection.clone(),
886 paths,
887 app_state,
888 open_options,
889 cx,
890 )
891 .await
892 })
893 }
894 }
895 .detach_and_prompt_err(
896 "Failed to open project",
897 window,
898 cx,
899 |_, _, _| None,
900 );
901 });
902 cx.emit(DismissEvent);
903 }
904 _ => {}
905 }
906 }
907
908 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
909
910 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
911 let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
912 "Recently opened projects will show up here".into()
913 } else {
914 "No matches".into()
915 };
916 Some(text)
917 }
918
919 fn render_match(
920 &self,
921 ix: usize,
922 selected: bool,
923 window: &mut Window,
924 cx: &mut Context<Picker<Self>>,
925 ) -> Option<Self::ListItem> {
926 match self.filtered_entries.get(ix)? {
927 ProjectPickerEntry::Header(title) => Some(
928 v_flex()
929 .w_full()
930 .gap_1()
931 .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
932 .child(ListSubHeader::new(title.clone()).inset(true))
933 .into_any_element(),
934 ),
935 ProjectPickerEntry::OpenFolder { index, positions } => {
936 let folder = self.open_folders.get(*index)?;
937 let name = folder.name.clone();
938 let path = folder.path.compact();
939 let branch = folder.branch.clone();
940 let is_active = folder.is_active;
941 let worktree_id = folder.worktree_id;
942 let positions = positions.clone();
943 let show_path = self.style == ProjectPickerStyle::Modal;
944
945 let secondary_actions = h_flex()
946 .gap_1()
947 .child(
948 IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
949 .icon_size(IconSize::Small)
950 .tooltip(Tooltip::text("Remove Folder from Workspace"))
951 .on_click(cx.listener(move |picker, _, window, cx| {
952 let Some(workspace) = picker.delegate.workspace.upgrade() else {
953 return;
954 };
955 workspace.update(cx, |workspace, cx| {
956 let project = workspace.project().clone();
957 project.update(cx, |project, cx| {
958 project.remove_worktree(worktree_id, cx);
959 });
960 });
961 picker.delegate.open_folders =
962 get_open_folders(workspace.read(cx), cx);
963 let query = picker.query(cx);
964 picker.update_matches(query, window, cx);
965 })),
966 )
967 .into_any_element();
968
969 let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
970
971 Some(
972 ListItem::new(ix)
973 .toggle_state(selected)
974 .inset(true)
975 .spacing(ListItemSpacing::Sparse)
976 .child(
977 h_flex()
978 .id("open_folder_item")
979 .gap_3()
980 .flex_grow()
981 .when(self.has_any_non_local_projects, |this| {
982 this.child(Icon::new(icon).color(Color::Muted))
983 })
984 .child(
985 v_flex()
986 .child(
987 h_flex()
988 .gap_1()
989 .child({
990 let highlighted = HighlightedMatch {
991 text: name.to_string(),
992 highlight_positions: positions,
993 color: Color::Default,
994 };
995 highlighted.render(window, cx)
996 })
997 .when_some(branch, |this, branch| {
998 this.child(
999 Label::new(branch).color(Color::Muted),
1000 )
1001 })
1002 .when(is_active, |this| {
1003 this.child(
1004 Icon::new(IconName::Check)
1005 .size(IconSize::Small)
1006 .color(Color::Accent),
1007 )
1008 }),
1009 )
1010 .when(show_path, |this| {
1011 this.child(
1012 Label::new(path.to_string_lossy().to_string())
1013 .size(LabelSize::Small)
1014 .color(Color::Muted),
1015 )
1016 }),
1017 )
1018 .when(!show_path, |this| {
1019 this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1020 }),
1021 )
1022 .map(|el| {
1023 if self.selected_index == ix {
1024 el.end_slot(secondary_actions)
1025 } else {
1026 el.end_hover_slot(secondary_actions)
1027 }
1028 })
1029 .into_any_element(),
1030 )
1031 }
1032 ProjectPickerEntry::RecentProject(hit) => {
1033 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1034 let (_, location, paths) = self.workspaces.get(hit.candidate_id)?;
1035 let tooltip_path: SharedString = paths
1036 .ordered_paths()
1037 .map(|p| p.compact().to_string_lossy().to_string())
1038 .collect::<Vec<_>>()
1039 .join("\n")
1040 .into();
1041
1042 let mut path_start_offset = 0;
1043 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1044 .ordered_paths()
1045 .map(|p| p.compact())
1046 .map(|path| {
1047 let highlighted_text =
1048 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1049 path_start_offset += highlighted_text.1.text.len();
1050 highlighted_text
1051 })
1052 .unzip();
1053
1054 let prefix = match &location {
1055 SerializedWorkspaceLocation::Remote(options) => {
1056 Some(SharedString::from(options.display_name()))
1057 }
1058 _ => None,
1059 };
1060
1061 let highlighted_match = HighlightedMatchWithPaths {
1062 prefix,
1063 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1064 paths,
1065 };
1066
1067 let focus_handle = self.focus_handle.clone();
1068
1069 let secondary_actions = h_flex()
1070 .gap_px()
1071 .when(popover_style, |this| {
1072 this.child(
1073 IconButton::new("open_new_window", IconName::ArrowUpRight)
1074 .icon_size(IconSize::XSmall)
1075 .tooltip({
1076 move |_, cx| {
1077 Tooltip::for_action_in(
1078 "Open Project in New Window",
1079 &menu::SecondaryConfirm,
1080 &focus_handle,
1081 cx,
1082 )
1083 }
1084 })
1085 .on_click(cx.listener(move |this, _event, window, cx| {
1086 cx.stop_propagation();
1087 window.prevent_default();
1088 this.delegate.set_selected_index(ix, window, cx);
1089 this.delegate.confirm(true, window, cx);
1090 })),
1091 )
1092 })
1093 .child(
1094 IconButton::new("delete", IconName::Close)
1095 .icon_size(IconSize::Small)
1096 .tooltip(Tooltip::text("Delete from Recent Projects"))
1097 .on_click(cx.listener(move |this, _event, window, cx| {
1098 cx.stop_propagation();
1099 window.prevent_default();
1100 this.delegate.delete_recent_project(ix, window, cx)
1101 })),
1102 )
1103 .into_any_element();
1104
1105 let icon = icon_for_remote_connection(match location {
1106 SerializedWorkspaceLocation::Local => None,
1107 SerializedWorkspaceLocation::Remote(options) => Some(options),
1108 });
1109
1110 Some(
1111 ListItem::new(ix)
1112 .toggle_state(selected)
1113 .inset(true)
1114 .spacing(ListItemSpacing::Sparse)
1115 .child(
1116 h_flex()
1117 .id("project_info_container")
1118 .gap_3()
1119 .flex_grow()
1120 .when(self.has_any_non_local_projects, |this| {
1121 this.child(Icon::new(icon).color(Color::Muted))
1122 })
1123 .child({
1124 let mut highlighted = highlighted_match;
1125 if !self.render_paths {
1126 highlighted.paths.clear();
1127 }
1128 highlighted.render(window, cx)
1129 })
1130 .tooltip(Tooltip::text(tooltip_path)),
1131 )
1132 .map(|el| {
1133 if self.selected_index == ix {
1134 el.end_slot(secondary_actions)
1135 } else {
1136 el.end_hover_slot(secondary_actions)
1137 }
1138 })
1139 .into_any_element(),
1140 )
1141 }
1142 }
1143 }
1144
1145 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1146 let focus_handle = self.focus_handle.clone();
1147 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1148 let open_folder_section = matches!(
1149 self.filtered_entries.get(self.selected_index)?,
1150 ProjectPickerEntry::OpenFolder { .. }
1151 );
1152
1153 if popover_style {
1154 return Some(
1155 v_flex()
1156 .flex_1()
1157 .p_1p5()
1158 .gap_1()
1159 .border_t_1()
1160 .border_color(cx.theme().colors().border_variant)
1161 .child(
1162 Button::new("add_folder", "Add Project to Workspace")
1163 .key_binding(KeyBinding::for_action_in(
1164 &workspace::AddFolderToProject,
1165 &focus_handle,
1166 cx,
1167 ))
1168 .on_click(|_, window, cx| {
1169 window.dispatch_action(
1170 workspace::AddFolderToProject.boxed_clone(),
1171 cx,
1172 )
1173 }),
1174 )
1175 .child(
1176 Button::new("open_local_folder", "Open Local Project")
1177 .key_binding(KeyBinding::for_action_in(
1178 &workspace::Open,
1179 &focus_handle,
1180 cx,
1181 ))
1182 .on_click(|_, window, cx| {
1183 window.dispatch_action(workspace::Open.boxed_clone(), cx)
1184 }),
1185 )
1186 .child(
1187 Button::new("open_remote_folder", "Open Remote Project")
1188 .key_binding(KeyBinding::for_action(
1189 &OpenRemote {
1190 from_existing_connection: false,
1191 create_new_window: false,
1192 },
1193 cx,
1194 ))
1195 .on_click(|_, window, cx| {
1196 window.dispatch_action(
1197 OpenRemote {
1198 from_existing_connection: false,
1199 create_new_window: false,
1200 }
1201 .boxed_clone(),
1202 cx,
1203 )
1204 }),
1205 )
1206 .into_any(),
1207 );
1208 }
1209
1210 Some(
1211 h_flex()
1212 .flex_1()
1213 .p_1p5()
1214 .gap_1()
1215 .justify_end()
1216 .border_t_1()
1217 .border_color(cx.theme().colors().border_variant)
1218 .map(|this| {
1219 if open_folder_section {
1220 this.child(
1221 Button::new("activate", "Activate")
1222 .key_binding(KeyBinding::for_action_in(
1223 &menu::Confirm,
1224 &focus_handle,
1225 cx,
1226 ))
1227 .on_click(|_, window, cx| {
1228 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1229 }),
1230 )
1231 } else {
1232 this.child(
1233 Button::new("open_new_window", "New Window")
1234 .key_binding(KeyBinding::for_action_in(
1235 &menu::SecondaryConfirm,
1236 &focus_handle,
1237 cx,
1238 ))
1239 .on_click(|_, window, cx| {
1240 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1241 }),
1242 )
1243 .child(
1244 Button::new("open_here", "Open")
1245 .key_binding(KeyBinding::for_action_in(
1246 &menu::Confirm,
1247 &focus_handle,
1248 cx,
1249 ))
1250 .on_click(|_, window, cx| {
1251 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1252 }),
1253 )
1254 }
1255 })
1256 .child(Divider::vertical())
1257 .child(
1258 PopoverMenu::new("actions-menu-popover")
1259 .with_handle(self.actions_menu_handle.clone())
1260 .anchor(gpui::Corner::BottomRight)
1261 .offset(gpui::Point {
1262 x: px(0.0),
1263 y: px(-2.0),
1264 })
1265 .trigger(
1266 Button::new("actions-trigger", "Actions…")
1267 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1268 .key_binding(KeyBinding::for_action_in(
1269 &ToggleActionsMenu,
1270 &focus_handle,
1271 cx,
1272 )),
1273 )
1274 .menu({
1275 let focus_handle = focus_handle.clone();
1276
1277 move |window, cx| {
1278 Some(ContextMenu::build(window, cx, {
1279 let focus_handle = focus_handle.clone();
1280 move |menu, _, _| {
1281 menu.context(focus_handle)
1282 .action(
1283 "Open Local Project",
1284 workspace::Open.boxed_clone(),
1285 )
1286 .action(
1287 "Open Remote Project",
1288 OpenRemote {
1289 from_existing_connection: false,
1290 create_new_window: false,
1291 }
1292 .boxed_clone(),
1293 )
1294 .action(
1295 "Add Project to Workspace",
1296 workspace::AddFolderToProject.boxed_clone(),
1297 )
1298 }
1299 }))
1300 }
1301 }),
1302 )
1303 .into_any(),
1304 )
1305 }
1306}
1307
1308fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1309 match options {
1310 None => IconName::Screen,
1311 Some(options) => match options {
1312 RemoteConnectionOptions::Ssh(_) => IconName::Server,
1313 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1314 RemoteConnectionOptions::Docker(_) => IconName::Box,
1315 #[cfg(any(test, feature = "test-support"))]
1316 RemoteConnectionOptions::Mock(_) => IconName::Server,
1317 },
1318 }
1319}
1320
1321// Compute the highlighted text for the name and path
1322fn highlights_for_path(
1323 path: &Path,
1324 match_positions: &Vec<usize>,
1325 path_start_offset: usize,
1326) -> (Option<HighlightedMatch>, HighlightedMatch) {
1327 let path_string = path.to_string_lossy();
1328 let path_text = path_string.to_string();
1329 let path_byte_len = path_text.len();
1330 // Get the subset of match highlight positions that line up with the given path.
1331 // Also adjusts them to start at the path start
1332 let path_positions = match_positions
1333 .iter()
1334 .copied()
1335 .skip_while(|position| *position < path_start_offset)
1336 .take_while(|position| *position < path_start_offset + path_byte_len)
1337 .map(|position| position - path_start_offset)
1338 .collect::<Vec<_>>();
1339
1340 // Again subset the highlight positions to just those that line up with the file_name
1341 // again adjusted to the start of the file_name
1342 let file_name_text_and_positions = path.file_name().map(|file_name| {
1343 let file_name_text = file_name.to_string_lossy().into_owned();
1344 let file_name_start_byte = path_byte_len - file_name_text.len();
1345 let highlight_positions = path_positions
1346 .iter()
1347 .copied()
1348 .skip_while(|position| *position < file_name_start_byte)
1349 .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1350 .map(|position| position - file_name_start_byte)
1351 .collect::<Vec<_>>();
1352 HighlightedMatch {
1353 text: file_name_text,
1354 highlight_positions,
1355 color: Color::Default,
1356 }
1357 });
1358
1359 (
1360 file_name_text_and_positions,
1361 HighlightedMatch {
1362 text: path_text,
1363 highlight_positions: path_positions,
1364 color: Color::Default,
1365 },
1366 )
1367}
1368impl RecentProjectsDelegate {
1369 fn delete_recent_project(
1370 &self,
1371 ix: usize,
1372 window: &mut Window,
1373 cx: &mut Context<Picker<Self>>,
1374 ) {
1375 if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
1376 self.filtered_entries.get(ix)
1377 {
1378 let (workspace_id, _, _) = &self.workspaces[selected_match.candidate_id];
1379 let workspace_id = *workspace_id;
1380 let fs = self
1381 .workspace
1382 .upgrade()
1383 .map(|ws| ws.read(cx).app_state().fs.clone());
1384 cx.spawn_in(window, async move |this, cx| {
1385 WORKSPACE_DB
1386 .delete_workspace_by_id(workspace_id)
1387 .await
1388 .log_err();
1389 let Some(fs) = fs else { return };
1390 let workspaces = WORKSPACE_DB
1391 .recent_workspaces_on_disk(fs.as_ref())
1392 .await
1393 .unwrap_or_default();
1394 this.update_in(cx, move |picker, window, cx| {
1395 picker.delegate.set_workspaces(workspaces);
1396 picker
1397 .delegate
1398 .set_selected_index(ix.saturating_sub(1), window, cx);
1399 picker.delegate.reset_selected_match_index = false;
1400 picker.update_matches(picker.query(cx), window, cx);
1401 // After deleting a project, we want to update the history manager to reflect the change.
1402 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1403 if let Some(history_manager) = HistoryManager::global(cx) {
1404 history_manager
1405 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1406 }
1407 })
1408 .ok();
1409 })
1410 .detach();
1411 }
1412 }
1413
1414 fn is_current_workspace(
1415 &self,
1416 workspace_id: WorkspaceId,
1417 cx: &mut Context<Picker<Self>>,
1418 ) -> bool {
1419 if let Some(workspace) = self.workspace.upgrade() {
1420 let workspace = workspace.read(cx);
1421 if Some(workspace_id) == workspace.database_id() {
1422 return true;
1423 }
1424 }
1425
1426 false
1427 }
1428
1429 fn is_open_folder(&self, paths: &PathList) -> bool {
1430 if self.open_folders.is_empty() {
1431 return false;
1432 }
1433
1434 for workspace_path in paths.paths() {
1435 for open_folder in &self.open_folders {
1436 if workspace_path == &open_folder.path {
1437 return true;
1438 }
1439 }
1440 }
1441
1442 false
1443 }
1444
1445 fn is_valid_recent_candidate(
1446 &self,
1447 workspace_id: WorkspaceId,
1448 paths: &PathList,
1449 cx: &mut Context<Picker<Self>>,
1450 ) -> bool {
1451 !self.is_current_workspace(workspace_id, cx) && !self.is_open_folder(paths)
1452 }
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457 use std::path::PathBuf;
1458
1459 use editor::Editor;
1460 use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
1461
1462 use serde_json::json;
1463 use settings::SettingsStore;
1464 use util::path;
1465 use workspace::{AppState, open_paths};
1466
1467 use super::*;
1468
1469 #[gpui::test]
1470 async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) {
1471 let app_state = init_test(cx);
1472
1473 cx.update(|cx| {
1474 SettingsStore::update_global(cx, |store, cx| {
1475 store.update_user_settings(cx, |settings| {
1476 settings
1477 .session
1478 .get_or_insert_default()
1479 .restore_unsaved_buffers = Some(false)
1480 });
1481 });
1482 });
1483
1484 app_state
1485 .fs
1486 .as_fake()
1487 .insert_tree(
1488 path!("/dir"),
1489 json!({
1490 "main.ts": "a"
1491 }),
1492 )
1493 .await;
1494 app_state
1495 .fs
1496 .as_fake()
1497 .insert_tree(path!("/test/path"), json!({}))
1498 .await;
1499 cx.update(|cx| {
1500 open_paths(
1501 &[PathBuf::from(path!("/dir/main.ts"))],
1502 app_state,
1503 workspace::OpenOptions::default(),
1504 cx,
1505 )
1506 })
1507 .await
1508 .unwrap();
1509 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1510
1511 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1512 multi_workspace
1513 .update(cx, |multi_workspace, _, cx| {
1514 assert!(!multi_workspace.workspace().read(cx).is_edited())
1515 })
1516 .unwrap();
1517
1518 let editor = multi_workspace
1519 .read_with(cx, |multi_workspace, cx| {
1520 multi_workspace
1521 .workspace()
1522 .read(cx)
1523 .active_item(cx)
1524 .unwrap()
1525 .downcast::<Editor>()
1526 .unwrap()
1527 })
1528 .unwrap();
1529 multi_workspace
1530 .update(cx, |_, window, cx| {
1531 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
1532 })
1533 .unwrap();
1534 multi_workspace
1535 .update(cx, |multi_workspace, _, cx| {
1536 assert!(
1537 multi_workspace.workspace().read(cx).is_edited(),
1538 "After inserting more text into the editor without saving, we should have a dirty project"
1539 )
1540 })
1541 .unwrap();
1542
1543 let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
1544 multi_workspace
1545 .update(cx, |_, _, cx| {
1546 recent_projects_picker.update(cx, |picker, cx| {
1547 assert_eq!(picker.query(cx), "");
1548 let delegate = &mut picker.delegate;
1549 delegate.set_workspaces(vec![(
1550 WorkspaceId::default(),
1551 SerializedWorkspaceLocation::Local,
1552 PathList::new(&[path!("/test/path")]),
1553 )]);
1554 delegate.filtered_entries =
1555 vec![ProjectPickerEntry::RecentProject(StringMatch {
1556 candidate_id: 0,
1557 score: 1.0,
1558 positions: Vec::new(),
1559 string: "fake candidate".to_string(),
1560 })];
1561 });
1562 })
1563 .unwrap();
1564
1565 assert!(
1566 !cx.has_pending_prompt(),
1567 "Should have no pending prompt on dirty project before opening the new recent project"
1568 );
1569 let dirty_workspace = multi_workspace
1570 .read_with(cx, |multi_workspace, _cx| {
1571 multi_workspace.workspace().clone()
1572 })
1573 .unwrap();
1574
1575 cx.dispatch_action(*multi_workspace, menu::Confirm);
1576 cx.run_until_parked();
1577
1578 multi_workspace
1579 .update(cx, |multi_workspace, _, cx| {
1580 assert!(
1581 multi_workspace
1582 .workspace()
1583 .read(cx)
1584 .active_modal::<RecentProjects>(cx)
1585 .is_none(),
1586 "Should remove the modal after selecting new recent project"
1587 );
1588
1589 assert!(
1590 multi_workspace.workspaces().len() >= 2,
1591 "Should have at least 2 workspaces: the dirty one and the newly opened one"
1592 );
1593
1594 assert!(
1595 multi_workspace.workspaces().contains(&dirty_workspace),
1596 "The original dirty workspace should still be present"
1597 );
1598
1599 assert!(
1600 dirty_workspace.read(cx).is_edited(),
1601 "The original workspace should still be dirty"
1602 );
1603 })
1604 .unwrap();
1605
1606 assert!(
1607 !cx.has_pending_prompt(),
1608 "No save prompt in multi-workspace mode — dirty workspace survives in background"
1609 );
1610 }
1611
1612 fn open_recent_projects(
1613 multi_workspace: &WindowHandle<MultiWorkspace>,
1614 cx: &mut TestAppContext,
1615 ) -> Entity<Picker<RecentProjectsDelegate>> {
1616 cx.dispatch_action(
1617 (*multi_workspace).into(),
1618 OpenRecent {
1619 create_new_window: false,
1620 },
1621 );
1622 multi_workspace
1623 .update(cx, |multi_workspace, _, cx| {
1624 multi_workspace
1625 .workspace()
1626 .read(cx)
1627 .active_modal::<RecentProjects>(cx)
1628 .unwrap()
1629 .read(cx)
1630 .picker
1631 .clone()
1632 })
1633 .unwrap()
1634 }
1635
1636 #[gpui::test]
1637 async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
1638 let app_state = init_test(cx);
1639
1640 app_state
1641 .fs
1642 .as_fake()
1643 .insert_tree(
1644 path!("/project"),
1645 json!({
1646 ".devcontainer": {
1647 "devcontainer.json": "{}"
1648 },
1649 "src": {
1650 "main.rs": "fn main() {}"
1651 }
1652 }),
1653 )
1654 .await;
1655
1656 cx.update(|cx| {
1657 open_paths(
1658 &[PathBuf::from(path!("/project"))],
1659 app_state,
1660 workspace::OpenOptions::default(),
1661 cx,
1662 )
1663 })
1664 .await
1665 .unwrap();
1666
1667 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1668 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1669
1670 cx.run_until_parked();
1671
1672 // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
1673 // -> Workspace::update -> toggle_modal -> new_dev_container.
1674 // Before the fix, this panicked with "cannot read workspace::Workspace while
1675 // it is already being updated" because new_dev_container and open_dev_container
1676 // tried to read the Workspace entity through a WeakEntity handle while it was
1677 // already leased by the outer update.
1678 cx.dispatch_action(*multi_workspace, OpenDevContainer);
1679
1680 multi_workspace
1681 .update(cx, |multi_workspace, _, cx| {
1682 let modal = multi_workspace
1683 .workspace()
1684 .read(cx)
1685 .active_modal::<RemoteServerProjects>(cx);
1686 assert!(
1687 modal.is_some(),
1688 "Dev container modal should be open after dispatching OpenDevContainer"
1689 );
1690 })
1691 .unwrap();
1692 }
1693
1694 #[gpui::test]
1695 async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
1696 let app_state = init_test(cx);
1697
1698 app_state
1699 .fs
1700 .as_fake()
1701 .insert_tree(
1702 path!("/project"),
1703 json!({
1704 ".devcontainer": {
1705 "rust": {
1706 "devcontainer.json": "{}"
1707 },
1708 "python": {
1709 "devcontainer.json": "{}"
1710 }
1711 },
1712 "src": {
1713 "main.rs": "fn main() {}"
1714 }
1715 }),
1716 )
1717 .await;
1718
1719 cx.update(|cx| {
1720 open_paths(
1721 &[PathBuf::from(path!("/project"))],
1722 app_state,
1723 workspace::OpenOptions::default(),
1724 cx,
1725 )
1726 })
1727 .await
1728 .unwrap();
1729
1730 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1731 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1732
1733 cx.run_until_parked();
1734
1735 cx.dispatch_action(*multi_workspace, OpenDevContainer);
1736
1737 multi_workspace
1738 .update(cx, |multi_workspace, _, cx| {
1739 let modal = multi_workspace
1740 .workspace()
1741 .read(cx)
1742 .active_modal::<RemoteServerProjects>(cx);
1743 assert!(
1744 modal.is_some(),
1745 "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
1746 );
1747 })
1748 .unwrap();
1749 }
1750
1751 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1752 cx.update(|cx| {
1753 let state = AppState::test(cx);
1754 crate::init(cx);
1755 editor::init(cx);
1756 state
1757 })
1758 }
1759}