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 theme::{Theme, ThemeRegistry};
 17use ui::{Avatar, FluentBuilder, KeyBinding, Vector, VectorName, prelude::*, rems_from_px};
 18use workspace::{
 19    AppState, Workspace, WorkspaceId,
 20    dock::DockPosition,
 21    item::{Item, ItemEvent},
 22    notifications::NotifyResultExt as _,
 23    open_new, with_active_or_new_workspace,
 24};
 25
 26mod basics_page;
 27mod editing_page;
 28mod welcome;
 29
 30pub struct OnBoardingFeatureFlag {}
 31
 32impl FeatureFlag for OnBoardingFeatureFlag {
 33    const NAME: &'static str = "onboarding";
 34}
 35
 36/// Imports settings from Visual Studio Code.
 37#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
 38#[action(namespace = zed)]
 39#[serde(deny_unknown_fields)]
 40pub struct ImportVsCodeSettings {
 41    #[serde(default)]
 42    pub skip_prompt: bool,
 43}
 44
 45/// Imports settings from Cursor editor.
 46#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
 47#[action(namespace = zed)]
 48#[serde(deny_unknown_fields)]
 49pub struct ImportCursorSettings {
 50    #[serde(default)]
 51    pub skip_prompt: bool,
 52}
 53
 54pub const FIRST_OPEN: &str = "first_open";
 55
 56actions!(
 57    zed,
 58    [
 59        /// Opens the onboarding view.
 60        OpenOnboarding
 61    ]
 62);
 63
 64pub fn init(cx: &mut App) {
 65    cx.on_action(|_: &OpenOnboarding, cx| {
 66        with_active_or_new_workspace(cx, |workspace, window, cx| {
 67            workspace
 68                .with_local_workspace(window, cx, |workspace, window, cx| {
 69                    let existing = workspace
 70                        .active_pane()
 71                        .read(cx)
 72                        .items()
 73                        .find_map(|item| item.downcast::<Onboarding>());
 74
 75                    if let Some(existing) = existing {
 76                        workspace.activate_item(&existing, true, true, window, cx);
 77                    } else {
 78                        let settings_page = Onboarding::new(
 79                            workspace.weak_handle(),
 80                            workspace.user_store().clone(),
 81                            cx,
 82                        );
 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 =
199                    Onboarding::new(workspace.weak_handle(), workspace.user_store().clone(), cx);
200                workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
201
202                window.focus(&onboarding_page.focus_handle(cx));
203
204                cx.notify();
205            };
206            db::write_and_log(cx, || {
207                KEY_VALUE_STORE.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
208            });
209        },
210    )
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214enum SelectedPage {
215    Basics,
216    Editing,
217    AiSetup,
218}
219
220struct Onboarding {
221    workspace: WeakEntity<Workspace>,
222    light_themes: [Arc<Theme>; 3],
223    dark_themes: [Arc<Theme>; 3],
224    focus_handle: FocusHandle,
225    selected_page: SelectedPage,
226    fs: Arc<dyn Fs>,
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        let theme_registry = ThemeRegistry::global(cx);
238
239        let one_dark = theme_registry
240            .get("One Dark")
241            .expect("Default themes are always present");
242        let ayu_dark = theme_registry
243            .get("Ayu Dark")
244            .expect("Default themes are always present");
245        let gruvbox_dark = theme_registry
246            .get("Gruvbox Dark")
247            .expect("Default themes are always present");
248
249        let one_light = theme_registry
250            .get("One Light")
251            .expect("Default themes are always present");
252        let ayu_light = theme_registry
253            .get("Ayu Light")
254            .expect("Default themes are always present");
255        let gruvbox_light = theme_registry
256            .get("Gruvbox Light")
257            .expect("Default themes are always present");
258
259        cx.new(|cx| Self {
260            workspace,
261            user_store,
262            focus_handle: cx.focus_handle(),
263            light_themes: [one_light, ayu_light, gruvbox_light],
264            dark_themes: [one_dark, ayu_dark, gruvbox_dark],
265            selected_page: SelectedPage::Basics,
266            fs: <dyn Fs>::global(cx),
267            _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
268        })
269    }
270
271    fn render_nav_button(
272        &mut self,
273        page: SelectedPage,
274        _: &mut Window,
275        cx: &mut Context<Self>,
276    ) -> impl IntoElement {
277        let text = match page {
278            SelectedPage::Basics => "Basics",
279            SelectedPage::Editing => "Editing",
280            SelectedPage::AiSetup => "AI Setup",
281        };
282
283        let binding = match page {
284            SelectedPage::Basics => {
285                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-1").unwrap()], cx)
286                    .map(|kb| kb.size(rems_from_px(12.)))
287            }
288            SelectedPage::Editing => {
289                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-2").unwrap()], cx)
290                    .map(|kb| kb.size(rems_from_px(12.)))
291            }
292            SelectedPage::AiSetup => {
293                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-3").unwrap()], cx)
294                    .map(|kb| kb.size(rems_from_px(12.)))
295            }
296        };
297
298        let selected = self.selected_page == page;
299
300        h_flex()
301            .id(text)
302            .relative()
303            .w_full()
304            .gap_2()
305            .px_2()
306            .py_0p5()
307            .justify_between()
308            .rounded_sm()
309            .when(selected, |this| {
310                this.child(
311                    div()
312                        .h_4()
313                        .w_px()
314                        .bg(cx.theme().colors().text_accent)
315                        .absolute()
316                        .left_0(),
317                )
318            })
319            .hover(|style| style.bg(cx.theme().colors().element_hover))
320            .child(Label::new(text).map(|this| {
321                if selected {
322                    this.color(Color::Default)
323                } else {
324                    this.color(Color::Muted)
325                }
326            }))
327            .child(binding)
328            .on_click(cx.listener(move |this, _, _, cx| {
329                this.selected_page = page;
330                cx.notify();
331            }))
332    }
333
334    fn render_nav(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
335        v_flex()
336            .h_full()
337            .w(rems_from_px(220.))
338            .flex_shrink_0()
339            .gap_4()
340            .justify_between()
341            .child(
342                v_flex()
343                    .gap_6()
344                    .child(
345                        h_flex()
346                            .px_2()
347                            .gap_4()
348                            .child(Vector::square(VectorName::ZedLogo, rems(2.5)))
349                            .child(
350                                v_flex()
351                                    .child(
352                                        Headline::new("Welcome to Zed").size(HeadlineSize::Small),
353                                    )
354                                    .child(
355                                        Label::new("The editor for what's next")
356                                            .color(Color::Muted)
357                                            .size(LabelSize::Small)
358                                            .italic(),
359                                    ),
360                            ),
361                    )
362                    .child(
363                        v_flex()
364                            .gap_4()
365                            .child(
366                                v_flex()
367                                    .py_4()
368                                    .border_y_1()
369                                    .border_color(cx.theme().colors().border_variant.opacity(0.5))
370                                    .gap_1()
371                                    .children([
372                                        self.render_nav_button(SelectedPage::Basics, window, cx)
373                                            .into_element(),
374                                        self.render_nav_button(SelectedPage::Editing, window, cx)
375                                            .into_element(),
376                                        self.render_nav_button(SelectedPage::AiSetup, window, cx)
377                                            .into_element(),
378                                    ]),
379                            )
380                            .child(Button::new("skip_all", "Skip All")),
381                    ),
382            )
383            .child(
384                if let Some(user) = self.user_store.read(cx).current_user() {
385                    h_flex()
386                        .gap_2()
387                        .child(Avatar::new(user.avatar_uri.clone()))
388                        .child(Label::new(user.github_login.clone()))
389                        .into_any_element()
390                } else {
391                    Button::new("sign_in", "Sign In")
392                        .style(ButtonStyle::Outlined)
393                        .full_width()
394                        .on_click(|_, window, cx| {
395                            let client = Client::global(cx);
396                            window
397                                .spawn(cx, async move |cx| {
398                                    client
399                                        .authenticate_and_connect(true, &cx)
400                                        .await
401                                        .into_response()
402                                        .notify_async_err(cx);
403                                })
404                                .detach();
405                        })
406                        .into_any_element()
407                },
408            )
409    }
410
411    fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
412        match self.selected_page {
413            SelectedPage::Basics => {
414                crate::basics_page::render_basics_page(&self, cx).into_any_element()
415            }
416            SelectedPage::Editing => {
417                crate::editing_page::render_editing_page(window, cx).into_any_element()
418            }
419            SelectedPage::AiSetup => self.render_ai_setup_page(window, cx).into_any_element(),
420        }
421    }
422
423    fn render_ai_setup_page(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
424        div().child("ai setup page")
425    }
426}
427
428impl Render for Onboarding {
429    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
430        h_flex()
431            .image_cache(gpui::retain_all("onboarding-page"))
432            .key_context("onboarding-page")
433            .size_full()
434            .bg(cx.theme().colors().editor_background)
435            .child(
436                h_flex()
437                    .max_w(rems_from_px(1100.))
438                    .size_full()
439                    .m_auto()
440                    .py_20()
441                    .px_12()
442                    .items_start()
443                    .gap_12()
444                    .child(self.render_nav(window, cx))
445                    .child(
446                        div()
447                            .pl_12()
448                            .border_l_1()
449                            .border_color(cx.theme().colors().border_variant.opacity(0.5))
450                            .size_full()
451                            .child(self.render_page(window, cx)),
452                    ),
453            )
454    }
455}
456
457impl EventEmitter<ItemEvent> for Onboarding {}
458
459impl Focusable for Onboarding {
460    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
461        self.focus_handle.clone()
462    }
463}
464
465impl Item for Onboarding {
466    type Event = ItemEvent;
467
468    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
469        "Onboarding".into()
470    }
471
472    fn telemetry_event_text(&self) -> Option<&'static str> {
473        Some("Onboarding Page Opened")
474    }
475
476    fn show_toolbar(&self) -> bool {
477        false
478    }
479
480    fn clone_on_split(
481        &self,
482        _workspace_id: Option<WorkspaceId>,
483        _: &mut Window,
484        cx: &mut Context<Self>,
485    ) -> Option<Entity<Self>> {
486        Some(Onboarding::new(
487            self.workspace.clone(),
488            self.user_store.clone(),
489            cx,
490        ))
491    }
492
493    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
494        f(*event)
495    }
496}
497
498pub async fn handle_import_vscode_settings(
499    source: VsCodeSettingsSource,
500    skip_prompt: bool,
501    fs: Arc<dyn Fs>,
502    cx: &mut AsyncWindowContext,
503) {
504    use util::truncate_and_remove_front;
505
506    let vscode_settings =
507        match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
508            Ok(vscode_settings) => vscode_settings,
509            Err(err) => {
510                zlog::error!("{err}");
511                let _ = cx.prompt(
512                    gpui::PromptLevel::Info,
513                    &format!("Could not find or load a {source} settings file"),
514                    None,
515                    &["Ok"],
516                );
517                return;
518            }
519        };
520
521    if !skip_prompt {
522        let prompt = cx.prompt(
523            gpui::PromptLevel::Warning,
524            &format!(
525                "Importing {} settings may overwrite your existing settings. \
526                Will import settings from {}",
527                vscode_settings.source,
528                truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
529            ),
530            None,
531            &["Ok", "Cancel"],
532        );
533        let result = cx.spawn(async move |_| prompt.await.ok()).await;
534        if result != Some(0) {
535            return;
536        }
537    };
538
539    cx.update(|_, cx| {
540        let source = vscode_settings.source;
541        let path = vscode_settings.path.clone();
542        cx.global::<SettingsStore>()
543            .import_vscode_settings(fs, vscode_settings);
544        zlog::info!("Imported {source} settings from {}", path.display());
545    })
546    .ok();
547}