recent_projects.rs

  1pub mod disconnected_overlay;
  2mod remote_connections;
  3mod remote_servers;
  4mod ssh_config;
  5
  6#[cfg(target_os = "windows")]
  7mod wsl_picker;
  8
  9use remote::RemoteConnectionOptions;
 10pub use remote_connections::open_remote_project;
 11
 12use disconnected_overlay::DisconnectedOverlay;
 13use fuzzy::{StringMatch, StringMatchCandidate};
 14use gpui::{
 15    Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
 16    Subscription, Task, WeakEntity, Window,
 17};
 18use ordered_float::OrderedFloat;
 19use picker::{
 20    Picker, PickerDelegate,
 21    highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
 22};
 23pub use remote_connections::SshSettings;
 24pub use remote_servers::RemoteServerProjects;
 25use settings::Settings;
 26use std::{path::Path, sync::Arc};
 27use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container};
 28use util::{ResultExt, paths::PathExt};
 29use workspace::{
 30    CloseIntent, HistoryManager, ModalView, OpenOptions, PathList, SerializedWorkspaceLocation,
 31    WORKSPACE_DB, Workspace, WorkspaceId, with_active_or_new_workspace,
 32};
 33use zed_actions::{OpenRecent, OpenRemote};
 34
 35pub fn init(cx: &mut App) {
 36    SshSettings::register(cx);
 37
 38    #[cfg(target_os = "windows")]
 39    cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {
 40        let create_new_window = open_wsl.create_new_window;
 41        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 42            use gpui::PathPromptOptions;
 43            use project::DirectoryLister;
 44
 45            let paths = workspace.prompt_for_open_path(
 46                PathPromptOptions {
 47                    files: true,
 48                    directories: true,
 49                    multiple: false,
 50                    prompt: None,
 51                },
 52                DirectoryLister::Local(
 53                    workspace.project().clone(),
 54                    workspace.app_state().fs.clone(),
 55                ),
 56                window,
 57                cx,
 58            );
 59
 60            cx.spawn_in(window, async move |workspace, cx| {
 61                use util::paths::SanitizedPath;
 62
 63                let Some(paths) = paths.await.log_err().flatten() else {
 64                    return;
 65                };
 66
 67                let paths = paths
 68                    .into_iter()
 69                    .filter_map(|path| SanitizedPath::new(&path).local_to_wsl())
 70                    .collect::<Vec<_>>();
 71
 72                if paths.is_empty() {
 73                    let message = indoc::indoc! { r#"
 74                        Invalid path specified when trying to open a folder inside WSL.
 75
 76                        Please note that Zed currently does not support opening network share folders inside wsl.
 77                    "#};
 78
 79                    let _ = cx.prompt(gpui::PromptLevel::Critical, "Invalid path", Some(&message), &["Ok"]).await;
 80                    return;
 81                }
 82
 83                workspace.update_in(cx, |workspace, window, cx| {
 84                    workspace.toggle_modal(window, cx, |window, cx| {
 85                        crate::wsl_picker::WslOpenModal::new(paths, create_new_window, window, cx)
 86                    });
 87                }).log_err();
 88            })
 89            .detach();
 90        });
 91    });
 92
 93    cx.on_action(|open_recent: &OpenRecent, cx| {
 94        let create_new_window = open_recent.create_new_window;
 95        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 96            let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
 97                RecentProjects::open(workspace, create_new_window, window, cx);
 98                return;
 99            };
100
101            recent_projects.update(cx, |recent_projects, cx| {
102                recent_projects
103                    .picker
104                    .update(cx, |picker, cx| picker.cycle_selection(window, cx))
105            });
106        });
107    });
108    cx.on_action(|open_remote: &OpenRemote, cx| {
109        let from_existing_connection = open_remote.from_existing_connection;
110        let create_new_window = open_remote.create_new_window;
111        with_active_or_new_workspace(cx, move |workspace, window, cx| {
112            if from_existing_connection {
113                cx.propagate();
114                return;
115            }
116            let handle = cx.entity().downgrade();
117            let fs = workspace.project().read(cx).fs().clone();
118            workspace.toggle_modal(window, cx, |window, cx| {
119                RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
120            })
121        });
122    });
123
124    cx.observe_new(DisconnectedOverlay::register).detach();
125}
126
127pub struct RecentProjects {
128    pub picker: Entity<Picker<RecentProjectsDelegate>>,
129    rem_width: f32,
130    _subscription: Subscription,
131}
132
133impl ModalView for RecentProjects {}
134
135impl RecentProjects {
136    fn new(
137        delegate: RecentProjectsDelegate,
138        rem_width: f32,
139        window: &mut Window,
140        cx: &mut Context<Self>,
141    ) -> Self {
142        let picker = cx.new(|cx| {
143            // We want to use a list when we render paths, because the items can have different heights (multiple paths).
144            if delegate.render_paths {
145                Picker::list(delegate, window, cx)
146            } else {
147                Picker::uniform_list(delegate, window, cx)
148            }
149        });
150        let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
151        // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
152        // out workspace locations once the future runs to completion.
153        cx.spawn_in(window, async move |this, cx| {
154            let workspaces = WORKSPACE_DB
155                .recent_workspaces_on_disk()
156                .await
157                .log_err()
158                .unwrap_or_default();
159            this.update_in(cx, move |this, window, cx| {
160                this.picker.update(cx, move |picker, cx| {
161                    picker.delegate.set_workspaces(workspaces);
162                    picker.update_matches(picker.query(cx), window, cx)
163                })
164            })
165            .ok()
166        })
167        .detach();
168        Self {
169            picker,
170            rem_width,
171            _subscription,
172        }
173    }
174
175    pub fn open(
176        workspace: &mut Workspace,
177        create_new_window: bool,
178        window: &mut Window,
179        cx: &mut Context<Workspace>,
180    ) {
181        let weak = cx.entity().downgrade();
182        workspace.toggle_modal(window, cx, |window, cx| {
183            let delegate = RecentProjectsDelegate::new(weak, create_new_window, true);
184
185            Self::new(delegate, 34., window, cx)
186        })
187    }
188}
189
190impl EventEmitter<DismissEvent> for RecentProjects {}
191
192impl Focusable for RecentProjects {
193    fn focus_handle(&self, cx: &App) -> FocusHandle {
194        self.picker.focus_handle(cx)
195    }
196}
197
198impl Render for RecentProjects {
199    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
200        v_flex()
201            .key_context("RecentProjects")
202            .w(rems(self.rem_width))
203            .child(self.picker.clone())
204            .on_mouse_down_out(cx.listener(|this, _, window, cx| {
205                this.picker.update(cx, |this, cx| {
206                    this.cancel(&Default::default(), window, cx);
207                })
208            }))
209    }
210}
211
212pub struct RecentProjectsDelegate {
213    workspace: WeakEntity<Workspace>,
214    workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
215    selected_match_index: usize,
216    matches: Vec<StringMatch>,
217    render_paths: bool,
218    create_new_window: bool,
219    // Flag to reset index when there is a new query vs not reset index when user delete an item
220    reset_selected_match_index: bool,
221    has_any_non_local_projects: bool,
222}
223
224impl RecentProjectsDelegate {
225    fn new(workspace: WeakEntity<Workspace>, create_new_window: bool, render_paths: bool) -> Self {
226        Self {
227            workspace,
228            workspaces: Vec::new(),
229            selected_match_index: 0,
230            matches: Default::default(),
231            create_new_window,
232            render_paths,
233            reset_selected_match_index: true,
234            has_any_non_local_projects: false,
235        }
236    }
237
238    pub fn set_workspaces(
239        &mut self,
240        workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
241    ) {
242        self.workspaces = workspaces;
243        self.has_any_non_local_projects = !self
244            .workspaces
245            .iter()
246            .all(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local));
247    }
248}
249impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
250impl PickerDelegate for RecentProjectsDelegate {
251    type ListItem = ListItem;
252
253    fn placeholder_text(&self, window: &mut Window, _: &mut App) -> Arc<str> {
254        let (create_window, reuse_window) = if self.create_new_window {
255            (
256                window.keystroke_text_for(&menu::Confirm),
257                window.keystroke_text_for(&menu::SecondaryConfirm),
258            )
259        } else {
260            (
261                window.keystroke_text_for(&menu::SecondaryConfirm),
262                window.keystroke_text_for(&menu::Confirm),
263            )
264        };
265        Arc::from(format!(
266            "{reuse_window} reuses this window, {create_window} opens a new one",
267        ))
268    }
269
270    fn match_count(&self) -> usize {
271        self.matches.len()
272    }
273
274    fn selected_index(&self) -> usize {
275        self.selected_match_index
276    }
277
278    fn set_selected_index(
279        &mut self,
280        ix: usize,
281        _window: &mut Window,
282        _cx: &mut Context<Picker<Self>>,
283    ) {
284        self.selected_match_index = ix;
285    }
286
287    fn update_matches(
288        &mut self,
289        query: String,
290        _: &mut Window,
291        cx: &mut Context<Picker<Self>>,
292    ) -> gpui::Task<()> {
293        let query = query.trim_start();
294        let smart_case = query.chars().any(|c| c.is_uppercase());
295        let candidates = self
296            .workspaces
297            .iter()
298            .enumerate()
299            .filter(|(_, (id, _, _))| !self.is_current_workspace(*id, cx))
300            .map(|(id, (_, _, paths))| {
301                let combined_string = paths
302                    .paths()
303                    .iter()
304                    .map(|path| path.compact().to_string_lossy().into_owned())
305                    .collect::<Vec<_>>()
306                    .join("");
307                StringMatchCandidate::new(id, &combined_string)
308            })
309            .collect::<Vec<_>>();
310        self.matches = smol::block_on(fuzzy::match_strings(
311            candidates.as_slice(),
312            query,
313            smart_case,
314            true,
315            100,
316            &Default::default(),
317            cx.background_executor().clone(),
318        ));
319        self.matches.sort_unstable_by_key(|m| m.candidate_id);
320
321        if self.reset_selected_match_index {
322            self.selected_match_index = self
323                .matches
324                .iter()
325                .enumerate()
326                .rev()
327                .max_by_key(|(_, m)| OrderedFloat(m.score))
328                .map(|(ix, _)| ix)
329                .unwrap_or(0);
330        }
331        self.reset_selected_match_index = true;
332        Task::ready(())
333    }
334
335    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
336        if let Some((selected_match, workspace)) = self
337            .matches
338            .get(self.selected_index())
339            .zip(self.workspace.upgrade())
340        {
341            let (candidate_workspace_id, candidate_workspace_location, candidate_workspace_paths) =
342                &self.workspaces[selected_match.candidate_id];
343            let replace_current_window = if self.create_new_window {
344                secondary
345            } else {
346                !secondary
347            };
348            workspace
349                .update(cx, |workspace, cx| {
350                    if workspace.database_id() == Some(*candidate_workspace_id) {
351                        Task::ready(Ok(()))
352                    } else {
353                        match candidate_workspace_location.clone() {
354                            SerializedWorkspaceLocation::Local => {
355                                let paths = candidate_workspace_paths.paths().to_vec();
356                                if replace_current_window {
357                                    cx.spawn_in(window, async move |workspace, cx| {
358                                        let continue_replacing = workspace
359                                            .update_in(cx, |workspace, window, cx| {
360                                                workspace.prepare_to_close(
361                                                    CloseIntent::ReplaceWindow,
362                                                    window,
363                                                    cx,
364                                                )
365                                            })?
366                                            .await?;
367                                        if continue_replacing {
368                                            workspace
369                                                .update_in(cx, |workspace, window, cx| {
370                                                    workspace.open_workspace_for_paths(
371                                                        true, paths, window, cx,
372                                                    )
373                                                })?
374                                                .await
375                                        } else {
376                                            Ok(())
377                                        }
378                                    })
379                                } else {
380                                    workspace.open_workspace_for_paths(false, paths, window, cx)
381                                }
382                            }
383                            SerializedWorkspaceLocation::Remote(mut connection) => {
384                                let app_state = workspace.app_state().clone();
385
386                                let replace_window = if replace_current_window {
387                                    window.window_handle().downcast::<Workspace>()
388                                } else {
389                                    None
390                                };
391
392                                let open_options = OpenOptions {
393                                    replace_window,
394                                    ..Default::default()
395                                };
396
397                                if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
398                                    SshSettings::get_global(cx)
399                                        .fill_connection_options_from_settings(connection);
400                                };
401
402                                let paths = candidate_workspace_paths.paths().to_vec();
403
404                                cx.spawn_in(window, async move |_, cx| {
405                                    open_remote_project(
406                                        connection.clone(),
407                                        paths,
408                                        app_state,
409                                        open_options,
410                                        cx,
411                                    )
412                                    .await
413                                })
414                            }
415                        }
416                    }
417                })
418                .detach_and_log_err(cx);
419            cx.emit(DismissEvent);
420        }
421    }
422
423    fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
424
425    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
426        let text = if self.workspaces.is_empty() {
427            "Recently opened projects will show up here".into()
428        } else {
429            "No matches".into()
430        };
431        Some(text)
432    }
433
434    fn render_match(
435        &self,
436        ix: usize,
437        selected: bool,
438        window: &mut Window,
439        cx: &mut Context<Picker<Self>>,
440    ) -> Option<Self::ListItem> {
441        let hit = self.matches.get(ix)?;
442
443        let (_, location, paths) = self.workspaces.get(hit.candidate_id)?;
444
445        let mut path_start_offset = 0;
446
447        let (match_labels, paths): (Vec<_>, Vec<_>) = paths
448            .paths()
449            .iter()
450            .map(|p| p.compact())
451            .map(|path| {
452                let highlighted_text =
453                    highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
454
455                path_start_offset += highlighted_text.1.char_count;
456                highlighted_text
457            })
458            .unzip();
459
460        let highlighted_match = HighlightedMatchWithPaths {
461            match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
462            paths,
463        };
464
465        Some(
466            ListItem::new(ix)
467                .toggle_state(selected)
468                .inset(true)
469                .spacing(ListItemSpacing::Sparse)
470                .child(
471                    h_flex()
472                        .flex_grow()
473                        .gap_3()
474                        .when(self.has_any_non_local_projects, |this| {
475                            this.child(match location {
476                                SerializedWorkspaceLocation::Local => Icon::new(IconName::Screen)
477                                    .color(Color::Muted)
478                                    .into_any_element(),
479                                SerializedWorkspaceLocation::Remote(options) => {
480                                    Icon::new(match options {
481                                        RemoteConnectionOptions::Ssh { .. } => IconName::Server,
482                                        RemoteConnectionOptions::Wsl { .. } => IconName::Linux,
483                                    })
484                                    .color(Color::Muted)
485                                    .into_any_element()
486                                }
487                            })
488                        })
489                        .child({
490                            let mut highlighted = highlighted_match.clone();
491                            if !self.render_paths {
492                                highlighted.paths.clear();
493                            }
494                            highlighted.render(window, cx)
495                        }),
496                )
497                .map(|el| {
498                    let delete_button = div()
499                        .child(
500                            IconButton::new("delete", IconName::Close)
501                                .icon_size(IconSize::Small)
502                                .on_click(cx.listener(move |this, _event, window, cx| {
503                                    cx.stop_propagation();
504                                    window.prevent_default();
505
506                                    this.delegate.delete_recent_project(ix, window, cx)
507                                }))
508                                .tooltip(Tooltip::text("Delete from Recent Projects...")),
509                        )
510                        .into_any_element();
511
512                    if self.selected_index() == ix {
513                        el.end_slot::<AnyElement>(delete_button)
514                    } else {
515                        el.end_hover_slot::<AnyElement>(delete_button)
516                    }
517                })
518                .tooltip(move |_, cx| {
519                    let tooltip_highlighted_location = highlighted_match.clone();
520                    cx.new(|_| MatchTooltip {
521                        highlighted_location: tooltip_highlighted_location,
522                    })
523                    .into()
524                }),
525        )
526    }
527
528    fn render_footer(
529        &self,
530        window: &mut Window,
531        cx: &mut Context<Picker<Self>>,
532    ) -> Option<AnyElement> {
533        Some(
534            h_flex()
535                .w_full()
536                .p_2()
537                .gap_2()
538                .justify_end()
539                .border_t_1()
540                .border_color(cx.theme().colors().border_variant)
541                .child(
542                    Button::new("remote", "Open Remote Folder")
543                        .key_binding(KeyBinding::for_action(
544                            &OpenRemote {
545                                from_existing_connection: false,
546                                create_new_window: false,
547                            },
548                            window,
549                            cx,
550                        ))
551                        .on_click(|_, window, cx| {
552                            window.dispatch_action(
553                                OpenRemote {
554                                    from_existing_connection: false,
555                                    create_new_window: false,
556                                }
557                                .boxed_clone(),
558                                cx,
559                            )
560                        }),
561                )
562                .child(
563                    Button::new("local", "Open Local Folder")
564                        .key_binding(KeyBinding::for_action(&workspace::Open, window, cx))
565                        .on_click(|_, window, cx| {
566                            window.dispatch_action(workspace::Open.boxed_clone(), cx)
567                        }),
568                )
569                .into_any(),
570        )
571    }
572}
573
574// Compute the highlighted text for the name and path
575fn highlights_for_path(
576    path: &Path,
577    match_positions: &Vec<usize>,
578    path_start_offset: usize,
579) -> (Option<HighlightedMatch>, HighlightedMatch) {
580    let path_string = path.to_string_lossy();
581    let path_char_count = path_string.chars().count();
582    // Get the subset of match highlight positions that line up with the given path.
583    // Also adjusts them to start at the path start
584    let path_positions = match_positions
585        .iter()
586        .copied()
587        .skip_while(|position| *position < path_start_offset)
588        .take_while(|position| *position < path_start_offset + path_char_count)
589        .map(|position| position - path_start_offset)
590        .collect::<Vec<_>>();
591
592    // Again subset the highlight positions to just those that line up with the file_name
593    // again adjusted to the start of the file_name
594    let file_name_text_and_positions = path.file_name().map(|file_name| {
595        let text = file_name.to_string_lossy();
596        let char_count = text.chars().count();
597        let file_name_start = path_char_count - char_count;
598        let highlight_positions = path_positions
599            .iter()
600            .copied()
601            .skip_while(|position| *position < file_name_start)
602            .take_while(|position| *position < file_name_start + char_count)
603            .map(|position| position - file_name_start)
604            .collect::<Vec<_>>();
605        HighlightedMatch {
606            text: text.to_string(),
607            highlight_positions,
608            char_count,
609            color: Color::Default,
610        }
611    });
612
613    (
614        file_name_text_and_positions,
615        HighlightedMatch {
616            text: path_string.to_string(),
617            highlight_positions: path_positions,
618            char_count: path_char_count,
619            color: Color::Default,
620        },
621    )
622}
623impl RecentProjectsDelegate {
624    fn delete_recent_project(
625        &self,
626        ix: usize,
627        window: &mut Window,
628        cx: &mut Context<Picker<Self>>,
629    ) {
630        if let Some(selected_match) = self.matches.get(ix) {
631            let (workspace_id, _, _) = self.workspaces[selected_match.candidate_id];
632            cx.spawn_in(window, async move |this, cx| {
633                let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
634                let workspaces = WORKSPACE_DB
635                    .recent_workspaces_on_disk()
636                    .await
637                    .unwrap_or_default();
638                this.update_in(cx, move |picker, window, cx| {
639                    picker.delegate.set_workspaces(workspaces);
640                    picker
641                        .delegate
642                        .set_selected_index(ix.saturating_sub(1), window, cx);
643                    picker.delegate.reset_selected_match_index = false;
644                    picker.update_matches(picker.query(cx), window, cx);
645                    // After deleting a project, we want to update the history manager to reflect the change.
646                    // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
647                    if let Some(history_manager) = HistoryManager::global(cx) {
648                        history_manager
649                            .update(cx, |this, cx| this.delete_history(workspace_id, cx));
650                    }
651                })
652            })
653            .detach();
654        }
655    }
656
657    fn is_current_workspace(
658        &self,
659        workspace_id: WorkspaceId,
660        cx: &mut Context<Picker<Self>>,
661    ) -> bool {
662        if let Some(workspace) = self.workspace.upgrade() {
663            let workspace = workspace.read(cx);
664            if Some(workspace_id) == workspace.database_id() {
665                return true;
666            }
667        }
668
669        false
670    }
671}
672struct MatchTooltip {
673    highlighted_location: HighlightedMatchWithPaths,
674}
675
676impl Render for MatchTooltip {
677    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
678        tooltip_container(window, cx, |div, _, _| {
679            self.highlighted_location.render_paths_children(div)
680        })
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use std::path::PathBuf;
687
688    use dap::debugger_settings::DebuggerSettings;
689    use editor::Editor;
690    use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
691    use project::{Project, project_settings::ProjectSettings};
692    use serde_json::json;
693    use settings::SettingsStore;
694    use util::path;
695    use workspace::{AppState, open_paths};
696
697    use super::*;
698
699    #[gpui::test]
700    async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) {
701        let app_state = init_test(cx);
702
703        cx.update(|cx| {
704            SettingsStore::update_global(cx, |store, cx| {
705                store.update_user_settings::<ProjectSettings>(cx, |settings| {
706                    settings.session.restore_unsaved_buffers = false
707                });
708            });
709        });
710
711        app_state
712            .fs
713            .as_fake()
714            .insert_tree(
715                path!("/dir"),
716                json!({
717                    "main.ts": "a"
718                }),
719            )
720            .await;
721        cx.update(|cx| {
722            open_paths(
723                &[PathBuf::from(path!("/dir/main.ts"))],
724                app_state,
725                workspace::OpenOptions::default(),
726                cx,
727            )
728        })
729        .await
730        .unwrap();
731        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
732
733        let workspace = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
734        workspace
735            .update(cx, |workspace, _, _| assert!(!workspace.is_edited()))
736            .unwrap();
737
738        let editor = workspace
739            .read_with(cx, |workspace, cx| {
740                workspace
741                    .active_item(cx)
742                    .unwrap()
743                    .downcast::<Editor>()
744                    .unwrap()
745            })
746            .unwrap();
747        workspace
748            .update(cx, |_, window, cx| {
749                editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
750            })
751            .unwrap();
752        workspace
753            .update(cx, |workspace, _, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project"))
754            .unwrap();
755
756        let recent_projects_picker = open_recent_projects(&workspace, cx);
757        workspace
758            .update(cx, |_, _, cx| {
759                recent_projects_picker.update(cx, |picker, cx| {
760                    assert_eq!(picker.query(cx), "");
761                    let delegate = &mut picker.delegate;
762                    delegate.matches = vec![StringMatch {
763                        candidate_id: 0,
764                        score: 1.0,
765                        positions: Vec::new(),
766                        string: "fake candidate".to_string(),
767                    }];
768                    delegate.set_workspaces(vec![(
769                        WorkspaceId::default(),
770                        SerializedWorkspaceLocation::Local,
771                        PathList::new(&[path!("/test/path")]),
772                    )]);
773                });
774            })
775            .unwrap();
776
777        assert!(
778            !cx.has_pending_prompt(),
779            "Should have no pending prompt on dirty project before opening the new recent project"
780        );
781        cx.dispatch_action(*workspace, menu::Confirm);
782        workspace
783            .update(cx, |workspace, _, cx| {
784                assert!(
785                    workspace.active_modal::<RecentProjects>(cx).is_none(),
786                    "Should remove the modal after selecting new recent project"
787                )
788            })
789            .unwrap();
790        assert!(
791            cx.has_pending_prompt(),
792            "Dirty workspace should prompt before opening the new recent project"
793        );
794        cx.simulate_prompt_answer("Cancel");
795        assert!(
796            !cx.has_pending_prompt(),
797            "Should have no pending prompt after cancelling"
798        );
799        workspace
800            .update(cx, |workspace, _, _| {
801                assert!(
802                    workspace.is_edited(),
803                    "Should be in the same dirty project after cancelling"
804                )
805            })
806            .unwrap();
807    }
808
809    fn open_recent_projects(
810        workspace: &WindowHandle<Workspace>,
811        cx: &mut TestAppContext,
812    ) -> Entity<Picker<RecentProjectsDelegate>> {
813        cx.dispatch_action(
814            (*workspace).into(),
815            OpenRecent {
816                create_new_window: false,
817            },
818        );
819        workspace
820            .update(cx, |workspace, _, cx| {
821                workspace
822                    .active_modal::<RecentProjects>(cx)
823                    .unwrap()
824                    .read(cx)
825                    .picker
826                    .clone()
827            })
828            .unwrap()
829    }
830
831    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
832        cx.update(|cx| {
833            let state = AppState::test(cx);
834            language::init(cx);
835            crate::init(cx);
836            editor::init(cx);
837            workspace::init_settings(cx);
838            DebuggerSettings::register(cx);
839            Project::init_settings(cx);
840            state
841        })
842    }
843}