recent_projects.rs

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