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