onboarding.rs

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