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