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