onboarding.rs

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