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        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}