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