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