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