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