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