recent_projects.rs

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