onboarding.rs

  1use crate::multibuffer_hint::MultibufferHint;
  2use client::{Client, UserStore, zed_urls};
  3use db::kvp::KeyValueStore;
  4use fs::Fs;
  5use gpui::{
  6    Action, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, EventEmitter,
  7    FocusHandle, Focusable, Global, IntoElement, KeyContext, Render, ScrollHandle, SharedString,
  8    Subscription, Task, WeakEntity, Window, actions,
  9};
 10use notifications::status_toast::StatusToast;
 11use schemars::JsonSchema;
 12use serde::Deserialize;
 13use settings::{SettingsStore, VsCodeSettingsSource};
 14use std::sync::Arc;
 15use ui::{
 16    Divider, KeyBinding, ParentElement as _, StatefulInteractiveElement, Vector, VectorName,
 17    WithScrollbar as _, prelude::*, rems_from_px,
 18};
 19
 20pub use workspace::welcome::ShowWelcome;
 21use workspace::welcome::WelcomePage;
 22use workspace::{
 23    AppState, Workspace, WorkspaceId,
 24    dock::DockPosition,
 25    item::{Item, ItemEvent},
 26    notifications::NotifyResultExt as _,
 27    open_new, register_serializable_item, with_active_or_new_workspace,
 28};
 29use zed_actions::OpenOnboarding;
 30
 31mod base_keymap_picker;
 32mod basics_page;
 33pub mod multibuffer_hint;
 34mod theme_preview;
 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    onboarding,
 59    [
 60        /// Finish the onboarding process.
 61        Finish,
 62        /// Sign in while in the onboarding flow.
 63        SignIn,
 64        /// Open the user account in zed.dev while in the onboarding flow.
 65        OpenAccount,
 66        /// Resets the welcome screen hints to their initial state.
 67        ResetHints
 68    ]
 69);
 70
 71pub fn init(cx: &mut App) {
 72    cx.observe_new(|workspace: &mut Workspace, _, _cx| {
 73        workspace
 74            .register_action(|_workspace, _: &ResetHints, _, cx| MultibufferHint::set_count(0, cx));
 75    })
 76    .detach();
 77
 78    cx.on_action(|_: &OpenOnboarding, cx| {
 79        with_active_or_new_workspace(cx, |workspace, window, cx| {
 80            workspace
 81                .with_local_workspace(window, cx, |workspace, window, cx| {
 82                    let existing = workspace
 83                        .active_pane()
 84                        .read(cx)
 85                        .items()
 86                        .find_map(|item| item.downcast::<Onboarding>());
 87
 88                    if let Some(existing) = existing {
 89                        workspace.activate_item(&existing, true, true, window, cx);
 90                    } else {
 91                        let settings_page = Onboarding::new(workspace, cx);
 92                        workspace.add_item_to_active_pane(
 93                            Box::new(settings_page),
 94                            None,
 95                            true,
 96                            window,
 97                            cx,
 98                        )
 99                    }
100                })
101                .detach();
102        });
103    });
104
105    cx.on_action(|_: &ShowWelcome, cx| {
106        with_active_or_new_workspace(cx, |workspace, window, cx| {
107            workspace
108                .with_local_workspace(window, cx, |workspace, window, cx| {
109                    let existing = workspace
110                        .active_pane()
111                        .read(cx)
112                        .items()
113                        .find_map(|item| item.downcast::<WelcomePage>());
114
115                    if let Some(existing) = existing {
116                        workspace.activate_item(&existing, true, true, window, cx);
117                    } else {
118                        let settings_page = cx
119                            .new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx));
120                        workspace.add_item_to_active_pane(
121                            Box::new(settings_page),
122                            None,
123                            true,
124                            window,
125                            cx,
126                        )
127                    }
128                })
129                .detach();
130        });
131    });
132
133    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
134        workspace.register_action(|_workspace, action: &ImportVsCodeSettings, window, cx| {
135            let fs = <dyn Fs>::global(cx);
136            let action = *action;
137
138            let workspace = cx.weak_entity();
139
140            window
141                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
142                    handle_import_vscode_settings(
143                        workspace,
144                        VsCodeSettingsSource::VsCode,
145                        action.skip_prompt,
146                        fs,
147                        cx,
148                    )
149                    .await
150                })
151                .detach();
152        });
153
154        workspace.register_action(|_workspace, action: &ImportCursorSettings, window, cx| {
155            let fs = <dyn Fs>::global(cx);
156            let action = *action;
157
158            let workspace = cx.weak_entity();
159
160            window
161                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
162                    handle_import_vscode_settings(
163                        workspace,
164                        VsCodeSettingsSource::Cursor,
165                        action.skip_prompt,
166                        fs,
167                        cx,
168                    )
169                    .await
170                })
171                .detach();
172        });
173    })
174    .detach();
175
176    base_keymap_picker::init(cx);
177
178    register_serializable_item::<Onboarding>(cx);
179    register_serializable_item::<WelcomePage>(cx);
180}
181
182pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyhow::Result<()>> {
183    telemetry::event!("Onboarding Page Opened");
184    open_new(
185        Default::default(),
186        app_state,
187        cx,
188        |workspace, window, cx| {
189            {
190                workspace.toggle_dock(DockPosition::Left, window, cx);
191                let onboarding_page = Onboarding::new(workspace, cx);
192                workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
193
194                window.focus(&onboarding_page.focus_handle(cx), cx);
195
196                cx.notify();
197            };
198            let kvp = KeyValueStore::global(cx);
199            db::write_and_log(cx, move || async move {
200                kvp.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
201                    .await
202            });
203        },
204    )
205}
206
207struct Onboarding {
208    workspace: WeakEntity<Workspace>,
209    focus_handle: FocusHandle,
210    user_store: Entity<UserStore>,
211    scroll_handle: ScrollHandle,
212    _settings_subscription: Subscription,
213}
214
215impl Onboarding {
216    fn new(workspace: &Workspace, cx: &mut App) -> Entity<Self> {
217        let font_family_cache = theme::FontFamilyCache::global(cx);
218
219        cx.new(|cx| {
220            cx.spawn(async move |this, cx| {
221                font_family_cache.prefetch(cx).await;
222                this.update(cx, |_, cx| {
223                    cx.notify();
224                })
225            })
226            .detach();
227
228            Self {
229                workspace: workspace.weak_handle(),
230                focus_handle: cx.focus_handle(),
231                scroll_handle: ScrollHandle::new(),
232                user_store: workspace.user_store().clone(),
233                _settings_subscription: cx
234                    .observe_global::<SettingsStore>(move |_, cx| cx.notify()),
235            }
236        })
237    }
238
239    fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) {
240        telemetry::event!("Finish Setup");
241        go_to_welcome_page(cx);
242    }
243
244    fn handle_sign_in(&mut self, _: &SignIn, window: &mut Window, cx: &mut Context<Self>) {
245        let client = Client::global(cx);
246        let workspace = self.workspace.clone();
247
248        window
249            .spawn(cx, async move |mut cx| {
250                client
251                    .sign_in_with_optional_connect(true, &cx)
252                    .await
253                    .notify_workspace_async_err(workspace, &mut cx);
254            })
255            .detach();
256    }
257
258    fn handle_open_account(_: &OpenAccount, _: &mut Window, cx: &mut App) {
259        cx.open_url(&zed_urls::account_url(cx))
260    }
261
262    fn render_page(&mut self, cx: &mut Context<Self>) -> AnyElement {
263        crate::basics_page::render_basics_page(&self.user_store, cx).into_any_element()
264    }
265}
266
267impl Render for Onboarding {
268    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
269        div()
270            .image_cache(gpui::retain_all("onboarding-page"))
271            .key_context({
272                let mut ctx = KeyContext::new_with_defaults();
273                ctx.add("Onboarding");
274                ctx.add("menu");
275                ctx
276            })
277            .track_focus(&self.focus_handle)
278            .size_full()
279            .bg(cx.theme().colors().editor_background)
280            .on_action(Self::on_finish)
281            .on_action(cx.listener(Self::handle_sign_in))
282            .on_action(Self::handle_open_account)
283            .on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| {
284                window.focus_next(cx);
285                cx.notify();
286            }))
287            .on_action(cx.listener(|_, _: &menu::SelectPrevious, window, cx| {
288                window.focus_prev(cx);
289                cx.notify();
290            }))
291            .vertical_scrollbar_for(&self.scroll_handle, window, cx)
292            .child(
293                div()
294                    .id("page-content")
295                    .size_full()
296                    .overflow_y_scroll()
297                    .child(
298                        v_flex()
299                            .min_w_0()
300                            .max_w(rems_from_px(780.))
301                            .w_full()
302                            .mx_auto()
303                            .p_12()
304                            .gap_6()
305                            .child(
306                                h_flex()
307                                    .w_full()
308                                    .gap_4()
309                                    .justify_between()
310                                    .child(
311                                        h_flex()
312                                            .gap_4()
313                                            .child(Vector::square(VectorName::ZedLogo, rems(2.5)))
314                                            .child(
315                                                v_flex()
316                                                    .child(
317                                                        Headline::new("Welcome to Zed")
318                                                            .size(HeadlineSize::Small),
319                                                    )
320                                                    .child(
321                                                        Label::new("The editor for what's next")
322                                                            .color(Color::Muted)
323                                                            .size(LabelSize::Small)
324                                                            .italic(),
325                                                    ),
326                                            ),
327                                    )
328                                    .child({
329                                        Button::new("finish_setup", "Finish Setup")
330                                            .style(ButtonStyle::Filled)
331                                            .size(ButtonSize::Medium)
332                                            .width(rems_from_px(200.))
333                                            .key_binding(KeyBinding::for_action_in(
334                                                &Finish,
335                                                &self.focus_handle,
336                                                cx,
337                                            ))
338                                            .on_click(|_, window, cx| {
339                                                window.dispatch_action(Finish.boxed_clone(), cx);
340                                            })
341                                    }),
342                            )
343                            .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
344                            .child(self.render_page(cx)),
345                    )
346                    .track_scroll(&self.scroll_handle),
347            )
348    }
349}
350
351impl EventEmitter<ItemEvent> for Onboarding {}
352
353impl Focusable for Onboarding {
354    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
355        self.focus_handle.clone()
356    }
357}
358
359impl Item for Onboarding {
360    type Event = ItemEvent;
361
362    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
363        "Onboarding".into()
364    }
365
366    fn telemetry_event_text(&self) -> Option<&'static str> {
367        Some("Onboarding Page Opened")
368    }
369
370    fn show_toolbar(&self) -> bool {
371        false
372    }
373
374    fn can_split(&self) -> bool {
375        true
376    }
377
378    fn clone_on_split(
379        &self,
380        _workspace_id: Option<WorkspaceId>,
381        _: &mut Window,
382        cx: &mut Context<Self>,
383    ) -> Task<Option<Entity<Self>>> {
384        Task::ready(Some(cx.new(|cx| Onboarding {
385            workspace: self.workspace.clone(),
386            user_store: self.user_store.clone(),
387            scroll_handle: ScrollHandle::new(),
388            focus_handle: cx.focus_handle(),
389            _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
390        })))
391    }
392
393    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
394        f(*event)
395    }
396}
397
398fn go_to_welcome_page(cx: &mut App) {
399    with_active_or_new_workspace(cx, |workspace, window, cx| {
400        let Some((onboarding_id, onboarding_idx)) = workspace
401            .active_pane()
402            .read(cx)
403            .items()
404            .enumerate()
405            .find_map(|(idx, item)| {
406                let _ = item.downcast::<Onboarding>()?;
407                Some((item.item_id(), idx))
408            })
409        else {
410            return;
411        };
412
413        workspace.active_pane().update(cx, |pane, cx| {
414            // Get the index here to get around the borrow checker
415            let idx = pane.items().enumerate().find_map(|(idx, item)| {
416                let _ = item.downcast::<WelcomePage>()?;
417                Some(idx)
418            });
419
420            if let Some(idx) = idx {
421                pane.activate_item(idx, true, true, window, cx);
422            } else {
423                let item = Box::new(
424                    cx.new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx)),
425                );
426                pane.add_item(item, true, true, Some(onboarding_idx), window, cx);
427            }
428
429            pane.remove_item(onboarding_id, false, false, window, cx);
430        });
431    });
432}
433
434pub async fn handle_import_vscode_settings(
435    workspace: WeakEntity<Workspace>,
436    source: VsCodeSettingsSource,
437    skip_prompt: bool,
438    fs: Arc<dyn Fs>,
439    cx: &mut AsyncWindowContext,
440) {
441    use util::truncate_and_remove_front;
442
443    let vscode_settings =
444        match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
445            Ok(vscode_settings) => vscode_settings,
446            Err(err) => {
447                zlog::error!("{err:?}");
448                let _ = cx.prompt(
449                    gpui::PromptLevel::Info,
450                    &format!("Could not find or load a {source} settings file"),
451                    None,
452                    &["Ok"],
453                );
454                return;
455            }
456        };
457
458    if !skip_prompt {
459        let prompt = cx.prompt(
460            gpui::PromptLevel::Warning,
461            &format!(
462                "Importing {} settings may overwrite your existing settings. \
463                Will import settings from {}",
464                vscode_settings.source,
465                truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
466            ),
467            None,
468            &["Ok", "Cancel"],
469        );
470        let result = cx.spawn(async move |_| prompt.await.ok()).await;
471        if result != Some(0) {
472            return;
473        }
474    };
475
476    let Ok(result_channel) = cx.update(|_, cx| {
477        let source = vscode_settings.source;
478        let path = vscode_settings.path.clone();
479        let result_channel = cx
480            .global::<SettingsStore>()
481            .import_vscode_settings(fs, vscode_settings);
482        zlog::info!("Imported {source} settings from {}", path.display());
483        result_channel
484    }) else {
485        return;
486    };
487
488    let result = result_channel.await;
489    workspace
490        .update_in(cx, |workspace, _, cx| match result {
491            Ok(_) => {
492                let confirmation_toast = StatusToast::new(
493                    format!("Your {} settings were successfully imported.", source),
494                    cx,
495                    |this, _| {
496                        this.icon(
497                            Icon::new(IconName::Check)
498                                .size(IconSize::Small)
499                                .color(Color::Success),
500                        )
501                        .dismiss_button(true)
502                    },
503                );
504                SettingsImportState::update(cx, |state, _| match source {
505                    VsCodeSettingsSource::VsCode => {
506                        state.vscode = true;
507                    }
508                    VsCodeSettingsSource::Cursor => {
509                        state.cursor = true;
510                    }
511                });
512                workspace.toggle_status_toast(confirmation_toast, cx);
513            }
514            Err(_) => {
515                let error_toast = StatusToast::new(
516                    "Failed to import settings. See log for details",
517                    cx,
518                    |this, _| {
519                        this.icon(
520                            Icon::new(IconName::Close)
521                                .size(IconSize::Small)
522                                .color(Color::Error),
523                        )
524                        .action("Open Log", |window, cx| {
525                            window.dispatch_action(workspace::OpenLog.boxed_clone(), cx)
526                        })
527                        .dismiss_button(true)
528                    },
529                );
530                workspace.toggle_status_toast(error_toast, cx);
531            }
532        })
533        .ok();
534}
535
536#[derive(Default, Copy, Clone)]
537pub struct SettingsImportState {
538    pub cursor: bool,
539    pub vscode: bool,
540}
541
542impl Global for SettingsImportState {}
543
544impl SettingsImportState {
545    pub fn global(cx: &App) -> Self {
546        cx.try_global().cloned().unwrap_or_default()
547    }
548    pub fn update<R>(cx: &mut App, f: impl FnOnce(&mut Self, &mut App) -> R) -> R {
549        cx.update_default_global(f)
550    }
551}
552
553impl workspace::SerializableItem for Onboarding {
554    fn serialized_item_kind() -> &'static str {
555        "OnboardingPage"
556    }
557
558    fn cleanup(
559        workspace_id: workspace::WorkspaceId,
560        alive_items: Vec<workspace::ItemId>,
561        _window: &mut Window,
562        cx: &mut App,
563    ) -> gpui::Task<gpui::Result<()>> {
564        workspace::delete_unloaded_items(
565            alive_items,
566            workspace_id,
567            "onboarding_pages",
568            &persistence::OnboardingPagesDb::global(cx),
569            cx,
570        )
571    }
572
573    fn deserialize(
574        _project: Entity<project::Project>,
575        workspace: WeakEntity<Workspace>,
576        workspace_id: workspace::WorkspaceId,
577        item_id: workspace::ItemId,
578        window: &mut Window,
579        cx: &mut App,
580    ) -> gpui::Task<gpui::Result<Entity<Self>>> {
581        let db = persistence::OnboardingPagesDb::global(cx);
582        window.spawn(cx, async move |cx| {
583            if let Some(_) = db.get_onboarding_page(item_id, workspace_id)? {
584                workspace.update(cx, |workspace, cx| Onboarding::new(workspace, cx))
585            } else {
586                Err(anyhow::anyhow!("No onboarding page to deserialize"))
587            }
588        })
589    }
590
591    fn serialize(
592        &mut self,
593        workspace: &mut Workspace,
594        item_id: workspace::ItemId,
595        _closing: bool,
596        _window: &mut Window,
597        cx: &mut ui::Context<Self>,
598    ) -> Option<gpui::Task<gpui::Result<()>>> {
599        let workspace_id = workspace.database_id()?;
600
601        let db = persistence::OnboardingPagesDb::global(cx);
602        Some(
603            cx.background_spawn(
604                async move { db.save_onboarding_page(item_id, workspace_id).await },
605            ),
606        )
607    }
608
609    fn should_serialize(&self, event: &Self::Event) -> bool {
610        event == &ItemEvent::UpdateTab
611    }
612}
613
614mod persistence {
615    use db::{
616        query,
617        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
618        sqlez_macros::sql,
619    };
620    use workspace::WorkspaceDb;
621
622    pub struct OnboardingPagesDb(ThreadSafeConnection);
623
624    impl Domain for OnboardingPagesDb {
625        const NAME: &str = stringify!(OnboardingPagesDb);
626
627        const MIGRATIONS: &[&str] = &[
628            sql!(
629                        CREATE TABLE onboarding_pages (
630                            workspace_id INTEGER,
631                            item_id INTEGER UNIQUE,
632                            page_number INTEGER,
633
634                            PRIMARY KEY(workspace_id, item_id),
635                            FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
636                            ON DELETE CASCADE
637                        ) STRICT;
638            ),
639            sql!(
640                        CREATE TABLE onboarding_pages_2 (
641                            workspace_id INTEGER,
642                            item_id INTEGER UNIQUE,
643
644                            PRIMARY KEY(workspace_id, item_id),
645                            FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
646                            ON DELETE CASCADE
647                        ) STRICT;
648                        INSERT INTO onboarding_pages_2 SELECT workspace_id, item_id FROM onboarding_pages;
649                        DROP TABLE onboarding_pages;
650                        ALTER TABLE onboarding_pages_2 RENAME TO onboarding_pages;
651            ),
652        ];
653    }
654
655    db::static_connection!(OnboardingPagesDb, [WorkspaceDb]);
656
657    impl OnboardingPagesDb {
658        query! {
659            pub async fn save_onboarding_page(
660                item_id: workspace::ItemId,
661                workspace_id: workspace::WorkspaceId
662            ) -> Result<()> {
663                INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id)
664                VALUES (?, ?)
665            }
666        }
667
668        query! {
669            pub fn get_onboarding_page(
670                item_id: workspace::ItemId,
671                workspace_id: workspace::WorkspaceId
672            ) -> Result<Option<workspace::ItemId>> {
673                SELECT item_id
674                FROM onboarding_pages
675                WHERE item_id = ? AND workspace_id = ?
676            }
677        }
678    }
679}