onboarding.rs

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