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