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                            window.dispatch_action(SignIn.boxed_clone(), cx);
493                        })
494                        .into_any_element()
495                },
496            )
497    }
498
499    fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) {
500        telemetry::event!("Welcome Skip Clicked");
501        go_to_welcome_page(cx);
502    }
503
504    fn handle_sign_in(_: &SignIn, window: &mut Window, cx: &mut App) {
505        let client = Client::global(cx);
506
507        window
508            .spawn(cx, async move |cx| {
509                client
510                    .sign_in_with_optional_connect(true, cx)
511                    .await
512                    .notify_async_err(cx);
513            })
514            .detach();
515    }
516
517    fn handle_open_account(_: &OpenAccount, _: &mut Window, cx: &mut App) {
518        cx.open_url(&zed_urls::account_url(cx))
519    }
520
521    fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
522        let client = Client::global(cx);
523
524        match self.selected_page {
525            SelectedPage::Basics => crate::basics_page::render_basics_page(cx).into_any_element(),
526            SelectedPage::Editing => {
527                crate::editing_page::render_editing_page(window, cx).into_any_element()
528            }
529            SelectedPage::AiSetup => crate::ai_setup_page::render_ai_setup_page(
530                self.workspace.clone(),
531                self.user_store.clone(),
532                client,
533                window,
534                cx,
535            )
536            .into_any_element(),
537        }
538    }
539}
540
541impl Render for Onboarding {
542    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
543        h_flex()
544            .image_cache(gpui::retain_all("onboarding-page"))
545            .key_context({
546                let mut ctx = KeyContext::new_with_defaults();
547                ctx.add("Onboarding");
548                ctx.add("menu");
549                ctx
550            })
551            .track_focus(&self.focus_handle)
552            .size_full()
553            .bg(cx.theme().colors().editor_background)
554            .on_action(Self::on_finish)
555            .on_action(Self::handle_sign_in)
556            .on_action(Self::handle_open_account)
557            .on_action(cx.listener(|this, _: &ActivateBasicsPage, _, cx| {
558                this.set_page(SelectedPage::Basics, Some("action"), cx);
559            }))
560            .on_action(cx.listener(|this, _: &ActivateEditingPage, _, cx| {
561                this.set_page(SelectedPage::Editing, Some("action"), cx);
562            }))
563            .on_action(cx.listener(|this, _: &ActivateAISetupPage, _, cx| {
564                this.set_page(SelectedPage::AiSetup, Some("action"), cx);
565            }))
566            .on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| {
567                window.focus_next();
568                cx.notify();
569            }))
570            .on_action(cx.listener(|_, _: &menu::SelectPrevious, window, cx| {
571                window.focus_prev();
572                cx.notify();
573            }))
574            .child(
575                h_flex()
576                    .max_w(rems_from_px(1100.))
577                    .max_h(rems_from_px(850.))
578                    .size_full()
579                    .m_auto()
580                    .py_20()
581                    .px_12()
582                    .items_start()
583                    .gap_12()
584                    .child(self.render_nav(window, cx))
585                    .child(
586                        v_flex()
587                            .id("page-content")
588                            .size_full()
589                            .max_w_full()
590                            .min_w_0()
591                            .pl_12()
592                            .border_l_1()
593                            .border_color(cx.theme().colors().border_variant.opacity(0.5))
594                            .overflow_y_scroll()
595                            .child(self.render_page(window, cx)),
596                    ),
597            )
598    }
599}
600
601impl EventEmitter<ItemEvent> for Onboarding {}
602
603impl Focusable for Onboarding {
604    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
605        self.focus_handle.clone()
606    }
607}
608
609impl Item for Onboarding {
610    type Event = ItemEvent;
611
612    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
613        "Onboarding".into()
614    }
615
616    fn telemetry_event_text(&self) -> Option<&'static str> {
617        Some("Onboarding Page Opened")
618    }
619
620    fn show_toolbar(&self) -> bool {
621        false
622    }
623
624    fn clone_on_split(
625        &self,
626        _workspace_id: Option<WorkspaceId>,
627        _: &mut Window,
628        cx: &mut Context<Self>,
629    ) -> Option<Entity<Self>> {
630        Some(cx.new(|cx| Onboarding {
631            workspace: self.workspace.clone(),
632            user_store: self.user_store.clone(),
633            selected_page: self.selected_page,
634            focus_handle: cx.focus_handle(),
635            _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
636        }))
637    }
638
639    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
640        f(*event)
641    }
642}
643
644fn go_to_welcome_page(cx: &mut App) {
645    with_active_or_new_workspace(cx, |workspace, window, cx| {
646        let Some((onboarding_id, onboarding_idx)) = workspace
647            .active_pane()
648            .read(cx)
649            .items()
650            .enumerate()
651            .find_map(|(idx, item)| {
652                let _ = item.downcast::<Onboarding>()?;
653                Some((item.item_id(), idx))
654            })
655        else {
656            return;
657        };
658
659        workspace.active_pane().update(cx, |pane, cx| {
660            // Get the index here to get around the borrow checker
661            let idx = pane.items().enumerate().find_map(|(idx, item)| {
662                let _ = item.downcast::<WelcomePage>()?;
663                Some(idx)
664            });
665
666            if let Some(idx) = idx {
667                pane.activate_item(idx, true, true, window, cx);
668            } else {
669                let item = Box::new(WelcomePage::new(window, cx));
670                pane.add_item(item, true, true, Some(onboarding_idx), window, cx);
671            }
672
673            pane.remove_item(onboarding_id, false, false, window, cx);
674        });
675    });
676}
677
678pub async fn handle_import_vscode_settings(
679    workspace: WeakEntity<Workspace>,
680    source: VsCodeSettingsSource,
681    skip_prompt: bool,
682    fs: Arc<dyn Fs>,
683    cx: &mut AsyncWindowContext,
684) {
685    use util::truncate_and_remove_front;
686
687    let vscode_settings =
688        match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
689            Ok(vscode_settings) => vscode_settings,
690            Err(err) => {
691                zlog::error!("{err}");
692                let _ = cx.prompt(
693                    gpui::PromptLevel::Info,
694                    &format!("Could not find or load a {source} settings file"),
695                    None,
696                    &["Ok"],
697                );
698                return;
699            }
700        };
701
702    if !skip_prompt {
703        let prompt = cx.prompt(
704            gpui::PromptLevel::Warning,
705            &format!(
706                "Importing {} settings may overwrite your existing settings. \
707                Will import settings from {}",
708                vscode_settings.source,
709                truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
710            ),
711            None,
712            &["Ok", "Cancel"],
713        );
714        let result = cx.spawn(async move |_| prompt.await.ok()).await;
715        if result != Some(0) {
716            return;
717        }
718    };
719
720    let Ok(result_channel) = cx.update(|_, cx| {
721        let source = vscode_settings.source;
722        let path = vscode_settings.path.clone();
723        let result_channel = cx
724            .global::<SettingsStore>()
725            .import_vscode_settings(fs, vscode_settings);
726        zlog::info!("Imported {source} settings from {}", path.display());
727        result_channel
728    }) else {
729        return;
730    };
731
732    let result = result_channel.await;
733    workspace
734        .update_in(cx, |workspace, _, cx| match result {
735            Ok(_) => {
736                let confirmation_toast = StatusToast::new(
737                    format!("Your {} settings were successfully imported.", source),
738                    cx,
739                    |this, _| {
740                        this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
741                            .dismiss_button(true)
742                    },
743                );
744                SettingsImportState::update(cx, |state, _| match source {
745                    VsCodeSettingsSource::VsCode => {
746                        state.vscode = true;
747                    }
748                    VsCodeSettingsSource::Cursor => {
749                        state.cursor = true;
750                    }
751                });
752                workspace.toggle_status_toast(confirmation_toast, cx);
753            }
754            Err(_) => {
755                let error_toast = StatusToast::new(
756                    "Failed to import settings. See log for details",
757                    cx,
758                    |this, _| {
759                        this.icon(ToastIcon::new(IconName::Close).color(Color::Error))
760                            .action("Open Log", |window, cx| {
761                                window.dispatch_action(workspace::OpenLog.boxed_clone(), cx)
762                            })
763                            .dismiss_button(true)
764                    },
765                );
766                workspace.toggle_status_toast(error_toast, cx);
767            }
768        })
769        .ok();
770}
771
772#[derive(Default, Copy, Clone)]
773pub struct SettingsImportState {
774    pub cursor: bool,
775    pub vscode: bool,
776}
777
778impl Global for SettingsImportState {}
779
780impl SettingsImportState {
781    pub fn global(cx: &App) -> Self {
782        cx.try_global().cloned().unwrap_or_default()
783    }
784    pub fn update<R>(cx: &mut App, f: impl FnOnce(&mut Self, &mut App) -> R) -> R {
785        cx.update_default_global(f)
786    }
787}
788
789impl workspace::SerializableItem for Onboarding {
790    fn serialized_item_kind() -> &'static str {
791        "OnboardingPage"
792    }
793
794    fn cleanup(
795        workspace_id: workspace::WorkspaceId,
796        alive_items: Vec<workspace::ItemId>,
797        _window: &mut Window,
798        cx: &mut App,
799    ) -> gpui::Task<gpui::Result<()>> {
800        workspace::delete_unloaded_items(
801            alive_items,
802            workspace_id,
803            "onboarding_pages",
804            &persistence::ONBOARDING_PAGES,
805            cx,
806        )
807    }
808
809    fn deserialize(
810        _project: Entity<project::Project>,
811        workspace: WeakEntity<Workspace>,
812        workspace_id: workspace::WorkspaceId,
813        item_id: workspace::ItemId,
814        window: &mut Window,
815        cx: &mut App,
816    ) -> gpui::Task<gpui::Result<Entity<Self>>> {
817        window.spawn(cx, async move |cx| {
818            if let Some(page_number) =
819                persistence::ONBOARDING_PAGES.get_onboarding_page(item_id, workspace_id)?
820            {
821                let page = match page_number {
822                    0 => Some(SelectedPage::Basics),
823                    1 => Some(SelectedPage::Editing),
824                    2 => Some(SelectedPage::AiSetup),
825                    _ => None,
826                };
827                workspace.update(cx, |workspace, cx| {
828                    let onboarding_page = Onboarding::new(workspace, cx);
829                    if let Some(page) = page {
830                        zlog::info!("Onboarding page {page:?} loaded");
831                        onboarding_page.update(cx, |onboarding_page, cx| {
832                            onboarding_page.set_page(page, None, cx);
833                        })
834                    }
835                    onboarding_page
836                })
837            } else {
838                Err(anyhow::anyhow!("No onboarding page to deserialize"))
839            }
840        })
841    }
842
843    fn serialize(
844        &mut self,
845        workspace: &mut Workspace,
846        item_id: workspace::ItemId,
847        _closing: bool,
848        _window: &mut Window,
849        cx: &mut ui::Context<Self>,
850    ) -> Option<gpui::Task<gpui::Result<()>>> {
851        let workspace_id = workspace.database_id()?;
852        let page_number = self.selected_page as u16;
853        Some(cx.background_spawn(async move {
854            persistence::ONBOARDING_PAGES
855                .save_onboarding_page(item_id, workspace_id, page_number)
856                .await
857        }))
858    }
859
860    fn should_serialize(&self, event: &Self::Event) -> bool {
861        event == &ItemEvent::UpdateTab
862    }
863}
864
865mod persistence {
866    use db::{
867        query,
868        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
869        sqlez_macros::sql,
870    };
871    use workspace::WorkspaceDb;
872
873    pub struct OnboardingPagesDb(ThreadSafeConnection);
874
875    impl Domain for OnboardingPagesDb {
876        const NAME: &str = stringify!(OnboardingPagesDb);
877
878        const MIGRATIONS: &[&str] = &[sql!(
879                    CREATE TABLE onboarding_pages (
880                        workspace_id INTEGER,
881                        item_id INTEGER UNIQUE,
882                        page_number INTEGER,
883
884                        PRIMARY KEY(workspace_id, item_id),
885                        FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
886                        ON DELETE CASCADE
887                    ) STRICT;
888        )];
889    }
890
891    db::static_connection!(ONBOARDING_PAGES, OnboardingPagesDb, [WorkspaceDb]);
892
893    impl OnboardingPagesDb {
894        query! {
895            pub async fn save_onboarding_page(
896                item_id: workspace::ItemId,
897                workspace_id: workspace::WorkspaceId,
898                page_number: u16
899            ) -> Result<()> {
900                INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id, page_number)
901                VALUES (?, ?, ?)
902            }
903        }
904
905        query! {
906            pub fn get_onboarding_page(
907                item_id: workspace::ItemId,
908                workspace_id: workspace::WorkspaceId
909            ) -> Result<Option<u16>> {
910                SELECT page_number
911                FROM onboarding_pages
912                WHERE item_id = ? AND workspace_id = ?
913            }
914        }
915    }
916}