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, register_serializable_item, 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    register_serializable_item::<Onboarding>(cx);
201}
202
203pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyhow::Result<()>> {
204    open_new(
205        Default::default(),
206        app_state,
207        cx,
208        |workspace, window, cx| {
209            {
210                workspace.toggle_dock(DockPosition::Left, window, cx);
211                let onboarding_page = Onboarding::new(workspace, cx);
212                workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
213
214                window.focus(&onboarding_page.focus_handle(cx));
215
216                cx.notify();
217            };
218            db::write_and_log(cx, || {
219                KEY_VALUE_STORE.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
220            });
221        },
222    )
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226enum SelectedPage {
227    Basics,
228    Editing,
229    AiSetup,
230}
231
232struct Onboarding {
233    workspace: WeakEntity<Workspace>,
234    focus_handle: FocusHandle,
235    selected_page: SelectedPage,
236    user_store: Entity<UserStore>,
237    _settings_subscription: Subscription,
238}
239
240impl Onboarding {
241    fn new(workspace: &Workspace, cx: &mut App) -> Entity<Self> {
242        cx.new(|cx| Self {
243            workspace: workspace.weak_handle(),
244            focus_handle: cx.focus_handle(),
245            selected_page: SelectedPage::Basics,
246            user_store: workspace.user_store().clone(),
247            _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
248        })
249    }
250
251    fn set_page(&mut self, page: SelectedPage, cx: &mut Context<Self>) {
252        self.selected_page = page;
253        cx.notify();
254        cx.emit(ItemEvent::UpdateTab);
255    }
256
257    fn go_to_welcome_page(&self, cx: &mut App) {
258        with_active_or_new_workspace(cx, |workspace, window, cx| {
259            let Some((onboarding_id, onboarding_idx)) = workspace
260                .active_pane()
261                .read(cx)
262                .items()
263                .enumerate()
264                .find_map(|(idx, item)| {
265                    let _ = item.downcast::<Onboarding>()?;
266                    Some((item.item_id(), idx))
267                })
268            else {
269                return;
270            };
271
272            workspace.active_pane().update(cx, |pane, cx| {
273                // Get the index here to get around the borrow checker
274                let idx = pane.items().enumerate().find_map(|(idx, item)| {
275                    let _ = item.downcast::<WelcomePage>()?;
276                    Some(idx)
277                });
278
279                if let Some(idx) = idx {
280                    pane.activate_item(idx, true, true, window, cx);
281                } else {
282                    let item = Box::new(WelcomePage::new(window, cx));
283                    pane.add_item(item, true, true, Some(onboarding_idx), window, cx);
284                }
285
286                pane.remove_item(onboarding_id, false, false, window, cx);
287            });
288        });
289    }
290
291    fn render_nav_buttons(
292        &mut self,
293        window: &mut Window,
294        cx: &mut Context<Self>,
295    ) -> [impl IntoElement; 3] {
296        let pages = [
297            SelectedPage::Basics,
298            SelectedPage::Editing,
299            SelectedPage::AiSetup,
300        ];
301
302        let text = ["Basics", "Editing", "AI Setup"];
303
304        let actions: [&dyn Action; 3] = [
305            &ActivateBasicsPage,
306            &ActivateEditingPage,
307            &ActivateAISetupPage,
308        ];
309
310        let mut binding = actions.map(|action| {
311            KeyBinding::for_action_in(action, &self.focus_handle, window, cx)
312                .map(|kb| kb.size(rems_from_px(12.)))
313        });
314
315        pages.map(|page| {
316            let i = page as usize;
317            let selected = self.selected_page == page;
318            h_flex()
319                .id(text[i])
320                .relative()
321                .w_full()
322                .gap_2()
323                .px_2()
324                .py_0p5()
325                .justify_between()
326                .rounded_sm()
327                .when(selected, |this| {
328                    this.child(
329                        div()
330                            .h_4()
331                            .w_px()
332                            .bg(cx.theme().colors().text_accent)
333                            .absolute()
334                            .left_0(),
335                    )
336                })
337                .hover(|style| style.bg(cx.theme().colors().element_hover))
338                .child(Label::new(text[i]).map(|this| {
339                    if selected {
340                        this.color(Color::Default)
341                    } else {
342                        this.color(Color::Muted)
343                    }
344                }))
345                .child(binding[i].take().map_or(
346                    gpui::Empty.into_any_element(),
347                    IntoElement::into_any_element,
348                ))
349                .on_click(cx.listener(move |this, _, _, cx| {
350                    this.set_page(page, cx);
351                }))
352        })
353    }
354
355    fn render_nav(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
356        let ai_setup_page = matches!(self.selected_page, SelectedPage::AiSetup);
357
358        v_flex()
359            .h_full()
360            .w(rems_from_px(220.))
361            .flex_shrink_0()
362            .gap_4()
363            .justify_between()
364            .child(
365                v_flex()
366                    .gap_6()
367                    .child(
368                        h_flex()
369                            .px_2()
370                            .gap_4()
371                            .child(Vector::square(VectorName::ZedLogo, rems(2.5)))
372                            .child(
373                                v_flex()
374                                    .child(
375                                        Headline::new("Welcome to Zed").size(HeadlineSize::Small),
376                                    )
377                                    .child(
378                                        Label::new("The editor for what's next")
379                                            .color(Color::Muted)
380                                            .size(LabelSize::Small)
381                                            .italic(),
382                                    ),
383                            ),
384                    )
385                    .child(
386                        v_flex()
387                            .gap_4()
388                            .child(
389                                v_flex()
390                                    .py_4()
391                                    .border_y_1()
392                                    .border_color(cx.theme().colors().border_variant.opacity(0.5))
393                                    .gap_1()
394                                    .children(self.render_nav_buttons(window, cx)),
395                            )
396                            .map(|this| {
397                                if ai_setup_page {
398                                    this.child(
399                                        ButtonLike::new("start_building")
400                                            .style(ButtonStyle::Outlined)
401                                            .size(ButtonSize::Medium)
402                                            .child(
403                                                h_flex()
404                                                    .ml_1()
405                                                    .w_full()
406                                                    .justify_between()
407                                                    .child(Label::new("Start Building"))
408                                                    .child(
409                                                        Icon::new(IconName::Check)
410                                                            .size(IconSize::Small),
411                                                    ),
412                                            )
413                                            .on_click(cx.listener(|this, _, _, cx| {
414                                                this.go_to_welcome_page(cx);
415                                            })),
416                                    )
417                                } else {
418                                    this.child(
419                                        ButtonLike::new("skip_all")
420                                            .size(ButtonSize::Medium)
421                                            .child(Label::new("Skip All").ml_1())
422                                            .on_click(cx.listener(|this, _, _, cx| {
423                                                this.go_to_welcome_page(cx);
424                                            })),
425                                    )
426                                }
427                            }),
428                    ),
429            )
430            .child(
431                if let Some(user) = self.user_store.read(cx).current_user() {
432                    h_flex()
433                        .pl_1p5()
434                        .gap_2()
435                        .child(Avatar::new(user.avatar_uri.clone()))
436                        .child(Label::new(user.github_login.clone()))
437                        .into_any_element()
438                } else {
439                    Button::new("sign_in", "Sign In")
440                        .full_width()
441                        .style(ButtonStyle::Outlined)
442                        .on_click(|_, window, cx| {
443                            let client = Client::global(cx);
444                            window
445                                .spawn(cx, async move |cx| {
446                                    client
447                                        .sign_in_with_optional_connect(true, &cx)
448                                        .await
449                                        .notify_async_err(cx);
450                                })
451                                .detach();
452                        })
453                        .into_any_element()
454                },
455            )
456    }
457
458    fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
459        match self.selected_page {
460            SelectedPage::Basics => {
461                crate::basics_page::render_basics_page(window, cx).into_any_element()
462            }
463            SelectedPage::Editing => {
464                crate::editing_page::render_editing_page(window, cx).into_any_element()
465            }
466            SelectedPage::AiSetup => {
467                crate::ai_setup_page::render_ai_setup_page(&self, window, cx).into_any_element()
468            }
469        }
470    }
471}
472
473impl Render for Onboarding {
474    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
475        h_flex()
476            .image_cache(gpui::retain_all("onboarding-page"))
477            .key_context({
478                let mut ctx = KeyContext::new_with_defaults();
479                ctx.add("Onboarding");
480                ctx
481            })
482            .track_focus(&self.focus_handle)
483            .size_full()
484            .bg(cx.theme().colors().editor_background)
485            .on_action(cx.listener(|this, _: &ActivateBasicsPage, _, cx| {
486                this.set_page(SelectedPage::Basics, cx);
487            }))
488            .on_action(cx.listener(|this, _: &ActivateEditingPage, _, cx| {
489                this.set_page(SelectedPage::Editing, cx);
490            }))
491            .on_action(cx.listener(|this, _: &ActivateAISetupPage, _, cx| {
492                this.set_page(SelectedPage::AiSetup, cx);
493            }))
494            .child(
495                h_flex()
496                    .max_w(rems_from_px(1100.))
497                    .size_full()
498                    .m_auto()
499                    .py_20()
500                    .px_12()
501                    .items_start()
502                    .gap_12()
503                    .child(self.render_nav(window, cx))
504                    .child(
505                        v_flex()
506                            .max_w_full()
507                            .min_w_0()
508                            .pl_12()
509                            .border_l_1()
510                            .border_color(cx.theme().colors().border_variant.opacity(0.5))
511                            .size_full()
512                            .child(self.render_page(window, cx)),
513                    ),
514            )
515    }
516}
517
518impl EventEmitter<ItemEvent> for Onboarding {}
519
520impl Focusable for Onboarding {
521    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
522        self.focus_handle.clone()
523    }
524}
525
526impl Item for Onboarding {
527    type Event = ItemEvent;
528
529    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
530        "Onboarding".into()
531    }
532
533    fn telemetry_event_text(&self) -> Option<&'static str> {
534        Some("Onboarding Page Opened")
535    }
536
537    fn show_toolbar(&self) -> bool {
538        false
539    }
540
541    fn clone_on_split(
542        &self,
543        _workspace_id: Option<WorkspaceId>,
544        _: &mut Window,
545        cx: &mut Context<Self>,
546    ) -> Option<Entity<Self>> {
547        self.workspace
548            .update(cx, |workspace, cx| Onboarding::new(workspace, cx))
549            .ok()
550    }
551
552    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
553        f(*event)
554    }
555}
556
557pub async fn handle_import_vscode_settings(
558    source: VsCodeSettingsSource,
559    skip_prompt: bool,
560    fs: Arc<dyn Fs>,
561    cx: &mut AsyncWindowContext,
562) {
563    use util::truncate_and_remove_front;
564
565    let vscode_settings =
566        match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
567            Ok(vscode_settings) => vscode_settings,
568            Err(err) => {
569                zlog::error!("{err}");
570                let _ = cx.prompt(
571                    gpui::PromptLevel::Info,
572                    &format!("Could not find or load a {source} settings file"),
573                    None,
574                    &["Ok"],
575                );
576                return;
577            }
578        };
579
580    if !skip_prompt {
581        let prompt = cx.prompt(
582            gpui::PromptLevel::Warning,
583            &format!(
584                "Importing {} settings may overwrite your existing settings. \
585                Will import settings from {}",
586                vscode_settings.source,
587                truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
588            ),
589            None,
590            &["Ok", "Cancel"],
591        );
592        let result = cx.spawn(async move |_| prompt.await.ok()).await;
593        if result != Some(0) {
594            return;
595        }
596    };
597
598    cx.update(|_, cx| {
599        let source = vscode_settings.source;
600        let path = vscode_settings.path.clone();
601        cx.global::<SettingsStore>()
602            .import_vscode_settings(fs, vscode_settings);
603        zlog::info!("Imported {source} settings from {}", path.display());
604    })
605    .ok();
606}
607
608impl workspace::SerializableItem for Onboarding {
609    fn serialized_item_kind() -> &'static str {
610        "OnboardingPage"
611    }
612
613    fn cleanup(
614        workspace_id: workspace::WorkspaceId,
615        alive_items: Vec<workspace::ItemId>,
616        _window: &mut Window,
617        cx: &mut App,
618    ) -> gpui::Task<gpui::Result<()>> {
619        workspace::delete_unloaded_items(
620            alive_items,
621            workspace_id,
622            "onboarding_pages",
623            &persistence::ONBOARDING_PAGES,
624            cx,
625        )
626    }
627
628    fn deserialize(
629        _project: Entity<project::Project>,
630        workspace: WeakEntity<Workspace>,
631        workspace_id: workspace::WorkspaceId,
632        item_id: workspace::ItemId,
633        window: &mut Window,
634        cx: &mut App,
635    ) -> gpui::Task<gpui::Result<Entity<Self>>> {
636        window.spawn(cx, async move |cx| {
637            if let Some(page_number) =
638                persistence::ONBOARDING_PAGES.get_onboarding_page(item_id, workspace_id)?
639            {
640                let page = match page_number {
641                    0 => Some(SelectedPage::Basics),
642                    1 => Some(SelectedPage::Editing),
643                    2 => Some(SelectedPage::AiSetup),
644                    _ => None,
645                };
646                workspace.update(cx, |workspace, cx| {
647                    let onboarding_page = Onboarding::new(workspace, cx);
648                    if let Some(page) = page {
649                        zlog::info!("Onboarding page {page:?} loaded");
650                        onboarding_page.update(cx, |onboarding_page, cx| {
651                            onboarding_page.set_page(page, cx);
652                        })
653                    }
654                    onboarding_page
655                })
656            } else {
657                Err(anyhow::anyhow!("No onboarding page to deserialize"))
658            }
659        })
660    }
661
662    fn serialize(
663        &mut self,
664        workspace: &mut Workspace,
665        item_id: workspace::ItemId,
666        _closing: bool,
667        _window: &mut Window,
668        cx: &mut ui::Context<Self>,
669    ) -> Option<gpui::Task<gpui::Result<()>>> {
670        let workspace_id = workspace.database_id()?;
671        let page_number = self.selected_page as u16;
672        Some(cx.background_spawn(async move {
673            persistence::ONBOARDING_PAGES
674                .save_onboarding_page(item_id, workspace_id, page_number)
675                .await
676        }))
677    }
678
679    fn should_serialize(&self, event: &Self::Event) -> bool {
680        event == &ItemEvent::UpdateTab
681    }
682}
683
684mod persistence {
685    use db::{define_connection, query, sqlez_macros::sql};
686    use workspace::WorkspaceDb;
687
688    define_connection! {
689        pub static ref ONBOARDING_PAGES: OnboardingPagesDb<WorkspaceDb> =
690            &[
691                sql!(
692                    CREATE TABLE onboarding_pages (
693                        workspace_id INTEGER,
694                        item_id INTEGER UNIQUE,
695                        page_number INTEGER,
696
697                        PRIMARY KEY(workspace_id, item_id),
698                        FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
699                        ON DELETE CASCADE
700                    ) STRICT;
701                ),
702            ];
703    }
704
705    impl OnboardingPagesDb {
706        query! {
707            pub async fn save_onboarding_page(
708                item_id: workspace::ItemId,
709                workspace_id: workspace::WorkspaceId,
710                page_number: u16
711            ) -> Result<()> {
712                INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id, page_number)
713                VALUES (?, ?, ?)
714            }
715        }
716
717        query! {
718            pub fn get_onboarding_page(
719                item_id: workspace::ItemId,
720                workspace_id: workspace::WorkspaceId
721            ) -> Result<Option<u16>> {
722                SELECT page_number
723                FROM onboarding_pages
724                WHERE item_id = ? AND workspace_id = ?
725            }
726        }
727    }
728}