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 Some(cx.new(|cx| Onboarding {
569 workspace: self.workspace.clone(),
570 user_store: self.user_store.clone(),
571 selected_page: self.selected_page,
572 focus_handle: cx.focus_handle(),
573 _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
574 }))
575 }
576
577 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
578 f(*event)
579 }
580}
581
582fn go_to_welcome_page(cx: &mut App) {
583 with_active_or_new_workspace(cx, |workspace, window, cx| {
584 let Some((onboarding_id, onboarding_idx)) = workspace
585 .active_pane()
586 .read(cx)
587 .items()
588 .enumerate()
589 .find_map(|(idx, item)| {
590 let _ = item.downcast::<Onboarding>()?;
591 Some((item.item_id(), idx))
592 })
593 else {
594 return;
595 };
596
597 workspace.active_pane().update(cx, |pane, cx| {
598 // Get the index here to get around the borrow checker
599 let idx = pane.items().enumerate().find_map(|(idx, item)| {
600 let _ = item.downcast::<WelcomePage>()?;
601 Some(idx)
602 });
603
604 if let Some(idx) = idx {
605 pane.activate_item(idx, true, true, window, cx);
606 } else {
607 let item = Box::new(WelcomePage::new(window, cx));
608 pane.add_item(item, true, true, Some(onboarding_idx), window, cx);
609 }
610
611 pane.remove_item(onboarding_id, false, false, window, cx);
612 });
613 });
614}
615
616pub async fn handle_import_vscode_settings(
617 workspace: WeakEntity<Workspace>,
618 source: VsCodeSettingsSource,
619 skip_prompt: bool,
620 fs: Arc<dyn Fs>,
621 cx: &mut AsyncWindowContext,
622) {
623 use util::truncate_and_remove_front;
624
625 let vscode_settings =
626 match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
627 Ok(vscode_settings) => vscode_settings,
628 Err(err) => {
629 zlog::error!("{err}");
630 let _ = cx.prompt(
631 gpui::PromptLevel::Info,
632 &format!("Could not find or load a {source} settings file"),
633 None,
634 &["Ok"],
635 );
636 return;
637 }
638 };
639
640 if !skip_prompt {
641 let prompt = cx.prompt(
642 gpui::PromptLevel::Warning,
643 &format!(
644 "Importing {} settings may overwrite your existing settings. \
645 Will import settings from {}",
646 vscode_settings.source,
647 truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
648 ),
649 None,
650 &["Ok", "Cancel"],
651 );
652 let result = cx.spawn(async move |_| prompt.await.ok()).await;
653 if result != Some(0) {
654 return;
655 }
656 };
657
658 let Ok(result_channel) = cx.update(|_, cx| {
659 let source = vscode_settings.source;
660 let path = vscode_settings.path.clone();
661 let result_channel = cx
662 .global::<SettingsStore>()
663 .import_vscode_settings(fs, vscode_settings);
664 zlog::info!("Imported {source} settings from {}", path.display());
665 result_channel
666 }) else {
667 return;
668 };
669
670 let result = result_channel.await;
671 workspace
672 .update_in(cx, |workspace, _, cx| match result {
673 Ok(_) => {
674 let confirmation_toast = StatusToast::new(
675 format!("Your {} settings were successfully imported.", source),
676 cx,
677 |this, _| {
678 this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
679 .dismiss_button(true)
680 },
681 );
682 SettingsImportState::update(cx, |state, _| match source {
683 VsCodeSettingsSource::VsCode => {
684 state.vscode = true;
685 }
686 VsCodeSettingsSource::Cursor => {
687 state.cursor = true;
688 }
689 });
690 workspace.toggle_status_toast(confirmation_toast, cx);
691 }
692 Err(_) => {
693 let error_toast = StatusToast::new(
694 "Failed to import settings. See log for details",
695 cx,
696 |this, _| {
697 this.icon(ToastIcon::new(IconName::X).color(Color::Error))
698 .action("Open Log", |window, cx| {
699 window.dispatch_action(workspace::OpenLog.boxed_clone(), cx)
700 })
701 .dismiss_button(true)
702 },
703 );
704 workspace.toggle_status_toast(error_toast, cx);
705 }
706 })
707 .ok();
708}
709
710#[derive(Default, Copy, Clone)]
711pub struct SettingsImportState {
712 pub cursor: bool,
713 pub vscode: bool,
714}
715
716impl Global for SettingsImportState {}
717
718impl SettingsImportState {
719 pub fn global(cx: &App) -> Self {
720 cx.try_global().cloned().unwrap_or_default()
721 }
722 pub fn update<R>(cx: &mut App, f: impl FnOnce(&mut Self, &mut App) -> R) -> R {
723 cx.update_default_global(f)
724 }
725}
726
727impl workspace::SerializableItem for Onboarding {
728 fn serialized_item_kind() -> &'static str {
729 "OnboardingPage"
730 }
731
732 fn cleanup(
733 workspace_id: workspace::WorkspaceId,
734 alive_items: Vec<workspace::ItemId>,
735 _window: &mut Window,
736 cx: &mut App,
737 ) -> gpui::Task<gpui::Result<()>> {
738 workspace::delete_unloaded_items(
739 alive_items,
740 workspace_id,
741 "onboarding_pages",
742 &persistence::ONBOARDING_PAGES,
743 cx,
744 )
745 }
746
747 fn deserialize(
748 _project: Entity<project::Project>,
749 workspace: WeakEntity<Workspace>,
750 workspace_id: workspace::WorkspaceId,
751 item_id: workspace::ItemId,
752 window: &mut Window,
753 cx: &mut App,
754 ) -> gpui::Task<gpui::Result<Entity<Self>>> {
755 window.spawn(cx, async move |cx| {
756 if let Some(page_number) =
757 persistence::ONBOARDING_PAGES.get_onboarding_page(item_id, workspace_id)?
758 {
759 let page = match page_number {
760 0 => Some(SelectedPage::Basics),
761 1 => Some(SelectedPage::Editing),
762 2 => Some(SelectedPage::AiSetup),
763 _ => None,
764 };
765 workspace.update(cx, |workspace, cx| {
766 let onboarding_page = Onboarding::new(workspace, cx);
767 if let Some(page) = page {
768 zlog::info!("Onboarding page {page:?} loaded");
769 onboarding_page.update(cx, |onboarding_page, cx| {
770 onboarding_page.set_page(page, cx);
771 })
772 }
773 onboarding_page
774 })
775 } else {
776 Err(anyhow::anyhow!("No onboarding page to deserialize"))
777 }
778 })
779 }
780
781 fn serialize(
782 &mut self,
783 workspace: &mut Workspace,
784 item_id: workspace::ItemId,
785 _closing: bool,
786 _window: &mut Window,
787 cx: &mut ui::Context<Self>,
788 ) -> Option<gpui::Task<gpui::Result<()>>> {
789 let workspace_id = workspace.database_id()?;
790 let page_number = self.selected_page as u16;
791 Some(cx.background_spawn(async move {
792 persistence::ONBOARDING_PAGES
793 .save_onboarding_page(item_id, workspace_id, page_number)
794 .await
795 }))
796 }
797
798 fn should_serialize(&self, event: &Self::Event) -> bool {
799 event == &ItemEvent::UpdateTab
800 }
801}
802
803mod persistence {
804 use db::{define_connection, query, sqlez_macros::sql};
805 use workspace::WorkspaceDb;
806
807 define_connection! {
808 pub static ref ONBOARDING_PAGES: OnboardingPagesDb<WorkspaceDb> =
809 &[
810 sql!(
811 CREATE TABLE onboarding_pages (
812 workspace_id INTEGER,
813 item_id INTEGER UNIQUE,
814 page_number INTEGER,
815
816 PRIMARY KEY(workspace_id, item_id),
817 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
818 ON DELETE CASCADE
819 ) STRICT;
820 ),
821 ];
822 }
823
824 impl OnboardingPagesDb {
825 query! {
826 pub async fn save_onboarding_page(
827 item_id: workspace::ItemId,
828 workspace_id: workspace::WorkspaceId,
829 page_number: u16
830 ) -> Result<()> {
831 INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id, page_number)
832 VALUES (?, ?, ?)
833 }
834 }
835
836 query! {
837 pub fn get_onboarding_page(
838 item_id: workspace::ItemId,
839 workspace_id: workspace::WorkspaceId
840 ) -> Result<Option<u16>> {
841 SELECT page_number
842 FROM onboarding_pages
843 WHERE item_id = ? AND workspace_id = ?
844 }
845 }
846 }
847}