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            true,
255            100,
256            &Default::default(),
257            cx.background_executor().clone(),
258        ));
259        self.matches.sort_unstable_by_key(|m| m.candidate_id);
260
261        if self.reset_selected_match_index {
262            self.selected_match_index = self
263                .matches
264                .iter()
265                .enumerate()
266                .rev()
267                .max_by_key(|(_, m)| OrderedFloat(m.score))
268                .map(|(ix, _)| ix)
269                .unwrap_or(0);
270        }
271        self.reset_selected_match_index = true;
272        Task::ready(())
273    }
274
275    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
276        if let Some((selected_match, workspace)) = self
277            .matches
278            .get(self.selected_index())
279            .zip(self.workspace.upgrade())
280        {
281            let (candidate_workspace_id, candidate_workspace_location) =
282                &self.workspaces[selected_match.candidate_id];
283            let replace_current_window = if self.create_new_window {
284                secondary
285            } else {
286                !secondary
287            };
288            workspace
289                .update(cx, |workspace, cx| {
290                    if workspace.database_id() == Some(*candidate_workspace_id) {
291                        Task::ready(Ok(()))
292                    } else {
293                        match candidate_workspace_location {
294                            SerializedWorkspaceLocation::Local(paths, _) => {
295                                let paths = paths.paths().to_vec();
296                                if replace_current_window {
297                                    cx.spawn_in(window, async move |workspace, cx| {
298                                        let continue_replacing = workspace
299                                            .update_in(cx, |workspace, window, cx| {
300                                                workspace.prepare_to_close(
301                                                    CloseIntent::ReplaceWindow,
302                                                    window,
303                                                    cx,
304                                                )
305                                            })?
306                                            .await?;
307                                        if continue_replacing {
308                                            workspace
309                                                .update_in(cx, |workspace, window, cx| {
310                                                    workspace.open_workspace_for_paths(
311                                                        true, paths, window, cx,
312                                                    )
313                                                })?
314                                                .await
315                                        } else {
316                                            Ok(())
317                                        }
318                                    })
319                                } else {
320                                    workspace.open_workspace_for_paths(false, paths, window, cx)
321                                }
322                            }
323                            SerializedWorkspaceLocation::Ssh(ssh_project) => {
324                                let app_state = workspace.app_state().clone();
325
326                                let replace_window = if replace_current_window {
327                                    window.window_handle().downcast::<Workspace>()
328                                } else {
329                                    None
330                                };
331
332                                let open_options = OpenOptions {
333                                    replace_window,
334                                    ..Default::default()
335                                };
336
337                                let connection_options = SshSettings::get_global(cx)
338                                    .connection_options_for(
339                                        ssh_project.host.clone(),
340                                        ssh_project.port,
341                                        ssh_project.user.clone(),
342                                    );
343
344                                let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
345
346                                cx.spawn_in(window, async move |_, cx| {
347                                    open_ssh_project(
348                                        connection_options,
349                                        paths,
350                                        app_state,
351                                        open_options,
352                                        cx,
353                                    )
354                                    .await
355                                })
356                            }
357                        }
358                    }
359                })
360                .detach_and_log_err(cx);
361            cx.emit(DismissEvent);
362        }
363    }
364
365    fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
366
367    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
368        let text = if self.workspaces.is_empty() {
369            "Recently opened projects will show up here".into()
370        } else {
371            "No matches".into()
372        };
373        Some(text)
374    }
375
376    fn render_match(
377        &self,
378        ix: usize,
379        selected: bool,
380        window: &mut Window,
381        cx: &mut Context<Picker<Self>>,
382    ) -> Option<Self::ListItem> {
383        let hit = self.matches.get(ix)?;
384
385        let (_, location) = self.workspaces.get(hit.candidate_id)?;
386
387        let mut path_start_offset = 0;
388
389        let (match_labels, paths): (Vec<_>, Vec<_>) = location
390            .sorted_paths()
391            .iter()
392            .map(|p| p.compact())
393            .map(|path| {
394                let highlighted_text =
395                    highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
396
397                path_start_offset += highlighted_text.1.char_count;
398                highlighted_text
399            })
400            .unzip();
401
402        let highlighted_match = HighlightedMatchWithPaths {
403            match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
404            paths,
405        };
406
407        Some(
408            ListItem::new(ix)
409                .toggle_state(selected)
410                .inset(true)
411                .spacing(ListItemSpacing::Sparse)
412                .child(
413                    h_flex()
414                        .flex_grow()
415                        .gap_3()
416                        .when(self.has_any_non_local_projects, |this| {
417                            this.child(match location {
418                                SerializedWorkspaceLocation::Local(_, _) => {
419                                    Icon::new(IconName::Screen)
420                                        .color(Color::Muted)
421                                        .into_any_element()
422                                }
423                                SerializedWorkspaceLocation::Ssh(_) => Icon::new(IconName::Server)
424                                    .color(Color::Muted)
425                                    .into_any_element(),
426                            })
427                        })
428                        .child({
429                            let mut highlighted = highlighted_match.clone();
430                            if !self.render_paths {
431                                highlighted.paths.clear();
432                            }
433                            highlighted.render(window, cx)
434                        }),
435                )
436                .map(|el| {
437                    let delete_button = div()
438                        .child(
439                            IconButton::new("delete", IconName::Close)
440                                .icon_size(IconSize::Small)
441                                .on_click(cx.listener(move |this, _event, window, cx| {
442                                    cx.stop_propagation();
443                                    window.prevent_default();
444
445                                    this.delegate.delete_recent_project(ix, window, cx)
446                                }))
447                                .tooltip(Tooltip::text("Delete from Recent Projects...")),
448                        )
449                        .into_any_element();
450
451                    if self.selected_index() == ix {
452                        el.end_slot::<AnyElement>(delete_button)
453                    } else {
454                        el.end_hover_slot::<AnyElement>(delete_button)
455                    }
456                })
457                .tooltip(move |_, cx| {
458                    let tooltip_highlighted_location = highlighted_match.clone();
459                    cx.new(|_| MatchTooltip {
460                        highlighted_location: tooltip_highlighted_location,
461                    })
462                    .into()
463                }),
464        )
465    }
466
467    fn render_footer(
468        &self,
469        window: &mut Window,
470        cx: &mut Context<Picker<Self>>,
471    ) -> Option<AnyElement> {
472        Some(
473            h_flex()
474                .w_full()
475                .p_2()
476                .gap_2()
477                .justify_end()
478                .border_t_1()
479                .border_color(cx.theme().colors().border_variant)
480                .child(
481                    Button::new("remote", "Open Remote Folder")
482                        .key_binding(KeyBinding::for_action(
483                            &OpenRemote {
484                                from_existing_connection: false,
485                                create_new_window: false,
486                            },
487                            window,
488                            cx,
489                        ))
490                        .on_click(|_, window, cx| {
491                            window.dispatch_action(
492                                OpenRemote {
493                                    from_existing_connection: false,
494                                    create_new_window: false,
495                                }
496                                .boxed_clone(),
497                                cx,
498                            )
499                        }),
500                )
501                .child(
502                    Button::new("local", "Open Local Folder")
503                        .key_binding(KeyBinding::for_action(&workspace::Open, window, cx))
504                        .on_click(|_, window, cx| {
505                            window.dispatch_action(workspace::Open.boxed_clone(), cx)
506                        }),
507                )
508                .into_any(),
509        )
510    }
511}
512
513// Compute the highlighted text for the name and path
514fn highlights_for_path(
515    path: &Path,
516    match_positions: &Vec<usize>,
517    path_start_offset: usize,
518) -> (Option<HighlightedMatch>, HighlightedMatch) {
519    let path_string = path.to_string_lossy();
520    let path_char_count = path_string.chars().count();
521    // Get the subset of match highlight positions that line up with the given path.
522    // Also adjusts them to start at the path start
523    let path_positions = match_positions
524        .iter()
525        .copied()
526        .skip_while(|position| *position < path_start_offset)
527        .take_while(|position| *position < path_start_offset + path_char_count)
528        .map(|position| position - path_start_offset)
529        .collect::<Vec<_>>();
530
531    // Again subset the highlight positions to just those that line up with the file_name
532    // again adjusted to the start of the file_name
533    let file_name_text_and_positions = path.file_name().map(|file_name| {
534        let text = file_name.to_string_lossy();
535        let char_count = text.chars().count();
536        let file_name_start = path_char_count - char_count;
537        let highlight_positions = path_positions
538            .iter()
539            .copied()
540            .skip_while(|position| *position < file_name_start)
541            .take_while(|position| *position < file_name_start + char_count)
542            .map(|position| position - file_name_start)
543            .collect::<Vec<_>>();
544        HighlightedMatch {
545            text: text.to_string(),
546            highlight_positions,
547            char_count,
548            color: Color::Default,
549        }
550    });
551
552    (
553        file_name_text_and_positions,
554        HighlightedMatch {
555            text: path_string.to_string(),
556            highlight_positions: path_positions,
557            char_count: path_char_count,
558            color: Color::Default,
559        },
560    )
561}
562impl RecentProjectsDelegate {
563    fn delete_recent_project(
564        &self,
565        ix: usize,
566        window: &mut Window,
567        cx: &mut Context<Picker<Self>>,
568    ) {
569        if let Some(selected_match) = self.matches.get(ix) {
570            let (workspace_id, _) = self.workspaces[selected_match.candidate_id];
571            cx.spawn_in(window, async move |this, cx| {
572                let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
573                let workspaces = WORKSPACE_DB
574                    .recent_workspaces_on_disk()
575                    .await
576                    .unwrap_or_default();
577                this.update_in(cx, move |picker, window, cx| {
578                    picker.delegate.set_workspaces(workspaces);
579                    picker
580                        .delegate
581                        .set_selected_index(ix.saturating_sub(1), window, cx);
582                    picker.delegate.reset_selected_match_index = false;
583                    picker.update_matches(picker.query(cx), window, cx);
584                    // After deleting a project, we want to update the history manager to reflect the change.
585                    // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
586                    if let Some(history_manager) = HistoryManager::global(cx) {
587                        history_manager
588                            .update(cx, |this, cx| this.delete_history(workspace_id, cx));
589                    }
590                })
591            })
592            .detach();
593        }
594    }
595
596    fn is_current_workspace(
597        &self,
598        workspace_id: WorkspaceId,
599        cx: &mut Context<Picker<Self>>,
600    ) -> bool {
601        if let Some(workspace) = self.workspace.upgrade() {
602            let workspace = workspace.read(cx);
603            if Some(workspace_id) == workspace.database_id() {
604                return true;
605            }
606        }
607
608        false
609    }
610}
611struct MatchTooltip {
612    highlighted_location: HighlightedMatchWithPaths,
613}
614
615impl Render for MatchTooltip {
616    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
617        tooltip_container(window, cx, |div, _, _| {
618            self.highlighted_location.render_paths_children(div)
619        })
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use std::path::PathBuf;
626
627    use dap::debugger_settings::DebuggerSettings;
628    use editor::Editor;
629    use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
630    use project::{Project, project_settings::ProjectSettings};
631    use serde_json::json;
632    use settings::SettingsStore;
633    use util::path;
634    use workspace::{AppState, open_paths};
635
636    use super::*;
637
638    #[gpui::test]
639    async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) {
640        let app_state = init_test(cx);
641
642        cx.update(|cx| {
643            SettingsStore::update_global(cx, |store, cx| {
644                store.update_user_settings::<ProjectSettings>(cx, |settings| {
645                    settings.session.restore_unsaved_buffers = false
646                });
647            });
648        });
649
650        app_state
651            .fs
652            .as_fake()
653            .insert_tree(
654                path!("/dir"),
655                json!({
656                    "main.ts": "a"
657                }),
658            )
659            .await;
660        cx.update(|cx| {
661            open_paths(
662                &[PathBuf::from(path!("/dir/main.ts"))],
663                app_state,
664                workspace::OpenOptions::default(),
665                cx,
666            )
667        })
668        .await
669        .unwrap();
670        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
671
672        let workspace = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
673        workspace
674            .update(cx, |workspace, _, _| assert!(!workspace.is_edited()))
675            .unwrap();
676
677        let editor = workspace
678            .read_with(cx, |workspace, cx| {
679                workspace
680                    .active_item(cx)
681                    .unwrap()
682                    .downcast::<Editor>()
683                    .unwrap()
684            })
685            .unwrap();
686        workspace
687            .update(cx, |_, window, cx| {
688                editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
689            })
690            .unwrap();
691        workspace
692            .update(cx, |workspace, _, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project"))
693            .unwrap();
694
695        let recent_projects_picker = open_recent_projects(&workspace, cx);
696        workspace
697            .update(cx, |_, _, cx| {
698                recent_projects_picker.update(cx, |picker, cx| {
699                    assert_eq!(picker.query(cx), "");
700                    let delegate = &mut picker.delegate;
701                    delegate.matches = vec![StringMatch {
702                        candidate_id: 0,
703                        score: 1.0,
704                        positions: Vec::new(),
705                        string: "fake candidate".to_string(),
706                    }];
707                    delegate.set_workspaces(vec![(
708                        WorkspaceId::default(),
709                        SerializedWorkspaceLocation::from_local_paths(vec![path!("/test/path/")]),
710                    )]);
711                });
712            })
713            .unwrap();
714
715        assert!(
716            !cx.has_pending_prompt(),
717            "Should have no pending prompt on dirty project before opening the new recent project"
718        );
719        cx.dispatch_action(*workspace, menu::Confirm);
720        workspace
721            .update(cx, |workspace, _, cx| {
722                assert!(
723                    workspace.active_modal::<RecentProjects>(cx).is_none(),
724                    "Should remove the modal after selecting new recent project"
725                )
726            })
727            .unwrap();
728        assert!(
729            cx.has_pending_prompt(),
730            "Dirty workspace should prompt before opening the new recent project"
731        );
732        cx.simulate_prompt_answer("Cancel");
733        assert!(
734            !cx.has_pending_prompt(),
735            "Should have no pending prompt after cancelling"
736        );
737        workspace
738            .update(cx, |workspace, _, _| {
739                assert!(
740                    workspace.is_edited(),
741                    "Should be in the same dirty project after cancelling"
742                )
743            })
744            .unwrap();
745    }
746
747    fn open_recent_projects(
748        workspace: &WindowHandle<Workspace>,
749        cx: &mut TestAppContext,
750    ) -> Entity<Picker<RecentProjectsDelegate>> {
751        cx.dispatch_action(
752            (*workspace).into(),
753            OpenRecent {
754                create_new_window: false,
755            },
756        );
757        workspace
758            .update(cx, |workspace, _, cx| {
759                workspace
760                    .active_modal::<RecentProjects>(cx)
761                    .unwrap()
762                    .read(cx)
763                    .picker
764                    .clone()
765            })
766            .unwrap()
767    }
768
769    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
770        cx.update(|cx| {
771            let state = AppState::test(cx);
772            language::init(cx);
773            crate::init(cx);
774            editor::init(cx);
775            workspace::init_settings(cx);
776            DebuggerSettings::register(cx);
777            Project::init_settings(cx);
778            state
779        })
780    }
781}