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, KeyContext, Render, SharedString, Subscription, Task,
 10    WeakEntity, 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
 68actions!(
 69    onboarding,
 70    [
 71        /// Activates the Basics page.
 72        ActivateBasicsPage,
 73        /// Activates the Editing page.
 74        ActivateEditingPage,
 75        /// Activates the AI Setup page.
 76        ActivateAISetupPage,
 77    ]
 78);
 79
 80pub fn init(cx: &mut App) {
 81    cx.on_action(|_: &OpenOnboarding, cx| {
 82        with_active_or_new_workspace(cx, |workspace, window, cx| {
 83            workspace
 84                .with_local_workspace(window, cx, |workspace, window, cx| {
 85                    let existing = workspace
 86                        .active_pane()
 87                        .read(cx)
 88                        .items()
 89                        .find_map(|item| item.downcast::<Onboarding>());
 90
 91                    if let Some(existing) = existing {
 92                        workspace.activate_item(&existing, true, true, window, cx);
 93                    } else {
 94                        let settings_page = Onboarding::new(workspace, cx);
 95                        workspace.add_item_to_active_pane(
 96                            Box::new(settings_page),
 97                            None,
 98                            true,
 99                            window,
100                            cx,
101                        )
102                    }
103                })
104                .detach();
105        });
106    });
107
108    cx.on_action(|_: &ShowWelcome, cx| {
109        with_active_or_new_workspace(cx, |workspace, window, cx| {
110            workspace
111                .with_local_workspace(window, cx, |workspace, window, cx| {
112                    let existing = workspace
113                        .active_pane()
114                        .read(cx)
115                        .items()
116                        .find_map(|item| item.downcast::<WelcomePage>());
117
118                    if let Some(existing) = existing {
119                        workspace.activate_item(&existing, true, true, window, cx);
120                    } else {
121                        let settings_page = WelcomePage::new(window, cx);
122                        workspace.add_item_to_active_pane(
123                            Box::new(settings_page),
124                            None,
125                            true,
126                            window,
127                            cx,
128                        )
129                    }
130                })
131                .detach();
132        });
133    });
134
135    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
136        workspace.register_action(|_workspace, action: &ImportVsCodeSettings, window, cx| {
137            let fs = <dyn Fs>::global(cx);
138            let action = *action;
139
140            window
141                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
142                    handle_import_vscode_settings(
143                        VsCodeSettingsSource::VsCode,
144                        action.skip_prompt,
145                        fs,
146                        cx,
147                    )
148                    .await
149                })
150                .detach();
151        });
152
153        workspace.register_action(|_workspace, action: &ImportCursorSettings, window, cx| {
154            let fs = <dyn Fs>::global(cx);
155            let action = *action;
156
157            window
158                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
159                    handle_import_vscode_settings(
160                        VsCodeSettingsSource::Cursor,
161                        action.skip_prompt,
162                        fs,
163                        cx,
164                    )
165                    .await
166                })
167                .detach();
168        });
169    })
170    .detach();
171
172    cx.observe_new::<Workspace>(|_, window, cx| {
173        let Some(window) = window else {
174            return;
175        };
176
177        let onboarding_actions = [
178            std::any::TypeId::of::<OpenOnboarding>(),
179            std::any::TypeId::of::<ShowWelcome>(),
180        ];
181
182        CommandPaletteFilter::update_global(cx, |filter, _cx| {
183            filter.hide_action_types(&onboarding_actions);
184        });
185
186        cx.observe_flag::<OnBoardingFeatureFlag, _>(window, move |is_enabled, _, _, cx| {
187            if is_enabled {
188                CommandPaletteFilter::update_global(cx, |filter, _cx| {
189                    filter.show_action_types(onboarding_actions.iter());
190                });
191            } else {
192                CommandPaletteFilter::update_global(cx, |filter, _cx| {
193                    filter.hide_action_types(&onboarding_actions);
194                });
195            }
196        })
197        .detach();
198    })
199    .detach();
200}
201
202pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyhow::Result<()>> {
203    open_new(
204        Default::default(),
205        app_state,
206        cx,
207        |workspace, window, cx| {
208            {
209                workspace.toggle_dock(DockPosition::Left, window, cx);
210                let onboarding_page = Onboarding::new(workspace, cx);
211                workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
212
213                window.focus(&onboarding_page.focus_handle(cx));
214
215                cx.notify();
216            };
217            db::write_and_log(cx, || {
218                KEY_VALUE_STORE.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
219            });
220        },
221    )
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225enum SelectedPage {
226    Basics,
227    Editing,
228    AiSetup,
229}
230
231struct Onboarding {
232    workspace: WeakEntity<Workspace>,
233    focus_handle: FocusHandle,
234    selected_page: SelectedPage,
235    user_store: Entity<UserStore>,
236    _settings_subscription: Subscription,
237}
238
239impl Onboarding {
240    fn new(workspace: &Workspace, cx: &mut App) -> Entity<Self> {
241        cx.new(|cx| Self {
242            workspace: workspace.weak_handle(),
243            focus_handle: cx.focus_handle(),
244            selected_page: SelectedPage::Basics,
245            user_store: workspace.user_store().clone(),
246            _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
247        })
248    }
249
250    fn render_nav_buttons(
251        &mut self,
252        window: &mut Window,
253        cx: &mut Context<Self>,
254    ) -> [impl IntoElement; 3] {
255        let pages = [
256            SelectedPage::Basics,
257            SelectedPage::Editing,
258            SelectedPage::AiSetup,
259        ];
260
261        let text = ["Basics", "Editing", "AI Setup"];
262
263        let actions: [&dyn Action; 3] = [
264            &ActivateBasicsPage,
265            &ActivateEditingPage,
266            &ActivateAISetupPage,
267        ];
268
269        let mut binding = actions.map(|action| {
270            KeyBinding::for_action_in(action, &self.focus_handle, window, cx)
271                .map(|kb| kb.size(rems_from_px(12.)))
272        });
273
274        pages.map(|page| {
275            let i = page as usize;
276            let selected = self.selected_page == page;
277            h_flex()
278                .id(text[i])
279                .relative()
280                .w_full()
281                .gap_2()
282                .px_2()
283                .py_0p5()
284                .justify_between()
285                .rounded_sm()
286                .when(selected, |this| {
287                    this.child(
288                        div()
289                            .h_4()
290                            .w_px()
291                            .bg(cx.theme().colors().text_accent)
292                            .absolute()
293                            .left_0(),
294                    )
295                })
296                .hover(|style| style.bg(cx.theme().colors().element_hover))
297                .child(Label::new(text[i]).map(|this| {
298                    if selected {
299                        this.color(Color::Default)
300                    } else {
301                        this.color(Color::Muted)
302                    }
303                }))
304                .child(binding[i].take().map_or(
305                    gpui::Empty.into_any_element(),
306                    IntoElement::into_any_element,
307                ))
308                .on_click(cx.listener(move |this, _, _, cx| {
309                    this.selected_page = page;
310                    cx.notify();
311                }))
312        })
313    }
314
315    fn render_nav(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
316        v_flex()
317            .h_full()
318            .w(rems_from_px(220.))
319            .flex_shrink_0()
320            .gap_4()
321            .justify_between()
322            .child(
323                v_flex()
324                    .gap_6()
325                    .child(
326                        h_flex()
327                            .px_2()
328                            .gap_4()
329                            .child(Vector::square(VectorName::ZedLogo, rems(2.5)))
330                            .child(
331                                v_flex()
332                                    .child(
333                                        Headline::new("Welcome to Zed").size(HeadlineSize::Small),
334                                    )
335                                    .child(
336                                        Label::new("The editor for what's next")
337                                            .color(Color::Muted)
338                                            .size(LabelSize::Small)
339                                            .italic(),
340                                    ),
341                            ),
342                    )
343                    .child(
344                        v_flex()
345                            .gap_4()
346                            .child(
347                                v_flex()
348                                    .py_4()
349                                    .border_y_1()
350                                    .border_color(cx.theme().colors().border_variant.opacity(0.5))
351                                    .gap_1()
352                                    .children(self.render_nav_buttons(window, cx)),
353                            )
354                            .child(
355                                ButtonLike::new("skip_all")
356                                    .child(Label::new("Skip All").ml_1())
357                                    .on_click(|_, _, cx| {
358                                        with_active_or_new_workspace(
359                                            cx,
360                                            |workspace, window, cx| {
361                                                let Some((onboarding_id, onboarding_idx)) =
362                                                    workspace
363                                                        .active_pane()
364                                                        .read(cx)
365                                                        .items()
366                                                        .enumerate()
367                                                        .find_map(|(idx, item)| {
368                                                            let _ =
369                                                                item.downcast::<Onboarding>()?;
370                                                            Some((item.item_id(), idx))
371                                                        })
372                                                else {
373                                                    return;
374                                                };
375
376                                                workspace.active_pane().update(cx, |pane, cx| {
377                                                    // Get the index here to get around the borrow checker
378                                                    let idx = pane.items().enumerate().find_map(
379                                                        |(idx, item)| {
380                                                            let _ =
381                                                                item.downcast::<WelcomePage>()?;
382                                                            Some(idx)
383                                                        },
384                                                    );
385
386                                                    if let Some(idx) = idx {
387                                                        pane.activate_item(
388                                                            idx, true, true, window, cx,
389                                                        );
390                                                    } else {
391                                                        let item =
392                                                            Box::new(WelcomePage::new(window, cx));
393                                                        pane.add_item(
394                                                            item,
395                                                            true,
396                                                            true,
397                                                            Some(onboarding_idx),
398                                                            window,
399                                                            cx,
400                                                        );
401                                                    }
402
403                                                    pane.remove_item(
404                                                        onboarding_id,
405                                                        false,
406                                                        false,
407                                                        window,
408                                                        cx,
409                                                    );
410                                                });
411                                            },
412                                        );
413                                    }),
414                            ),
415                    ),
416            )
417            .child(
418                if let Some(user) = self.user_store.read(cx).current_user() {
419                    h_flex()
420                        .pl_1p5()
421                        .gap_2()
422                        .child(Avatar::new(user.avatar_uri.clone()))
423                        .child(Label::new(user.github_login.clone()))
424                        .into_any_element()
425                } else {
426                    Button::new("sign_in", "Sign In")
427                        .style(ButtonStyle::Outlined)
428                        .full_width()
429                        .on_click(|_, window, cx| {
430                            let client = Client::global(cx);
431                            window
432                                .spawn(cx, async move |cx| {
433                                    client
434                                        .sign_in_with_optional_connect(true, &cx)
435                                        .await
436                                        .notify_async_err(cx);
437                                })
438                                .detach();
439                        })
440                        .into_any_element()
441                },
442            )
443    }
444
445    fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
446        match self.selected_page {
447            SelectedPage::Basics => {
448                crate::basics_page::render_basics_page(window, cx).into_any_element()
449            }
450            SelectedPage::Editing => {
451                crate::editing_page::render_editing_page(window, cx).into_any_element()
452            }
453            SelectedPage::AiSetup => {
454                crate::ai_setup_page::render_ai_setup_page(&self, window, cx).into_any_element()
455            }
456        }
457    }
458}
459
460impl Render for Onboarding {
461    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
462        h_flex()
463            .image_cache(gpui::retain_all("onboarding-page"))
464            .key_context({
465                let mut ctx = KeyContext::new_with_defaults();
466                ctx.add("Onboarding");
467                ctx
468            })
469            .track_focus(&self.focus_handle)
470            .size_full()
471            .bg(cx.theme().colors().editor_background)
472            .on_action(cx.listener(|this, _: &ActivateBasicsPage, _, cx| {
473                this.selected_page = SelectedPage::Basics;
474                cx.notify();
475            }))
476            .on_action(cx.listener(|this, _: &ActivateEditingPage, _, cx| {
477                this.selected_page = SelectedPage::Editing;
478                cx.notify();
479            }))
480            .on_action(cx.listener(|this, _: &ActivateAISetupPage, _, cx| {
481                this.selected_page = SelectedPage::AiSetup;
482                cx.notify();
483            }))
484            .child(
485                h_flex()
486                    .max_w(rems_from_px(1100.))
487                    .size_full()
488                    .m_auto()
489                    .py_20()
490                    .px_12()
491                    .items_start()
492                    .gap_12()
493                    .child(self.render_nav(window, cx))
494                    .child(
495                        v_flex()
496                            .max_w_full()
497                            .min_w_0()
498                            .pl_12()
499                            .border_l_1()
500                            .border_color(cx.theme().colors().border_variant.opacity(0.5))
501                            .size_full()
502                            .child(self.render_page(window, cx)),
503                    ),
504            )
505    }
506}
507
508impl EventEmitter<ItemEvent> for Onboarding {}
509
510impl Focusable for Onboarding {
511    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
512        self.focus_handle.clone()
513    }
514}
515
516impl Item for Onboarding {
517    type Event = ItemEvent;
518
519    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
520        "Onboarding".into()
521    }
522
523    fn telemetry_event_text(&self) -> Option<&'static str> {
524        Some("Onboarding Page Opened")
525    }
526
527    fn show_toolbar(&self) -> bool {
528        false
529    }
530
531    fn clone_on_split(
532        &self,
533        _workspace_id: Option<WorkspaceId>,
534        _: &mut Window,
535        cx: &mut Context<Self>,
536    ) -> Option<Entity<Self>> {
537        self.workspace
538            .update(cx, |workspace, cx| Onboarding::new(workspace, cx))
539            .ok()
540    }
541
542    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
543        f(*event)
544    }
545}
546
547pub async fn handle_import_vscode_settings(
548    source: VsCodeSettingsSource,
549    skip_prompt: bool,
550    fs: Arc<dyn Fs>,
551    cx: &mut AsyncWindowContext,
552) {
553    use util::truncate_and_remove_front;
554
555    let vscode_settings =
556        match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
557            Ok(vscode_settings) => vscode_settings,
558            Err(err) => {
559                zlog::error!("{err}");
560                let _ = cx.prompt(
561                    gpui::PromptLevel::Info,
562                    &format!("Could not find or load a {source} settings file"),
563                    None,
564                    &["Ok"],
565                );
566                return;
567            }
568        };
569
570    if !skip_prompt {
571        let prompt = cx.prompt(
572            gpui::PromptLevel::Warning,
573            &format!(
574                "Importing {} settings may overwrite your existing settings. \
575                Will import settings from {}",
576                vscode_settings.source,
577                truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
578            ),
579            None,
580            &["Ok", "Cancel"],
581        );
582        let result = cx.spawn(async move |_| prompt.await.ok()).await;
583        if result != Some(0) {
584            return;
585        }
586    };
587
588    cx.update(|_, cx| {
589        let source = vscode_settings.source;
590        let path = vscode_settings.path.clone();
591        cx.global::<SettingsStore>()
592            .import_vscode_settings(fs, vscode_settings);
593        zlog::info!("Imported {source} settings from {}", path.display());
594    })
595    .ok();
596}