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}