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