onboarding: Create basic onboarding page (#34723)

Anthony Eid and Ben Kunkle created

Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>

Change summary

Cargo.lock                          |  18 +
Cargo.toml                          |   2 
crates/onboarding/Cargo.toml        |  28 ++
crates/onboarding/LICENSE-GPL       |   1 
crates/onboarding/src/onboarding.rs | 352 +++++++++++++++++++++++++++++++
crates/zed/Cargo.toml               |   1 
crates/zed/src/main.rs              |   6 
crates/zed/src/zed.rs               |   1 
8 files changed, 409 insertions(+)

Detailed changes

Cargo.lock 🔗

@@ -10983,6 +10983,23 @@ dependencies = [
  "workspace-hack",
 ]
 
+[[package]]
+name = "onboarding"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "command_palette_hooks",
+ "db",
+ "feature_flags",
+ "fs",
+ "gpui",
+ "settings",
+ "theme",
+ "ui",
+ "workspace",
+ "workspace-hack",
+]
+
 [[package]]
 name = "once_cell"
 version = "1.21.3"
@@ -20223,6 +20240,7 @@ dependencies = [
  "nix 0.29.0",
  "node_runtime",
  "notifications",
+ "onboarding",
  "outline",
  "outline_panel",
  "parking_lot",

Cargo.toml 🔗

@@ -108,6 +108,7 @@ members = [
     "crates/node_runtime",
     "crates/notifications",
     "crates/ollama",
+    "crates/onboarding",
     "crates/open_ai",
     "crates/open_router",
     "crates/outline",
@@ -325,6 +326,7 @@ net = { path = "crates/net" }
 node_runtime = { path = "crates/node_runtime" }
 notifications = { path = "crates/notifications" }
 ollama = { path = "crates/ollama" }
+onboarding = { path = "crates/onboarding" }
 open_ai = { path = "crates/open_ai" }
 open_router = { path = "crates/open_router", features = ["schemars"] }
 outline = { path = "crates/outline" }

crates/onboarding/Cargo.toml 🔗

@@ -0,0 +1,28 @@
+[package]
+name = "onboarding"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/onboarding.rs"
+
+[features]
+default = []
+
+[dependencies]
+anyhow.workspace = true
+command_palette_hooks.workspace = true
+db.workspace = true
+feature_flags.workspace = true
+fs.workspace = true
+gpui.workspace = true
+settings.workspace = true
+theme.workspace = true
+ui.workspace = true
+workspace.workspace = true
+workspace-hack.workspace = true

crates/onboarding/src/onboarding.rs 🔗

@@ -0,0 +1,352 @@
+use command_palette_hooks::CommandPaletteFilter;
+use db::kvp::KEY_VALUE_STORE;
+use feature_flags::{FeatureFlag, FeatureFlagViewExt as _};
+use fs::Fs;
+use gpui::{
+    AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
+    IntoElement, Render, SharedString, Subscription, Task, WeakEntity, Window, actions,
+};
+use settings::{Settings, SettingsStore, update_settings_file};
+use std::sync::Arc;
+use theme::{ThemeMode, ThemeSettings};
+use ui::{
+    ButtonCommon as _, ButtonSize, ButtonStyle, Clickable as _, Color, Divider, FluentBuilder,
+    Headline, InteractiveElement, KeyBinding, Label, LabelCommon, ParentElement as _,
+    StatefulInteractiveElement, Styled, ToggleButton, Toggleable as _, Vector, VectorName, div,
+    h_flex, rems, v_container, v_flex,
+};
+use workspace::{
+    AppState, Workspace, WorkspaceId,
+    dock::DockPosition,
+    item::{Item, ItemEvent},
+    open_new, with_active_or_new_workspace,
+};
+
+pub struct OnBoardingFeatureFlag {}
+
+impl FeatureFlag for OnBoardingFeatureFlag {
+    const NAME: &'static str = "onboarding";
+}
+
+pub const FIRST_OPEN: &str = "first_open";
+
+actions!(
+    zed,
+    [
+        /// Opens the onboarding view.
+        OpenOnboarding
+    ]
+);
+
+pub fn init(cx: &mut App) {
+    cx.on_action(|_: &OpenOnboarding, cx| {
+        with_active_or_new_workspace(cx, |workspace, window, cx| {
+            workspace
+                .with_local_workspace(window, cx, |workspace, window, cx| {
+                    let existing = workspace
+                        .active_pane()
+                        .read(cx)
+                        .items()
+                        .find_map(|item| item.downcast::<Onboarding>());
+
+                    if let Some(existing) = existing {
+                        workspace.activate_item(&existing, true, true, window, cx);
+                    } else {
+                        let settings_page = Onboarding::new(workspace.weak_handle(), cx);
+                        workspace.add_item_to_active_pane(
+                            Box::new(settings_page),
+                            None,
+                            true,
+                            window,
+                            cx,
+                        )
+                    }
+                })
+                .detach();
+        });
+    });
+    cx.observe_new::<Workspace>(|_, window, cx| {
+        let Some(window) = window else {
+            return;
+        };
+
+        let onboarding_actions = [std::any::TypeId::of::<OpenOnboarding>()];
+
+        CommandPaletteFilter::update_global(cx, |filter, _cx| {
+            filter.hide_action_types(&onboarding_actions);
+        });
+
+        cx.observe_flag::<OnBoardingFeatureFlag, _>(window, move |is_enabled, _, _, cx| {
+            if is_enabled {
+                CommandPaletteFilter::update_global(cx, |filter, _cx| {
+                    filter.show_action_types(onboarding_actions.iter());
+                });
+            } else {
+                CommandPaletteFilter::update_global(cx, |filter, _cx| {
+                    filter.hide_action_types(&onboarding_actions);
+                });
+            }
+        })
+        .detach();
+    })
+    .detach();
+}
+
+pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyhow::Result<()>> {
+    open_new(
+        Default::default(),
+        app_state,
+        cx,
+        |workspace, window, cx| {
+            {
+                workspace.toggle_dock(DockPosition::Left, window, cx);
+                let onboarding_page = Onboarding::new(workspace.weak_handle(), cx);
+                workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
+
+                window.focus(&onboarding_page.focus_handle(cx));
+
+                cx.notify();
+            };
+            db::write_and_log(cx, || {
+                KEY_VALUE_STORE.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
+            });
+        },
+    )
+}
+
+fn read_theme_selection(cx: &App) -> ThemeMode {
+    let settings = ThemeSettings::get_global(cx);
+    settings
+        .theme_selection
+        .as_ref()
+        .and_then(|selection| selection.mode())
+        .unwrap_or_default()
+}
+
+fn write_theme_selection(theme_mode: ThemeMode, cx: &App) {
+    let fs = <dyn Fs>::global(cx);
+
+    update_settings_file::<ThemeSettings>(fs, cx, move |settings, _| {
+        settings.set_mode(theme_mode);
+    });
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum SelectedPage {
+    Basics,
+    Editing,
+    AiSetup,
+}
+
+struct Onboarding {
+    workspace: WeakEntity<Workspace>,
+    focus_handle: FocusHandle,
+    selected_page: SelectedPage,
+    _settings_subscription: Subscription,
+}
+
+impl Onboarding {
+    fn new(workspace: WeakEntity<Workspace>, cx: &mut App) -> Entity<Self> {
+        cx.new(|cx| Self {
+            workspace,
+            focus_handle: cx.focus_handle(),
+            selected_page: SelectedPage::Basics,
+            _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
+        })
+    }
+
+    fn render_page_nav(
+        &mut self,
+        page: SelectedPage,
+        _: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let text = match page {
+            SelectedPage::Basics => "Basics",
+            SelectedPage::Editing => "Editing",
+            SelectedPage::AiSetup => "AI Setup",
+        };
+        let binding = match page {
+            SelectedPage::Basics => {
+                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-1").unwrap()], cx)
+            }
+            SelectedPage::Editing => {
+                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-2").unwrap()], cx)
+            }
+            SelectedPage::AiSetup => {
+                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-3").unwrap()], cx)
+            }
+        };
+        let selected = self.selected_page == page;
+        h_flex()
+            .id(text)
+            .rounded_sm()
+            .child(text)
+            .child(binding)
+            .h_8()
+            .gap_2()
+            .px_2()
+            .py_0p5()
+            .w_full()
+            .justify_between()
+            .map(|this| {
+                if selected {
+                    this.bg(Color::Selected.color(cx))
+                        .border_l_1()
+                        .border_color(Color::Accent.color(cx))
+                } else {
+                    this.text_color(Color::Muted.color(cx))
+                }
+            })
+            .hover(|style| {
+                if selected {
+                    style.bg(Color::Selected.color(cx).opacity(0.6))
+                } else {
+                    style.bg(Color::Selected.color(cx).opacity(0.3))
+                }
+            })
+            .on_click(cx.listener(move |this, _, _, cx| {
+                this.selected_page = page;
+                cx.notify();
+            }))
+    }
+
+    fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
+        match self.selected_page {
+            SelectedPage::Basics => self.render_basics_page(window, cx).into_any_element(),
+            SelectedPage::Editing => self.render_editing_page(window, cx).into_any_element(),
+            SelectedPage::AiSetup => self.render_ai_setup_page(window, cx).into_any_element(),
+        }
+    }
+
+    fn render_basics_page(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let theme_mode = read_theme_selection(cx);
+
+        v_container().child(
+            h_flex()
+                .items_center()
+                .justify_between()
+                .child(Label::new("Theme"))
+                .child(
+                    h_flex()
+                        .rounded_md()
+                        .child(
+                            ToggleButton::new("light", "Light")
+                                .style(ButtonStyle::Filled)
+                                .size(ButtonSize::Large)
+                                .toggle_state(theme_mode == ThemeMode::Light)
+                                .on_click(|_, _, cx| write_theme_selection(ThemeMode::Light, cx))
+                                .first(),
+                        )
+                        .child(
+                            ToggleButton::new("dark", "Dark")
+                                .style(ButtonStyle::Filled)
+                                .size(ButtonSize::Large)
+                                .toggle_state(theme_mode == ThemeMode::Dark)
+                                .on_click(|_, _, cx| write_theme_selection(ThemeMode::Dark, cx))
+                                .last(),
+                        )
+                        .child(
+                            ToggleButton::new("system", "System")
+                                .style(ButtonStyle::Filled)
+                                .size(ButtonSize::Large)
+                                .toggle_state(theme_mode == ThemeMode::System)
+                                .on_click(|_, _, cx| write_theme_selection(ThemeMode::System, cx))
+                                .middle(),
+                        ),
+                ),
+        )
+    }
+
+    fn render_editing_page(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+        // div().child("editing page")
+        "Right"
+    }
+
+    fn render_ai_setup_page(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+        div().child("ai setup page")
+    }
+}
+
+impl Render for Onboarding {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        h_flex()
+            .image_cache(gpui::retain_all("onboarding-page"))
+            .key_context("onboarding-page")
+            .px_24()
+            .py_12()
+            .items_start()
+            .child(
+                v_flex()
+                    .w_1_3()
+                    .h_full()
+                    .child(
+                        h_flex()
+                            .pt_0p5()
+                            .child(Vector::square(VectorName::ZedLogo, rems(2.)))
+                            .child(
+                                v_flex()
+                                    .left_1()
+                                    .items_center()
+                                    .child(Headline::new("Welcome to Zed"))
+                                    .child(
+                                        Label::new("The editor for what's next")
+                                            .color(Color::Muted)
+                                            .italic(),
+                                    ),
+                            ),
+                    )
+                    .p_1()
+                    .child(Divider::horizontal_dashed())
+                    .child(
+                        v_flex().gap_1().children([
+                            self.render_page_nav(SelectedPage::Basics, window, cx)
+                                .into_element(),
+                            self.render_page_nav(SelectedPage::Editing, window, cx)
+                                .into_element(),
+                            self.render_page_nav(SelectedPage::AiSetup, window, cx)
+                                .into_element(),
+                        ]),
+                    ),
+            )
+            // .child(Divider::vertical_dashed())
+            .child(div().w_2_3().h_full().child(self.render_page(window, cx)))
+    }
+}
+
+impl EventEmitter<ItemEvent> for Onboarding {}
+
+impl Focusable for Onboarding {
+    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl Item for Onboarding {
+    type Event = ItemEvent;
+
+    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
+        "Onboarding".into()
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        Some("Onboarding Page Opened")
+    }
+
+    fn show_toolbar(&self) -> bool {
+        false
+    }
+
+    fn clone_on_split(
+        &self,
+        _workspace_id: Option<WorkspaceId>,
+        _: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<Entity<Self>> {
+        Some(Onboarding::new(self.workspace.clone(), cx))
+    }
+
+    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
+        f(*event)
+    }
+}

crates/zed/Cargo.toml 🔗

@@ -99,6 +99,7 @@ nc.workspace = true
 nix = { workspace = true, features = ["pthread", "signal"] }
 node_runtime.workspace = true
 notifications.workspace = true
+onboarding.workspace = true
 outline.workspace = true
 outline_panel.workspace = true
 parking_lot.workspace = true

crates/zed/src/main.rs 🔗

@@ -24,6 +24,7 @@ use reqwest_client::ReqwestClient;
 
 use assets::Assets;
 use node_runtime::{NodeBinaryOptions, NodeRuntime};
+use onboarding::show_onboarding_view;
 use parking_lot::Mutex;
 use project::project_settings::ProjectSettings;
 use recent_projects::{SshSettings, open_ssh_project};
@@ -619,6 +620,7 @@ pub fn main() {
         markdown_preview::init(cx);
         svg_preview::init(cx);
         welcome::init(cx);
+        onboarding::init(cx);
         settings_ui::init(cx);
         extensions_ui::init(cx);
         zeta::init(cx);
@@ -1039,6 +1041,10 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
                 );
             }
         }
+    } else if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) {
+        let state = app_state.clone();
+        cx.update(|cx| show_onboarding_view(state, cx))?.await?;
+        // cx.update(|cx| show_welcome_view(app_state, cx))?.await?;
     } else if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) {
         cx.update(|cx| show_welcome_view(app_state, cx))?.await?;
     } else {

crates/zed/src/zed.rs 🔗

@@ -3957,6 +3957,7 @@ mod tests {
             language::init(cx);
             workspace::init(app_state.clone(), cx);
             welcome::init(cx);
+            onboarding::init(cx);
             Project::init_settings(cx);
             app_state
         })