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 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    user_store: Entity<UserStore>,
224    _settings_subscription: Subscription,
225}
226
227impl Onboarding {
228    fn new(workspace: &Workspace, cx: &mut App) -> Entity<Self> {
229        cx.new(|cx| Self {
230            workspace: workspace.weak_handle(),
231            focus_handle: cx.focus_handle(),
232            selected_page: SelectedPage::Basics,
233            user_store: workspace.user_store().clone(),
234            _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
235        })
236    }
237
238    fn render_nav_button(
239        &mut self,
240        page: SelectedPage,
241        _: &mut Window,
242        cx: &mut Context<Self>,
243    ) -> impl IntoElement {
244        let text = match page {
245            SelectedPage::Basics => "Basics",
246            SelectedPage::Editing => "Editing",
247            SelectedPage::AiSetup => "AI Setup",
248        };
249
250        let binding = match page {
251            SelectedPage::Basics => {
252                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-1").unwrap()], cx)
253                    .map(|kb| kb.size(rems_from_px(12.)))
254            }
255            SelectedPage::Editing => {
256                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-2").unwrap()], cx)
257                    .map(|kb| kb.size(rems_from_px(12.)))
258            }
259            SelectedPage::AiSetup => {
260                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-3").unwrap()], cx)
261                    .map(|kb| kb.size(rems_from_px(12.)))
262            }
263        };
264
265        let selected = self.selected_page == page;
266
267        h_flex()
268            .id(text)
269            .relative()
270            .w_full()
271            .gap_2()
272            .px_2()
273            .py_0p5()
274            .justify_between()
275            .rounded_sm()
276            .when(selected, |this| {
277                this.child(
278                    div()
279                        .h_4()
280                        .w_px()
281                        .bg(cx.theme().colors().text_accent)
282                        .absolute()
283                        .left_0(),
284                )
285            })
286            .hover(|style| style.bg(cx.theme().colors().element_hover))
287            .child(Label::new(text).map(|this| {
288                if selected {
289                    this.color(Color::Default)
290                } else {
291                    this.color(Color::Muted)
292                }
293            }))
294            .child(binding)
295            .on_click(cx.listener(move |this, _, _, cx| {
296                this.selected_page = page;
297                cx.notify();
298            }))
299    }
300
301    fn render_nav(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
302        v_flex()
303            .h_full()
304            .w(rems_from_px(220.))
305            .flex_shrink_0()
306            .gap_4()
307            .justify_between()
308            .child(
309                v_flex()
310                    .gap_6()
311                    .child(
312                        h_flex()
313                            .px_2()
314                            .gap_4()
315                            .child(Vector::square(VectorName::ZedLogo, rems(2.5)))
316                            .child(
317                                v_flex()
318                                    .child(
319                                        Headline::new("Welcome to Zed").size(HeadlineSize::Small),
320                                    )
321                                    .child(
322                                        Label::new("The editor for what's next")
323                                            .color(Color::Muted)
324                                            .size(LabelSize::Small)
325                                            .italic(),
326                                    ),
327                            ),
328                    )
329                    .child(
330                        v_flex()
331                            .gap_4()
332                            .child(
333                                v_flex()
334                                    .py_4()
335                                    .border_y_1()
336                                    .border_color(cx.theme().colors().border_variant.opacity(0.5))
337                                    .gap_1()
338                                    .children([
339                                        self.render_nav_button(SelectedPage::Basics, window, cx)
340                                            .into_element(),
341                                        self.render_nav_button(SelectedPage::Editing, window, cx)
342                                            .into_element(),
343                                        self.render_nav_button(SelectedPage::AiSetup, window, cx)
344                                            .into_element(),
345                                    ]),
346                            )
347                            .child(Button::new("skip_all", "Skip All")),
348                    ),
349            )
350            .child(
351                if let Some(user) = self.user_store.read(cx).current_user() {
352                    h_flex()
353                        .gap_2()
354                        .child(Avatar::new(user.avatar_uri.clone()))
355                        .child(Label::new(user.github_login.clone()))
356                        .into_any_element()
357                } else {
358                    Button::new("sign_in", "Sign In")
359                        .style(ButtonStyle::Outlined)
360                        .full_width()
361                        .on_click(|_, window, cx| {
362                            let client = Client::global(cx);
363                            window
364                                .spawn(cx, async move |cx| {
365                                    client
366                                        .sign_in_with_optional_connect(true, &cx)
367                                        .await
368                                        .notify_async_err(cx);
369                                })
370                                .detach();
371                        })
372                        .into_any_element()
373                },
374            )
375    }
376
377    fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
378        match self.selected_page {
379            SelectedPage::Basics => {
380                crate::basics_page::render_basics_page(window, cx).into_any_element()
381            }
382            SelectedPage::Editing => {
383                crate::editing_page::render_editing_page(window, cx).into_any_element()
384            }
385            SelectedPage::AiSetup => {
386                crate::ai_setup_page::render_ai_setup_page(&self, window, cx).into_any_element()
387            }
388        }
389    }
390}
391
392impl Render for Onboarding {
393    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
394        h_flex()
395            .image_cache(gpui::retain_all("onboarding-page"))
396            .key_context("onboarding-page")
397            .size_full()
398            .bg(cx.theme().colors().editor_background)
399            .child(
400                h_flex()
401                    .max_w(rems_from_px(1100.))
402                    .size_full()
403                    .m_auto()
404                    .py_20()
405                    .px_12()
406                    .items_start()
407                    .gap_12()
408                    .child(self.render_nav(window, cx))
409                    .child(
410                        v_flex()
411                            .max_w_full()
412                            .min_w_0()
413                            .pl_12()
414                            .border_l_1()
415                            .border_color(cx.theme().colors().border_variant.opacity(0.5))
416                            .size_full()
417                            .child(self.render_page(window, cx)),
418                    ),
419            )
420    }
421}
422
423impl EventEmitter<ItemEvent> for Onboarding {}
424
425impl Focusable for Onboarding {
426    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
427        self.focus_handle.clone()
428    }
429}
430
431impl Item for Onboarding {
432    type Event = ItemEvent;
433
434    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
435        "Onboarding".into()
436    }
437
438    fn telemetry_event_text(&self) -> Option<&'static str> {
439        Some("Onboarding Page Opened")
440    }
441
442    fn show_toolbar(&self) -> bool {
443        false
444    }
445
446    fn clone_on_split(
447        &self,
448        _workspace_id: Option<WorkspaceId>,
449        _: &mut Window,
450        cx: &mut Context<Self>,
451    ) -> Option<Entity<Self>> {
452        self.workspace
453            .update(cx, |workspace, cx| Onboarding::new(workspace, cx))
454            .ok()
455    }
456
457    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
458        f(*event)
459    }
460}
461
462pub async fn handle_import_vscode_settings(
463    source: VsCodeSettingsSource,
464    skip_prompt: bool,
465    fs: Arc<dyn Fs>,
466    cx: &mut AsyncWindowContext,
467) {
468    use util::truncate_and_remove_front;
469
470    let vscode_settings =
471        match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
472            Ok(vscode_settings) => vscode_settings,
473            Err(err) => {
474                zlog::error!("{err}");
475                let _ = cx.prompt(
476                    gpui::PromptLevel::Info,
477                    &format!("Could not find or load a {source} settings file"),
478                    None,
479                    &["Ok"],
480                );
481                return;
482            }
483        };
484
485    if !skip_prompt {
486        let prompt = cx.prompt(
487            gpui::PromptLevel::Warning,
488            &format!(
489                "Importing {} settings may overwrite your existing settings. \
490                Will import settings from {}",
491                vscode_settings.source,
492                truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
493            ),
494            None,
495            &["Ok", "Cancel"],
496        );
497        let result = cx.spawn(async move |_| prompt.await.ok()).await;
498        if result != Some(0) {
499            return;
500        }
501    };
502
503    cx.update(|_, cx| {
504        let source = vscode_settings.source;
505        let path = vscode_settings.path.clone();
506        cx.global::<SettingsStore>()
507            .import_vscode_settings(fs, vscode_settings);
508        zlog::info!("Imported {source} settings from {}", path.display());
509    })
510    .ok();
511}