onboarding.rs

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