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