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, ButtonLike, FluentBuilder, Headline, KeyBinding, ParentElement as _,
 18    StatefulInteractiveElement, 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(
348                                ButtonLike::new("skip_all")
349                                    .child(Label::new("Skip All").ml_1())
350                                    .on_click(|_, _, cx| {
351                                        with_active_or_new_workspace(
352                                            cx,
353                                            |workspace, window, cx| {
354                                                let Some((onboarding_id, onboarding_idx)) =
355                                                    workspace
356                                                        .active_pane()
357                                                        .read(cx)
358                                                        .items()
359                                                        .enumerate()
360                                                        .find_map(|(idx, item)| {
361                                                            let _ =
362                                                                item.downcast::<Onboarding>()?;
363                                                            Some((item.item_id(), idx))
364                                                        })
365                                                else {
366                                                    return;
367                                                };
368
369                                                workspace.active_pane().update(cx, |pane, cx| {
370                                                    // Get the index here to get around the borrow checker
371                                                    let idx = pane.items().enumerate().find_map(
372                                                        |(idx, item)| {
373                                                            let _ =
374                                                                item.downcast::<WelcomePage>()?;
375                                                            Some(idx)
376                                                        },
377                                                    );
378
379                                                    if let Some(idx) = idx {
380                                                        pane.activate_item(
381                                                            idx, true, true, window, cx,
382                                                        );
383                                                    } else {
384                                                        let item =
385                                                            Box::new(WelcomePage::new(window, cx));
386                                                        pane.add_item(
387                                                            item,
388                                                            true,
389                                                            true,
390                                                            Some(onboarding_idx),
391                                                            window,
392                                                            cx,
393                                                        );
394                                                    }
395
396                                                    pane.remove_item(
397                                                        onboarding_id,
398                                                        false,
399                                                        false,
400                                                        window,
401                                                        cx,
402                                                    );
403                                                });
404                                            },
405                                        );
406                                    }),
407                            ),
408                    ),
409            )
410            .child(
411                if let Some(user) = self.user_store.read(cx).current_user() {
412                    h_flex()
413                        .pl_1p5()
414                        .gap_2()
415                        .child(Avatar::new(user.avatar_uri.clone()))
416                        .child(Label::new(user.github_login.clone()))
417                        .into_any_element()
418                } else {
419                    Button::new("sign_in", "Sign In")
420                        .style(ButtonStyle::Outlined)
421                        .full_width()
422                        .on_click(|_, window, cx| {
423                            let client = Client::global(cx);
424                            window
425                                .spawn(cx, async move |cx| {
426                                    client
427                                        .sign_in_with_optional_connect(true, &cx)
428                                        .await
429                                        .notify_async_err(cx);
430                                })
431                                .detach();
432                        })
433                        .into_any_element()
434                },
435            )
436    }
437
438    fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
439        match self.selected_page {
440            SelectedPage::Basics => {
441                crate::basics_page::render_basics_page(window, cx).into_any_element()
442            }
443            SelectedPage::Editing => {
444                crate::editing_page::render_editing_page(window, cx).into_any_element()
445            }
446            SelectedPage::AiSetup => {
447                crate::ai_setup_page::render_ai_setup_page(&self, window, cx).into_any_element()
448            }
449        }
450    }
451}
452
453impl Render for Onboarding {
454    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
455        h_flex()
456            .image_cache(gpui::retain_all("onboarding-page"))
457            .key_context("onboarding-page")
458            .size_full()
459            .bg(cx.theme().colors().editor_background)
460            .child(
461                h_flex()
462                    .max_w(rems_from_px(1100.))
463                    .size_full()
464                    .m_auto()
465                    .py_20()
466                    .px_12()
467                    .items_start()
468                    .gap_12()
469                    .child(self.render_nav(window, cx))
470                    .child(
471                        v_flex()
472                            .max_w_full()
473                            .min_w_0()
474                            .pl_12()
475                            .border_l_1()
476                            .border_color(cx.theme().colors().border_variant.opacity(0.5))
477                            .size_full()
478                            .child(self.render_page(window, cx)),
479                    ),
480            )
481    }
482}
483
484impl EventEmitter<ItemEvent> for Onboarding {}
485
486impl Focusable for Onboarding {
487    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
488        self.focus_handle.clone()
489    }
490}
491
492impl Item for Onboarding {
493    type Event = ItemEvent;
494
495    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
496        "Onboarding".into()
497    }
498
499    fn telemetry_event_text(&self) -> Option<&'static str> {
500        Some("Onboarding Page Opened")
501    }
502
503    fn show_toolbar(&self) -> bool {
504        false
505    }
506
507    fn clone_on_split(
508        &self,
509        _workspace_id: Option<WorkspaceId>,
510        _: &mut Window,
511        cx: &mut Context<Self>,
512    ) -> Option<Entity<Self>> {
513        self.workspace
514            .update(cx, |workspace, cx| Onboarding::new(workspace, cx))
515            .ok()
516    }
517
518    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
519        f(*event)
520    }
521}
522
523pub async fn handle_import_vscode_settings(
524    source: VsCodeSettingsSource,
525    skip_prompt: bool,
526    fs: Arc<dyn Fs>,
527    cx: &mut AsyncWindowContext,
528) {
529    use util::truncate_and_remove_front;
530
531    let vscode_settings =
532        match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
533            Ok(vscode_settings) => vscode_settings,
534            Err(err) => {
535                zlog::error!("{err}");
536                let _ = cx.prompt(
537                    gpui::PromptLevel::Info,
538                    &format!("Could not find or load a {source} settings file"),
539                    None,
540                    &["Ok"],
541                );
542                return;
543            }
544        };
545
546    if !skip_prompt {
547        let prompt = cx.prompt(
548            gpui::PromptLevel::Warning,
549            &format!(
550                "Importing {} settings may overwrite your existing settings. \
551                Will import settings from {}",
552                vscode_settings.source,
553                truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
554            ),
555            None,
556            &["Ok", "Cancel"],
557        );
558        let result = cx.spawn(async move |_| prompt.await.ok()).await;
559        if result != Some(0) {
560            return;
561        }
562    };
563
564    cx.update(|_, cx| {
565        let source = vscode_settings.source;
566        let path = vscode_settings.path.clone();
567        cx.global::<SettingsStore>()
568            .import_vscode_settings(fs, vscode_settings);
569        zlog::info!("Imported {source} settings from {}", path.display());
570    })
571    .ok();
572}