onboarding.rs

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