onboarding.rs

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