onboarding.rs

  1use crate::welcome::{ShowWelcome, WelcomePage};
  2use client::{Client, UserStore};
  3use command_palette_hooks::CommandPaletteFilter;
  4use db::kvp::KEY_VALUE_STORE;
  5use feature_flags::{FeatureFlag, FeatureFlagViewExt as _};
  6use fs::Fs;
  7use gpui::{
  8    Action, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, EventEmitter,
  9    FocusHandle, Focusable, IntoElement, Render, SharedString, Subscription, Task, WeakEntity,
 10    Window, actions,
 11};
 12use schemars::JsonSchema;
 13use serde::Deserialize;
 14use settings::{SettingsStore, VsCodeSettingsSource};
 15use std::sync::Arc;
 16use ui::{
 17    Avatar, FluentBuilder, Headline, KeyBinding, ParentElement as _, StatefulInteractiveElement,
 18    Vector, VectorName, 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, with_active_or_new_workspace,
 26};
 27
 28mod basics_page;
 29mod editing_page;
 30mod theme_preview;
 31mod welcome;
 32
 33pub struct OnBoardingFeatureFlag {}
 34
 35impl FeatureFlag for OnBoardingFeatureFlag {
 36    const NAME: &'static str = "onboarding";
 37}
 38
 39/// Imports settings from Visual Studio Code.
 40#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
 41#[action(namespace = zed)]
 42#[serde(deny_unknown_fields)]
 43pub struct ImportVsCodeSettings {
 44    #[serde(default)]
 45    pub skip_prompt: bool,
 46}
 47
 48/// Imports settings from Cursor editor.
 49#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
 50#[action(namespace = zed)]
 51#[serde(deny_unknown_fields)]
 52pub struct ImportCursorSettings {
 53    #[serde(default)]
 54    pub skip_prompt: bool,
 55}
 56
 57pub const FIRST_OPEN: &str = "first_open";
 58
 59actions!(
 60    zed,
 61    [
 62        /// Opens the onboarding view.
 63        OpenOnboarding
 64    ]
 65);
 66
 67pub fn init(cx: &mut App) {
 68    cx.on_action(|_: &OpenOnboarding, cx| {
 69        with_active_or_new_workspace(cx, |workspace, window, cx| {
 70            workspace
 71                .with_local_workspace(window, cx, |workspace, window, cx| {
 72                    let existing = workspace
 73                        .active_pane()
 74                        .read(cx)
 75                        .items()
 76                        .find_map(|item| item.downcast::<Onboarding>());
 77
 78                    if let Some(existing) = existing {
 79                        workspace.activate_item(&existing, true, true, window, cx);
 80                    } else {
 81                        let settings_page = Onboarding::new(
 82                            workspace.weak_handle(),
 83                            workspace.user_store().clone(),
 84                            cx,
 85                        );
 86                        workspace.add_item_to_active_pane(
 87                            Box::new(settings_page),
 88                            None,
 89                            true,
 90                            window,
 91                            cx,
 92                        )
 93                    }
 94                })
 95                .detach();
 96        });
 97    });
 98
 99    cx.on_action(|_: &ShowWelcome, cx| {
100        with_active_or_new_workspace(cx, |workspace, window, cx| {
101            workspace
102                .with_local_workspace(window, cx, |workspace, window, cx| {
103                    let existing = workspace
104                        .active_pane()
105                        .read(cx)
106                        .items()
107                        .find_map(|item| item.downcast::<WelcomePage>());
108
109                    if let Some(existing) = existing {
110                        workspace.activate_item(&existing, true, true, window, cx);
111                    } else {
112                        let settings_page = WelcomePage::new(window, cx);
113                        workspace.add_item_to_active_pane(
114                            Box::new(settings_page),
115                            None,
116                            true,
117                            window,
118                            cx,
119                        )
120                    }
121                })
122                .detach();
123        });
124    });
125
126    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
127        workspace.register_action(|_workspace, action: &ImportVsCodeSettings, window, cx| {
128            let fs = <dyn Fs>::global(cx);
129            let action = *action;
130
131            window
132                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
133                    handle_import_vscode_settings(
134                        VsCodeSettingsSource::VsCode,
135                        action.skip_prompt,
136                        fs,
137                        cx,
138                    )
139                    .await
140                })
141                .detach();
142        });
143
144        workspace.register_action(|_workspace, action: &ImportCursorSettings, window, cx| {
145            let fs = <dyn Fs>::global(cx);
146            let action = *action;
147
148            window
149                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
150                    handle_import_vscode_settings(
151                        VsCodeSettingsSource::Cursor,
152                        action.skip_prompt,
153                        fs,
154                        cx,
155                    )
156                    .await
157                })
158                .detach();
159        });
160    })
161    .detach();
162
163    cx.observe_new::<Workspace>(|_, window, cx| {
164        let Some(window) = window else {
165            return;
166        };
167
168        let onboarding_actions = [
169            std::any::TypeId::of::<OpenOnboarding>(),
170            std::any::TypeId::of::<ShowWelcome>(),
171        ];
172
173        CommandPaletteFilter::update_global(cx, |filter, _cx| {
174            filter.hide_action_types(&onboarding_actions);
175        });
176
177        cx.observe_flag::<OnBoardingFeatureFlag, _>(window, move |is_enabled, _, _, cx| {
178            if is_enabled {
179                CommandPaletteFilter::update_global(cx, |filter, _cx| {
180                    filter.show_action_types(onboarding_actions.iter());
181                });
182            } else {
183                CommandPaletteFilter::update_global(cx, |filter, _cx| {
184                    filter.hide_action_types(&onboarding_actions);
185                });
186            }
187        })
188        .detach();
189    })
190    .detach();
191}
192
193pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyhow::Result<()>> {
194    open_new(
195        Default::default(),
196        app_state,
197        cx,
198        |workspace, window, cx| {
199            {
200                workspace.toggle_dock(DockPosition::Left, window, cx);
201                let onboarding_page =
202                    Onboarding::new(workspace.weak_handle(), workspace.user_store().clone(), cx);
203                workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
204
205                window.focus(&onboarding_page.focus_handle(cx));
206
207                cx.notify();
208            };
209            db::write_and_log(cx, || {
210                KEY_VALUE_STORE.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
211            });
212        },
213    )
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217enum SelectedPage {
218    Basics,
219    Editing,
220    AiSetup,
221}
222
223struct Onboarding {
224    workspace: WeakEntity<Workspace>,
225    focus_handle: FocusHandle,
226    selected_page: SelectedPage,
227    user_store: Entity<UserStore>,
228    _settings_subscription: Subscription,
229}
230
231impl Onboarding {
232    fn new(
233        workspace: WeakEntity<Workspace>,
234        user_store: Entity<UserStore>,
235        cx: &mut App,
236    ) -> Entity<Self> {
237        cx.new(|cx| Self {
238            workspace,
239            user_store,
240            focus_handle: cx.focus_handle(),
241            selected_page: SelectedPage::Basics,
242            _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
243        })
244    }
245
246    fn render_nav_button(
247        &mut self,
248        page: SelectedPage,
249        _: &mut Window,
250        cx: &mut Context<Self>,
251    ) -> impl IntoElement {
252        let text = match page {
253            SelectedPage::Basics => "Basics",
254            SelectedPage::Editing => "Editing",
255            SelectedPage::AiSetup => "AI Setup",
256        };
257
258        let binding = match page {
259            SelectedPage::Basics => {
260                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-1").unwrap()], cx)
261                    .map(|kb| kb.size(rems_from_px(12.)))
262            }
263            SelectedPage::Editing => {
264                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-2").unwrap()], cx)
265                    .map(|kb| kb.size(rems_from_px(12.)))
266            }
267            SelectedPage::AiSetup => {
268                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-3").unwrap()], cx)
269                    .map(|kb| kb.size(rems_from_px(12.)))
270            }
271        };
272
273        let selected = self.selected_page == page;
274
275        h_flex()
276            .id(text)
277            .relative()
278            .w_full()
279            .gap_2()
280            .px_2()
281            .py_0p5()
282            .justify_between()
283            .rounded_sm()
284            .when(selected, |this| {
285                this.child(
286                    div()
287                        .h_4()
288                        .w_px()
289                        .bg(cx.theme().colors().text_accent)
290                        .absolute()
291                        .left_0(),
292                )
293            })
294            .hover(|style| style.bg(cx.theme().colors().element_hover))
295            .child(Label::new(text).map(|this| {
296                if selected {
297                    this.color(Color::Default)
298                } else {
299                    this.color(Color::Muted)
300                }
301            }))
302            .child(binding)
303            .on_click(cx.listener(move |this, _, _, cx| {
304                this.selected_page = page;
305                cx.notify();
306            }))
307    }
308
309    fn render_nav(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
310        v_flex()
311            .h_full()
312            .w(rems_from_px(220.))
313            .flex_shrink_0()
314            .gap_4()
315            .justify_between()
316            .child(
317                v_flex()
318                    .gap_6()
319                    .child(
320                        h_flex()
321                            .px_2()
322                            .gap_4()
323                            .child(Vector::square(VectorName::ZedLogo, rems(2.5)))
324                            .child(
325                                v_flex()
326                                    .child(
327                                        Headline::new("Welcome to Zed").size(HeadlineSize::Small),
328                                    )
329                                    .child(
330                                        Label::new("The editor for what's next")
331                                            .color(Color::Muted)
332                                            .size(LabelSize::Small)
333                                            .italic(),
334                                    ),
335                            ),
336                    )
337                    .child(
338                        v_flex()
339                            .gap_4()
340                            .child(
341                                v_flex()
342                                    .py_4()
343                                    .border_y_1()
344                                    .border_color(cx.theme().colors().border_variant.opacity(0.5))
345                                    .gap_1()
346                                    .children([
347                                        self.render_nav_button(SelectedPage::Basics, window, cx)
348                                            .into_element(),
349                                        self.render_nav_button(SelectedPage::Editing, window, cx)
350                                            .into_element(),
351                                        self.render_nav_button(SelectedPage::AiSetup, window, cx)
352                                            .into_element(),
353                                    ]),
354                            )
355                            .child(Button::new("skip_all", "Skip All")),
356                    ),
357            )
358            .child(
359                if let Some(user) = self.user_store.read(cx).current_user() {
360                    h_flex()
361                        .gap_2()
362                        .child(Avatar::new(user.avatar_uri.clone()))
363                        .child(Label::new(user.github_login.clone()))
364                        .into_any_element()
365                } else {
366                    Button::new("sign_in", "Sign In")
367                        .style(ButtonStyle::Outlined)
368                        .full_width()
369                        .on_click(|_, window, cx| {
370                            let client = Client::global(cx);
371                            window
372                                .spawn(cx, async move |cx| {
373                                    client
374                                        .authenticate_and_connect(true, &cx)
375                                        .await
376                                        .into_response()
377                                        .notify_async_err(cx);
378                                })
379                                .detach();
380                        })
381                        .into_any_element()
382                },
383            )
384    }
385
386    fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
387        match self.selected_page {
388            SelectedPage::Basics => {
389                crate::basics_page::render_basics_page(window, cx).into_any_element()
390            }
391            SelectedPage::Editing => {
392                crate::editing_page::render_editing_page(window, cx).into_any_element()
393            }
394            SelectedPage::AiSetup => self.render_ai_setup_page(window, cx).into_any_element(),
395        }
396    }
397
398    fn render_ai_setup_page(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
399        div().child("ai setup page")
400    }
401}
402
403impl Render for Onboarding {
404    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
405        h_flex()
406            .image_cache(gpui::retain_all("onboarding-page"))
407            .key_context("onboarding-page")
408            .size_full()
409            .bg(cx.theme().colors().editor_background)
410            .child(
411                h_flex()
412                    .max_w(rems_from_px(1100.))
413                    .size_full()
414                    .m_auto()
415                    .py_20()
416                    .px_12()
417                    .items_start()
418                    .gap_12()
419                    .child(self.render_nav(window, cx))
420                    .child(
421                        div()
422                            .pl_12()
423                            .border_l_1()
424                            .border_color(cx.theme().colors().border_variant.opacity(0.5))
425                            .size_full()
426                            .child(self.render_page(window, cx)),
427                    ),
428            )
429    }
430}
431
432impl EventEmitter<ItemEvent> for Onboarding {}
433
434impl Focusable for Onboarding {
435    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
436        self.focus_handle.clone()
437    }
438}
439
440impl Item for Onboarding {
441    type Event = ItemEvent;
442
443    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
444        "Onboarding".into()
445    }
446
447    fn telemetry_event_text(&self) -> Option<&'static str> {
448        Some("Onboarding Page Opened")
449    }
450
451    fn show_toolbar(&self) -> bool {
452        false
453    }
454
455    fn clone_on_split(
456        &self,
457        _workspace_id: Option<WorkspaceId>,
458        _: &mut Window,
459        cx: &mut Context<Self>,
460    ) -> Option<Entity<Self>> {
461        Some(Onboarding::new(
462            self.workspace.clone(),
463            self.user_store.clone(),
464            cx,
465        ))
466    }
467
468    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
469        f(*event)
470    }
471}
472
473pub async fn handle_import_vscode_settings(
474    source: VsCodeSettingsSource,
475    skip_prompt: bool,
476    fs: Arc<dyn Fs>,
477    cx: &mut AsyncWindowContext,
478) {
479    use util::truncate_and_remove_front;
480
481    let vscode_settings =
482        match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
483            Ok(vscode_settings) => vscode_settings,
484            Err(err) => {
485                zlog::error!("{err}");
486                let _ = cx.prompt(
487                    gpui::PromptLevel::Info,
488                    &format!("Could not find or load a {source} settings file"),
489                    None,
490                    &["Ok"],
491                );
492                return;
493            }
494        };
495
496    if !skip_prompt {
497        let prompt = cx.prompt(
498            gpui::PromptLevel::Warning,
499            &format!(
500                "Importing {} settings may overwrite your existing settings. \
501                Will import settings from {}",
502                vscode_settings.source,
503                truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
504            ),
505            None,
506            &["Ok", "Cancel"],
507        );
508        let result = cx.spawn(async move |_| prompt.await.ok()).await;
509        if result != Some(0) {
510            return;
511        }
512    };
513
514    cx.update(|_, cx| {
515        let source = vscode_settings.source;
516        let path = vscode_settings.path.clone();
517        cx.global::<SettingsStore>()
518            .import_vscode_settings(fs, vscode_settings);
519        zlog::info!("Imported {source} settings from {}", path.display());
520    })
521    .ok();
522}