onboarding.rs

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