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