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