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