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