onboarding.rs

  1use crate::welcome::{ShowWelcome, WelcomePage};
  2use client::{Client, UserStore, zed_urls};
  3use command_palette_hooks::CommandPaletteFilter;
  4use db::kvp::KEY_VALUE_STORE;
  5use feature_flags::{FeatureFlag, FeatureFlagViewExt as _};
  6use fs::Fs;
  7use gpui::{
  8    Action, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, EventEmitter,
  9    FocusHandle, Focusable, Global, IntoElement, KeyContext, Render, SharedString, Subscription,
 10    Task, WeakEntity, Window, actions,
 11};
 12use notifications::status_toast::{StatusToast, ToastIcon};
 13use schemars::JsonSchema;
 14use serde::Deserialize;
 15use settings::{SettingsStore, VsCodeSettingsSource};
 16use std::sync::Arc;
 17use ui::{
 18    Avatar, ButtonLike, FluentBuilder, Headline, KeyBinding, ParentElement as _,
 19    StatefulInteractiveElement, Vector, VectorName, prelude::*, rems_from_px,
 20};
 21use workspace::{
 22    AppState, Workspace, WorkspaceId,
 23    dock::DockPosition,
 24    item::{Item, ItemEvent},
 25    notifications::NotifyResultExt as _,
 26    open_new, register_serializable_item, with_active_or_new_workspace,
 27};
 28
 29mod ai_setup_page;
 30mod basics_page;
 31mod editing_page;
 32mod theme_preview;
 33mod welcome;
 34
 35pub struct OnBoardingFeatureFlag {}
 36
 37impl FeatureFlag for OnBoardingFeatureFlag {
 38    const NAME: &'static str = "onboarding";
 39}
 40
 41/// Imports settings from Visual Studio Code.
 42#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
 43#[action(namespace = zed)]
 44#[serde(deny_unknown_fields)]
 45pub struct ImportVsCodeSettings {
 46    #[serde(default)]
 47    pub skip_prompt: bool,
 48}
 49
 50/// Imports settings from Cursor editor.
 51#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
 52#[action(namespace = zed)]
 53#[serde(deny_unknown_fields)]
 54pub struct ImportCursorSettings {
 55    #[serde(default)]
 56    pub skip_prompt: bool,
 57}
 58
 59pub const FIRST_OPEN: &str = "first_open";
 60
 61actions!(
 62    zed,
 63    [
 64        /// Opens the onboarding view.
 65        OpenOnboarding
 66    ]
 67);
 68
 69actions!(
 70    onboarding,
 71    [
 72        /// Activates the Basics page.
 73        ActivateBasicsPage,
 74        /// Activates the Editing page.
 75        ActivateEditingPage,
 76        /// Activates the AI Setup page.
 77        ActivateAISetupPage,
 78        /// Finish the onboarding process.
 79        Finish,
 80        /// Sign in while in the onboarding flow.
 81        SignIn,
 82        /// Open the user account in zed.dev while in the onboarding flow.
 83        OpenAccount
 84    ]
 85);
 86
 87pub fn init(cx: &mut App) {
 88    cx.on_action(|_: &OpenOnboarding, cx| {
 89        with_active_or_new_workspace(cx, |workspace, window, cx| {
 90            workspace
 91                .with_local_workspace(window, cx, |workspace, window, cx| {
 92                    let existing = workspace
 93                        .active_pane()
 94                        .read(cx)
 95                        .items()
 96                        .find_map(|item| item.downcast::<Onboarding>());
 97
 98                    if let Some(existing) = existing {
 99                        workspace.activate_item(&existing, true, true, window, cx);
100                    } else {
101                        let settings_page = Onboarding::new(workspace, cx);
102                        workspace.add_item_to_active_pane(
103                            Box::new(settings_page),
104                            None,
105                            true,
106                            window,
107                            cx,
108                        )
109                    }
110                })
111                .detach();
112        });
113    });
114
115    cx.on_action(|_: &ShowWelcome, cx| {
116        with_active_or_new_workspace(cx, |workspace, window, cx| {
117            workspace
118                .with_local_workspace(window, cx, |workspace, window, cx| {
119                    let existing = workspace
120                        .active_pane()
121                        .read(cx)
122                        .items()
123                        .find_map(|item| item.downcast::<WelcomePage>());
124
125                    if let Some(existing) = existing {
126                        workspace.activate_item(&existing, true, true, window, cx);
127                    } else {
128                        let settings_page = WelcomePage::new(window, cx);
129                        workspace.add_item_to_active_pane(
130                            Box::new(settings_page),
131                            None,
132                            true,
133                            window,
134                            cx,
135                        )
136                    }
137                })
138                .detach();
139        });
140    });
141
142    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
143        workspace.register_action(|_workspace, action: &ImportVsCodeSettings, window, cx| {
144            let fs = <dyn Fs>::global(cx);
145            let action = *action;
146
147            let workspace = cx.weak_entity();
148
149            window
150                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
151                    handle_import_vscode_settings(
152                        workspace,
153                        VsCodeSettingsSource::VsCode,
154                        action.skip_prompt,
155                        fs,
156                        cx,
157                    )
158                    .await
159                })
160                .detach();
161        });
162
163        workspace.register_action(|_workspace, action: &ImportCursorSettings, window, cx| {
164            let fs = <dyn Fs>::global(cx);
165            let action = *action;
166
167            let workspace = cx.weak_entity();
168
169            window
170                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
171                    handle_import_vscode_settings(
172                        workspace,
173                        VsCodeSettingsSource::Cursor,
174                        action.skip_prompt,
175                        fs,
176                        cx,
177                    )
178                    .await
179                })
180                .detach();
181        });
182    })
183    .detach();
184
185    cx.observe_new::<Workspace>(|_, window, cx| {
186        let Some(window) = window else {
187            return;
188        };
189
190        let onboarding_actions = [
191            std::any::TypeId::of::<OpenOnboarding>(),
192            std::any::TypeId::of::<ShowWelcome>(),
193        ];
194
195        CommandPaletteFilter::update_global(cx, |filter, _cx| {
196            filter.hide_action_types(&onboarding_actions);
197        });
198
199        cx.observe_flag::<OnBoardingFeatureFlag, _>(window, move |is_enabled, _, _, cx| {
200            if is_enabled {
201                CommandPaletteFilter::update_global(cx, |filter, _cx| {
202                    filter.show_action_types(onboarding_actions.iter());
203                });
204            } else {
205                CommandPaletteFilter::update_global(cx, |filter, _cx| {
206                    filter.hide_action_types(&onboarding_actions);
207                });
208            }
209        })
210        .detach();
211    })
212    .detach();
213    register_serializable_item::<Onboarding>(cx);
214}
215
216pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyhow::Result<()>> {
217    telemetry::event!("Onboarding Page Opened");
218    open_new(
219        Default::default(),
220        app_state,
221        cx,
222        |workspace, window, cx| {
223            {
224                workspace.toggle_dock(DockPosition::Left, window, cx);
225                let onboarding_page = Onboarding::new(workspace, cx);
226                workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
227
228                window.focus(&onboarding_page.focus_handle(cx));
229
230                cx.notify();
231            };
232            db::write_and_log(cx, || {
233                KEY_VALUE_STORE.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
234            });
235        },
236    )
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240enum SelectedPage {
241    Basics,
242    Editing,
243    AiSetup,
244}
245
246impl SelectedPage {
247    fn name(&self) -> &'static str {
248        match self {
249            SelectedPage::Basics => "Basics",
250            SelectedPage::Editing => "Editing",
251            SelectedPage::AiSetup => "AI Setup",
252        }
253    }
254}
255
256struct Onboarding {
257    workspace: WeakEntity<Workspace>,
258    focus_handle: FocusHandle,
259    selected_page: SelectedPage,
260    user_store: Entity<UserStore>,
261    _settings_subscription: Subscription,
262}
263
264impl Onboarding {
265    fn new(workspace: &Workspace, cx: &mut App) -> Entity<Self> {
266        cx.new(|cx| Self {
267            workspace: workspace.weak_handle(),
268            focus_handle: cx.focus_handle(),
269            selected_page: SelectedPage::Basics,
270            user_store: workspace.user_store().clone(),
271            _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
272        })
273    }
274
275    fn set_page(
276        &mut self,
277        page: SelectedPage,
278        clicked: Option<&'static str>,
279        cx: &mut Context<Self>,
280    ) {
281        if let Some(click) = clicked {
282            telemetry::event!(
283                "Welcome Tab Clicked",
284                from = self.selected_page.name(),
285                to = page.name(),
286                clicked = click,
287            );
288        }
289
290        self.selected_page = page;
291        cx.notify();
292        cx.emit(ItemEvent::UpdateTab);
293    }
294
295    fn render_nav_buttons(
296        &mut self,
297        window: &mut Window,
298        cx: &mut Context<Self>,
299    ) -> [impl IntoElement; 3] {
300        let pages = [
301            SelectedPage::Basics,
302            SelectedPage::Editing,
303            SelectedPage::AiSetup,
304        ];
305
306        let text = ["Basics", "Editing", "AI Setup"];
307
308        let actions: [&dyn Action; 3] = [
309            &ActivateBasicsPage,
310            &ActivateEditingPage,
311            &ActivateAISetupPage,
312        ];
313
314        let mut binding = actions.map(|action| {
315            KeyBinding::for_action_in(action, &self.focus_handle, window, cx)
316                .map(|kb| kb.size(rems_from_px(12.)))
317        });
318
319        pages.map(|page| {
320            let i = page as usize;
321            let selected = self.selected_page == page;
322            h_flex()
323                .id(text[i])
324                .relative()
325                .w_full()
326                .gap_2()
327                .px_2()
328                .py_0p5()
329                .justify_between()
330                .rounded_sm()
331                .when(selected, |this| {
332                    this.child(
333                        div()
334                            .h_4()
335                            .w_px()
336                            .bg(cx.theme().colors().text_accent)
337                            .absolute()
338                            .left_0(),
339                    )
340                })
341                .hover(|style| style.bg(cx.theme().colors().element_hover))
342                .child(Label::new(text[i]).map(|this| {
343                    if selected {
344                        this.color(Color::Default)
345                    } else {
346                        this.color(Color::Muted)
347                    }
348                }))
349                .child(binding[i].take().map_or(
350                    gpui::Empty.into_any_element(),
351                    IntoElement::into_any_element,
352                ))
353                .on_click(cx.listener(move |this, click_event, _, cx| {
354                    let click = match click_event {
355                        gpui::ClickEvent::Mouse(_) => "mouse",
356                        gpui::ClickEvent::Keyboard(_) => "keyboard",
357                    };
358
359                    this.set_page(page, Some(click), cx);
360                }))
361        })
362    }
363
364    fn render_nav(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
365        let ai_setup_page = matches!(self.selected_page, SelectedPage::AiSetup);
366
367        v_flex()
368            .h_full()
369            .w(rems_from_px(220.))
370            .flex_shrink_0()
371            .gap_4()
372            .justify_between()
373            .child(
374                v_flex()
375                    .gap_6()
376                    .child(
377                        h_flex()
378                            .px_2()
379                            .gap_4()
380                            .child(Vector::square(VectorName::ZedLogo, rems(2.5)))
381                            .child(
382                                v_flex()
383                                    .child(
384                                        Headline::new("Welcome to Zed").size(HeadlineSize::Small),
385                                    )
386                                    .child(
387                                        Label::new("The editor for what's next")
388                                            .color(Color::Muted)
389                                            .size(LabelSize::Small)
390                                            .italic(),
391                                    ),
392                            ),
393                    )
394                    .child(
395                        v_flex()
396                            .gap_4()
397                            .child(
398                                v_flex()
399                                    .py_4()
400                                    .border_y_1()
401                                    .border_color(cx.theme().colors().border_variant.opacity(0.5))
402                                    .gap_1()
403                                    .children(self.render_nav_buttons(window, cx)),
404                            )
405                            .map(|this| {
406                                let keybinding = KeyBinding::for_action_in(
407                                    &Finish,
408                                    &self.focus_handle,
409                                    window,
410                                    cx,
411                                )
412                                .map(|kb| kb.size(rems_from_px(12.)));
413
414                                if ai_setup_page {
415                                    this.child(
416                                        ButtonLike::new("start_building")
417                                            .style(ButtonStyle::Outlined)
418                                            .size(ButtonSize::Medium)
419                                            .child(
420                                                h_flex()
421                                                    .ml_1()
422                                                    .w_full()
423                                                    .justify_between()
424                                                    .child(Label::new("Start Building"))
425                                                    .children(keybinding),
426                                            )
427                                            .on_click(|_, window, cx| {
428                                                window.dispatch_action(Finish.boxed_clone(), cx);
429                                            }),
430                                    )
431                                } else {
432                                    this.child(
433                                        ButtonLike::new("skip_all")
434                                            .size(ButtonSize::Medium)
435                                            .child(
436                                                h_flex()
437                                                    .ml_1()
438                                                    .w_full()
439                                                    .justify_between()
440                                                    .child(
441                                                        Label::new("Skip All").color(Color::Muted),
442                                                    )
443                                                    .children(keybinding),
444                                            )
445                                            .on_click(|_, window, cx| {
446                                                window.dispatch_action(Finish.boxed_clone(), cx);
447                                            }),
448                                    )
449                                }
450                            }),
451                    ),
452            )
453            .child(
454                if let Some(user) = self.user_store.read(cx).current_user() {
455                    v_flex()
456                        .gap_1()
457                        .child(
458                            h_flex()
459                                .ml_2()
460                                .gap_2()
461                                .max_w_full()
462                                .w_full()
463                                .child(Avatar::new(user.avatar_uri.clone()))
464                                .child(Label::new(user.github_login.clone()).truncate()),
465                        )
466                        .child(
467                            ButtonLike::new("open_account")
468                                .size(ButtonSize::Medium)
469                                .child(
470                                    h_flex()
471                                        .ml_1()
472                                        .w_full()
473                                        .justify_between()
474                                        .child(Label::new("Open Account"))
475                                        .children(
476                                            KeyBinding::for_action_in(
477                                                &OpenAccount,
478                                                &self.focus_handle,
479                                                window,
480                                                cx,
481                                            )
482                                            .map(|kb| kb.size(rems_from_px(12.))),
483                                        ),
484                                )
485                                .on_click(|_, window, cx| {
486                                    window.dispatch_action(OpenAccount.boxed_clone(), cx);
487                                }),
488                        )
489                        .into_any_element()
490                } else {
491                    Button::new("sign_in", "Sign In")
492                        .full_width()
493                        .style(ButtonStyle::Outlined)
494                        .size(ButtonSize::Medium)
495                        .key_binding(
496                            KeyBinding::for_action_in(&SignIn, &self.focus_handle, window, cx)
497                                .map(|kb| kb.size(rems_from_px(12.))),
498                        )
499                        .on_click(|_, window, cx| {
500                            window.dispatch_action(SignIn.boxed_clone(), cx);
501                        })
502                        .into_any_element()
503                },
504            )
505    }
506
507    fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) {
508        telemetry::event!("Welcome Skip Clicked");
509        go_to_welcome_page(cx);
510    }
511
512    fn handle_sign_in(_: &SignIn, window: &mut Window, cx: &mut App) {
513        let client = Client::global(cx);
514
515        window
516            .spawn(cx, async move |cx| {
517                client
518                    .sign_in_with_optional_connect(true, &cx)
519                    .await
520                    .notify_async_err(cx);
521            })
522            .detach();
523    }
524
525    fn handle_open_account(_: &OpenAccount, _: &mut Window, cx: &mut App) {
526        cx.open_url(&zed_urls::account_url(cx))
527    }
528
529    fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
530        let client = Client::global(cx);
531
532        match self.selected_page {
533            SelectedPage::Basics => crate::basics_page::render_basics_page(cx).into_any_element(),
534            SelectedPage::Editing => {
535                crate::editing_page::render_editing_page(window, cx).into_any_element()
536            }
537            SelectedPage::AiSetup => crate::ai_setup_page::render_ai_setup_page(
538                self.workspace.clone(),
539                self.user_store.clone(),
540                client,
541                window,
542                cx,
543            )
544            .into_any_element(),
545        }
546    }
547}
548
549impl Render for Onboarding {
550    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
551        h_flex()
552            .image_cache(gpui::retain_all("onboarding-page"))
553            .key_context({
554                let mut ctx = KeyContext::new_with_defaults();
555                ctx.add("Onboarding");
556                ctx.add("menu");
557                ctx
558            })
559            .track_focus(&self.focus_handle)
560            .size_full()
561            .bg(cx.theme().colors().editor_background)
562            .on_action(Self::on_finish)
563            .on_action(Self::handle_sign_in)
564            .on_action(Self::handle_open_account)
565            .on_action(cx.listener(|this, _: &ActivateBasicsPage, _, cx| {
566                this.set_page(SelectedPage::Basics, Some("action"), cx);
567            }))
568            .on_action(cx.listener(|this, _: &ActivateEditingPage, _, cx| {
569                this.set_page(SelectedPage::Editing, Some("action"), cx);
570            }))
571            .on_action(cx.listener(|this, _: &ActivateAISetupPage, _, cx| {
572                this.set_page(SelectedPage::AiSetup, Some("action"), cx);
573            }))
574            .on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| {
575                window.focus_next();
576                cx.notify();
577            }))
578            .on_action(cx.listener(|_, _: &menu::SelectPrevious, window, cx| {
579                window.focus_prev();
580                cx.notify();
581            }))
582            .child(
583                h_flex()
584                    .max_w(rems_from_px(1100.))
585                    .max_h(rems_from_px(850.))
586                    .size_full()
587                    .m_auto()
588                    .py_20()
589                    .px_12()
590                    .items_start()
591                    .gap_12()
592                    .child(self.render_nav(window, cx))
593                    .child(
594                        v_flex()
595                            .id("page-content")
596                            .size_full()
597                            .max_w_full()
598                            .min_w_0()
599                            .pl_12()
600                            .border_l_1()
601                            .border_color(cx.theme().colors().border_variant.opacity(0.5))
602                            .overflow_y_scroll()
603                            .child(self.render_page(window, cx)),
604                    ),
605            )
606    }
607}
608
609impl EventEmitter<ItemEvent> for Onboarding {}
610
611impl Focusable for Onboarding {
612    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
613        self.focus_handle.clone()
614    }
615}
616
617impl Item for Onboarding {
618    type Event = ItemEvent;
619
620    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
621        "Onboarding".into()
622    }
623
624    fn telemetry_event_text(&self) -> Option<&'static str> {
625        Some("Onboarding Page Opened")
626    }
627
628    fn show_toolbar(&self) -> bool {
629        false
630    }
631
632    fn clone_on_split(
633        &self,
634        _workspace_id: Option<WorkspaceId>,
635        _: &mut Window,
636        cx: &mut Context<Self>,
637    ) -> Option<Entity<Self>> {
638        Some(cx.new(|cx| Onboarding {
639            workspace: self.workspace.clone(),
640            user_store: self.user_store.clone(),
641            selected_page: self.selected_page,
642            focus_handle: cx.focus_handle(),
643            _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
644        }))
645    }
646
647    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
648        f(*event)
649    }
650}
651
652fn go_to_welcome_page(cx: &mut App) {
653    with_active_or_new_workspace(cx, |workspace, window, cx| {
654        let Some((onboarding_id, onboarding_idx)) = workspace
655            .active_pane()
656            .read(cx)
657            .items()
658            .enumerate()
659            .find_map(|(idx, item)| {
660                let _ = item.downcast::<Onboarding>()?;
661                Some((item.item_id(), idx))
662            })
663        else {
664            return;
665        };
666
667        workspace.active_pane().update(cx, |pane, cx| {
668            // Get the index here to get around the borrow checker
669            let idx = pane.items().enumerate().find_map(|(idx, item)| {
670                let _ = item.downcast::<WelcomePage>()?;
671                Some(idx)
672            });
673
674            if let Some(idx) = idx {
675                pane.activate_item(idx, true, true, window, cx);
676            } else {
677                let item = Box::new(WelcomePage::new(window, cx));
678                pane.add_item(item, true, true, Some(onboarding_idx), window, cx);
679            }
680
681            pane.remove_item(onboarding_id, false, false, window, cx);
682        });
683    });
684}
685
686pub async fn handle_import_vscode_settings(
687    workspace: WeakEntity<Workspace>,
688    source: VsCodeSettingsSource,
689    skip_prompt: bool,
690    fs: Arc<dyn Fs>,
691    cx: &mut AsyncWindowContext,
692) {
693    use util::truncate_and_remove_front;
694
695    let vscode_settings =
696        match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
697            Ok(vscode_settings) => vscode_settings,
698            Err(err) => {
699                zlog::error!("{err}");
700                let _ = cx.prompt(
701                    gpui::PromptLevel::Info,
702                    &format!("Could not find or load a {source} settings file"),
703                    None,
704                    &["Ok"],
705                );
706                return;
707            }
708        };
709
710    if !skip_prompt {
711        let prompt = cx.prompt(
712            gpui::PromptLevel::Warning,
713            &format!(
714                "Importing {} settings may overwrite your existing settings. \
715                Will import settings from {}",
716                vscode_settings.source,
717                truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
718            ),
719            None,
720            &["Ok", "Cancel"],
721        );
722        let result = cx.spawn(async move |_| prompt.await.ok()).await;
723        if result != Some(0) {
724            return;
725        }
726    };
727
728    let Ok(result_channel) = cx.update(|_, cx| {
729        let source = vscode_settings.source;
730        let path = vscode_settings.path.clone();
731        let result_channel = cx
732            .global::<SettingsStore>()
733            .import_vscode_settings(fs, vscode_settings);
734        zlog::info!("Imported {source} settings from {}", path.display());
735        result_channel
736    }) else {
737        return;
738    };
739
740    let result = result_channel.await;
741    workspace
742        .update_in(cx, |workspace, _, cx| match result {
743            Ok(_) => {
744                let confirmation_toast = StatusToast::new(
745                    format!("Your {} settings were successfully imported.", source),
746                    cx,
747                    |this, _| {
748                        this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
749                            .dismiss_button(true)
750                    },
751                );
752                SettingsImportState::update(cx, |state, _| match source {
753                    VsCodeSettingsSource::VsCode => {
754                        state.vscode = true;
755                    }
756                    VsCodeSettingsSource::Cursor => {
757                        state.cursor = true;
758                    }
759                });
760                workspace.toggle_status_toast(confirmation_toast, cx);
761            }
762            Err(_) => {
763                let error_toast = StatusToast::new(
764                    "Failed to import settings. See log for details",
765                    cx,
766                    |this, _| {
767                        this.icon(ToastIcon::new(IconName::Close).color(Color::Error))
768                            .action("Open Log", |window, cx| {
769                                window.dispatch_action(workspace::OpenLog.boxed_clone(), cx)
770                            })
771                            .dismiss_button(true)
772                    },
773                );
774                workspace.toggle_status_toast(error_toast, cx);
775            }
776        })
777        .ok();
778}
779
780#[derive(Default, Copy, Clone)]
781pub struct SettingsImportState {
782    pub cursor: bool,
783    pub vscode: bool,
784}
785
786impl Global for SettingsImportState {}
787
788impl SettingsImportState {
789    pub fn global(cx: &App) -> Self {
790        cx.try_global().cloned().unwrap_or_default()
791    }
792    pub fn update<R>(cx: &mut App, f: impl FnOnce(&mut Self, &mut App) -> R) -> R {
793        cx.update_default_global(f)
794    }
795}
796
797impl workspace::SerializableItem for Onboarding {
798    fn serialized_item_kind() -> &'static str {
799        "OnboardingPage"
800    }
801
802    fn cleanup(
803        workspace_id: workspace::WorkspaceId,
804        alive_items: Vec<workspace::ItemId>,
805        _window: &mut Window,
806        cx: &mut App,
807    ) -> gpui::Task<gpui::Result<()>> {
808        workspace::delete_unloaded_items(
809            alive_items,
810            workspace_id,
811            "onboarding_pages",
812            &persistence::ONBOARDING_PAGES,
813            cx,
814        )
815    }
816
817    fn deserialize(
818        _project: Entity<project::Project>,
819        workspace: WeakEntity<Workspace>,
820        workspace_id: workspace::WorkspaceId,
821        item_id: workspace::ItemId,
822        window: &mut Window,
823        cx: &mut App,
824    ) -> gpui::Task<gpui::Result<Entity<Self>>> {
825        window.spawn(cx, async move |cx| {
826            if let Some(page_number) =
827                persistence::ONBOARDING_PAGES.get_onboarding_page(item_id, workspace_id)?
828            {
829                let page = match page_number {
830                    0 => Some(SelectedPage::Basics),
831                    1 => Some(SelectedPage::Editing),
832                    2 => Some(SelectedPage::AiSetup),
833                    _ => None,
834                };
835                workspace.update(cx, |workspace, cx| {
836                    let onboarding_page = Onboarding::new(workspace, cx);
837                    if let Some(page) = page {
838                        zlog::info!("Onboarding page {page:?} loaded");
839                        onboarding_page.update(cx, |onboarding_page, cx| {
840                            onboarding_page.set_page(page, None, cx);
841                        })
842                    }
843                    onboarding_page
844                })
845            } else {
846                Err(anyhow::anyhow!("No onboarding page to deserialize"))
847            }
848        })
849    }
850
851    fn serialize(
852        &mut self,
853        workspace: &mut Workspace,
854        item_id: workspace::ItemId,
855        _closing: bool,
856        _window: &mut Window,
857        cx: &mut ui::Context<Self>,
858    ) -> Option<gpui::Task<gpui::Result<()>>> {
859        let workspace_id = workspace.database_id()?;
860        let page_number = self.selected_page as u16;
861        Some(cx.background_spawn(async move {
862            persistence::ONBOARDING_PAGES
863                .save_onboarding_page(item_id, workspace_id, page_number)
864                .await
865        }))
866    }
867
868    fn should_serialize(&self, event: &Self::Event) -> bool {
869        event == &ItemEvent::UpdateTab
870    }
871}
872
873mod persistence {
874    use db::{define_connection, query, sqlez_macros::sql};
875    use workspace::WorkspaceDb;
876
877    define_connection! {
878        pub static ref ONBOARDING_PAGES: OnboardingPagesDb<WorkspaceDb> =
879            &[
880                sql!(
881                    CREATE TABLE onboarding_pages (
882                        workspace_id INTEGER,
883                        item_id INTEGER UNIQUE,
884                        page_number INTEGER,
885
886                        PRIMARY KEY(workspace_id, item_id),
887                        FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
888                        ON DELETE CASCADE
889                    ) STRICT;
890                ),
891            ];
892    }
893
894    impl OnboardingPagesDb {
895        query! {
896            pub async fn save_onboarding_page(
897                item_id: workspace::ItemId,
898                workspace_id: workspace::WorkspaceId,
899                page_number: u16
900            ) -> Result<()> {
901                INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id, page_number)
902                VALUES (?, ?, ?)
903            }
904        }
905
906        query! {
907            pub fn get_onboarding_page(
908                item_id: workspace::ItemId,
909                workspace_id: workspace::WorkspaceId
910            ) -> Result<Option<u16>> {
911                SELECT page_number
912                FROM onboarding_pages
913                WHERE item_id = ? AND workspace_id = ?
914            }
915        }
916    }
917}