recent_projects.rs

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