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