onboarding.rs

  1use crate::welcome::{ShowWelcome, WelcomePage};
  2use client::{Client, UserStore};
  3use command_palette_hooks::CommandPaletteFilter;
  4use db::kvp::KEY_VALUE_STORE;
  5use feature_flags::{FeatureFlag, FeatureFlagViewExt as _};
  6use fs::Fs;
  7use gpui::{
  8    Action, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, EventEmitter,
  9    FocusHandle, Focusable, Global, IntoElement, KeyContext, Render, SharedString, Subscription,
 10    Task, WeakEntity, Window, actions,
 11};
 12use notifications::status_toast::{StatusToast, ToastIcon};
 13use schemars::JsonSchema;
 14use serde::Deserialize;
 15use settings::{SettingsStore, VsCodeSettingsSource};
 16use std::sync::Arc;
 17use ui::{
 18    Avatar, ButtonLike, FluentBuilder, Headline, KeyBinding, ParentElement as _,
 19    StatefulInteractiveElement, Vector, VectorName, prelude::*, rems_from_px,
 20};
 21use workspace::{
 22    AppState, Workspace, WorkspaceId,
 23    dock::DockPosition,
 24    item::{Item, ItemEvent},
 25    notifications::NotifyResultExt as _,
 26    open_new, register_serializable_item, with_active_or_new_workspace,
 27};
 28
 29mod ai_setup_page;
 30mod basics_page;
 31mod editing_page;
 32mod theme_preview;
 33mod welcome;
 34
 35pub struct OnBoardingFeatureFlag {}
 36
 37impl FeatureFlag for OnBoardingFeatureFlag {
 38    const NAME: &'static str = "onboarding";
 39}
 40
 41/// Imports settings from Visual Studio Code.
 42#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
 43#[action(namespace = zed)]
 44#[serde(deny_unknown_fields)]
 45pub struct ImportVsCodeSettings {
 46    #[serde(default)]
 47    pub skip_prompt: bool,
 48}
 49
 50/// Imports settings from Cursor editor.
 51#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
 52#[action(namespace = zed)]
 53#[serde(deny_unknown_fields)]
 54pub struct ImportCursorSettings {
 55    #[serde(default)]
 56    pub skip_prompt: bool,
 57}
 58
 59pub const FIRST_OPEN: &str = "first_open";
 60
 61actions!(
 62    zed,
 63    [
 64        /// Opens the onboarding view.
 65        OpenOnboarding
 66    ]
 67);
 68
 69actions!(
 70    onboarding,
 71    [
 72        /// Activates the Basics page.
 73        ActivateBasicsPage,
 74        /// Activates the Editing page.
 75        ActivateEditingPage,
 76        /// Activates the AI Setup page.
 77        ActivateAISetupPage,
 78        /// Finish the onboarding process.
 79        Finish,
 80        /// 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}