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