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