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 .size_full()
555 .m_auto()
556 .py_20()
557 .px_12()
558 .items_start()
559 .gap_12()
560 .child(self.render_nav(window, cx))
561 .child(
562 v_flex()
563 .max_w_full()
564 .min_w_0()
565 .pl_12()
566 .border_l_1()
567 .border_color(cx.theme().colors().border_variant.opacity(0.5))
568 .size_full()
569 .child(self.render_page(window, cx)),
570 ),
571 )
572 }
573}
574
575impl EventEmitter<ItemEvent> for Onboarding {}
576
577impl Focusable for Onboarding {
578 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
579 self.focus_handle.clone()
580 }
581}
582
583impl Item for Onboarding {
584 type Event = ItemEvent;
585
586 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
587 "Onboarding".into()
588 }
589
590 fn telemetry_event_text(&self) -> Option<&'static str> {
591 Some("Onboarding Page Opened")
592 }
593
594 fn show_toolbar(&self) -> bool {
595 false
596 }
597
598 fn clone_on_split(
599 &self,
600 _workspace_id: Option<WorkspaceId>,
601 _: &mut Window,
602 cx: &mut Context<Self>,
603 ) -> Option<Entity<Self>> {
604 Some(cx.new(|cx| Onboarding {
605 workspace: self.workspace.clone(),
606 user_store: self.user_store.clone(),
607 selected_page: self.selected_page,
608 focus_handle: cx.focus_handle(),
609 _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
610 }))
611 }
612
613 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
614 f(*event)
615 }
616}
617
618fn go_to_welcome_page(cx: &mut App) {
619 with_active_or_new_workspace(cx, |workspace, window, cx| {
620 let Some((onboarding_id, onboarding_idx)) = workspace
621 .active_pane()
622 .read(cx)
623 .items()
624 .enumerate()
625 .find_map(|(idx, item)| {
626 let _ = item.downcast::<Onboarding>()?;
627 Some((item.item_id(), idx))
628 })
629 else {
630 return;
631 };
632
633 workspace.active_pane().update(cx, |pane, cx| {
634 // Get the index here to get around the borrow checker
635 let idx = pane.items().enumerate().find_map(|(idx, item)| {
636 let _ = item.downcast::<WelcomePage>()?;
637 Some(idx)
638 });
639
640 if let Some(idx) = idx {
641 pane.activate_item(idx, true, true, window, cx);
642 } else {
643 let item = Box::new(WelcomePage::new(window, cx));
644 pane.add_item(item, true, true, Some(onboarding_idx), window, cx);
645 }
646
647 pane.remove_item(onboarding_id, false, false, window, cx);
648 });
649 });
650}
651
652pub async fn handle_import_vscode_settings(
653 workspace: WeakEntity<Workspace>,
654 source: VsCodeSettingsSource,
655 skip_prompt: bool,
656 fs: Arc<dyn Fs>,
657 cx: &mut AsyncWindowContext,
658) {
659 use util::truncate_and_remove_front;
660
661 let vscode_settings =
662 match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
663 Ok(vscode_settings) => vscode_settings,
664 Err(err) => {
665 zlog::error!("{err}");
666 let _ = cx.prompt(
667 gpui::PromptLevel::Info,
668 &format!("Could not find or load a {source} settings file"),
669 None,
670 &["Ok"],
671 );
672 return;
673 }
674 };
675
676 if !skip_prompt {
677 let prompt = cx.prompt(
678 gpui::PromptLevel::Warning,
679 &format!(
680 "Importing {} settings may overwrite your existing settings. \
681 Will import settings from {}",
682 vscode_settings.source,
683 truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
684 ),
685 None,
686 &["Ok", "Cancel"],
687 );
688 let result = cx.spawn(async move |_| prompt.await.ok()).await;
689 if result != Some(0) {
690 return;
691 }
692 };
693
694 let Ok(result_channel) = cx.update(|_, cx| {
695 let source = vscode_settings.source;
696 let path = vscode_settings.path.clone();
697 let result_channel = cx
698 .global::<SettingsStore>()
699 .import_vscode_settings(fs, vscode_settings);
700 zlog::info!("Imported {source} settings from {}", path.display());
701 result_channel
702 }) else {
703 return;
704 };
705
706 let result = result_channel.await;
707 workspace
708 .update_in(cx, |workspace, _, cx| match result {
709 Ok(_) => {
710 let confirmation_toast = StatusToast::new(
711 format!("Your {} settings were successfully imported.", source),
712 cx,
713 |this, _| {
714 this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
715 .dismiss_button(true)
716 },
717 );
718 SettingsImportState::update(cx, |state, _| match source {
719 VsCodeSettingsSource::VsCode => {
720 state.vscode = true;
721 }
722 VsCodeSettingsSource::Cursor => {
723 state.cursor = true;
724 }
725 });
726 workspace.toggle_status_toast(confirmation_toast, cx);
727 }
728 Err(_) => {
729 let error_toast = StatusToast::new(
730 "Failed to import settings. See log for details",
731 cx,
732 |this, _| {
733 this.icon(ToastIcon::new(IconName::Close).color(Color::Error))
734 .action("Open Log", |window, cx| {
735 window.dispatch_action(workspace::OpenLog.boxed_clone(), cx)
736 })
737 .dismiss_button(true)
738 },
739 );
740 workspace.toggle_status_toast(error_toast, cx);
741 }
742 })
743 .ok();
744}
745
746#[derive(Default, Copy, Clone)]
747pub struct SettingsImportState {
748 pub cursor: bool,
749 pub vscode: bool,
750}
751
752impl Global for SettingsImportState {}
753
754impl SettingsImportState {
755 pub fn global(cx: &App) -> Self {
756 cx.try_global().cloned().unwrap_or_default()
757 }
758 pub fn update<R>(cx: &mut App, f: impl FnOnce(&mut Self, &mut App) -> R) -> R {
759 cx.update_default_global(f)
760 }
761}
762
763impl workspace::SerializableItem for Onboarding {
764 fn serialized_item_kind() -> &'static str {
765 "OnboardingPage"
766 }
767
768 fn cleanup(
769 workspace_id: workspace::WorkspaceId,
770 alive_items: Vec<workspace::ItemId>,
771 _window: &mut Window,
772 cx: &mut App,
773 ) -> gpui::Task<gpui::Result<()>> {
774 workspace::delete_unloaded_items(
775 alive_items,
776 workspace_id,
777 "onboarding_pages",
778 &persistence::ONBOARDING_PAGES,
779 cx,
780 )
781 }
782
783 fn deserialize(
784 _project: Entity<project::Project>,
785 workspace: WeakEntity<Workspace>,
786 workspace_id: workspace::WorkspaceId,
787 item_id: workspace::ItemId,
788 window: &mut Window,
789 cx: &mut App,
790 ) -> gpui::Task<gpui::Result<Entity<Self>>> {
791 window.spawn(cx, async move |cx| {
792 if let Some(page_number) =
793 persistence::ONBOARDING_PAGES.get_onboarding_page(item_id, workspace_id)?
794 {
795 let page = match page_number {
796 0 => Some(SelectedPage::Basics),
797 1 => Some(SelectedPage::Editing),
798 2 => Some(SelectedPage::AiSetup),
799 _ => None,
800 };
801 workspace.update(cx, |workspace, cx| {
802 let onboarding_page = Onboarding::new(workspace, cx);
803 if let Some(page) = page {
804 zlog::info!("Onboarding page {page:?} loaded");
805 onboarding_page.update(cx, |onboarding_page, cx| {
806 onboarding_page.set_page(page, cx);
807 })
808 }
809 onboarding_page
810 })
811 } else {
812 Err(anyhow::anyhow!("No onboarding page to deserialize"))
813 }
814 })
815 }
816
817 fn serialize(
818 &mut self,
819 workspace: &mut Workspace,
820 item_id: workspace::ItemId,
821 _closing: bool,
822 _window: &mut Window,
823 cx: &mut ui::Context<Self>,
824 ) -> Option<gpui::Task<gpui::Result<()>>> {
825 let workspace_id = workspace.database_id()?;
826 let page_number = self.selected_page as u16;
827 Some(cx.background_spawn(async move {
828 persistence::ONBOARDING_PAGES
829 .save_onboarding_page(item_id, workspace_id, page_number)
830 .await
831 }))
832 }
833
834 fn should_serialize(&self, event: &Self::Event) -> bool {
835 event == &ItemEvent::UpdateTab
836 }
837}
838
839mod persistence {
840 use db::{define_connection, query, sqlez_macros::sql};
841 use workspace::WorkspaceDb;
842
843 define_connection! {
844 pub static ref ONBOARDING_PAGES: OnboardingPagesDb<WorkspaceDb> =
845 &[
846 sql!(
847 CREATE TABLE onboarding_pages (
848 workspace_id INTEGER,
849 item_id INTEGER UNIQUE,
850 page_number INTEGER,
851
852 PRIMARY KEY(workspace_id, item_id),
853 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
854 ON DELETE CASCADE
855 ) STRICT;
856 ),
857 ];
858 }
859
860 impl OnboardingPagesDb {
861 query! {
862 pub async fn save_onboarding_page(
863 item_id: workspace::ItemId,
864 workspace_id: workspace::WorkspaceId,
865 page_number: u16
866 ) -> Result<()> {
867 INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id, page_number)
868 VALUES (?, ?, ?)
869 }
870 }
871
872 query! {
873 pub fn get_onboarding_page(
874 item_id: workspace::ItemId,
875 workspace_id: workspace::WorkspaceId
876 ) -> Result<Option<u16>> {
877 SELECT page_number
878 FROM onboarding_pages
879 WHERE item_id = ? AND workspace_id = ?
880 }
881 }
882 }
883}