diff --git a/Cargo.lock b/Cargo.lock index c0d8dabf09..1dcfb87756 100644 --- a/Cargo.lock +++ b/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", diff --git a/Cargo.toml b/Cargo.toml index c99ba3953d..ea8690f2b3 100644 --- a/Cargo.toml +++ b/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" } diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml new file mode 100644 index 0000000000..693e39d4ca --- /dev/null +++ b/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 diff --git a/crates/onboarding/LICENSE-GPL b/crates/onboarding/LICENSE-GPL new file mode 120000 index 0000000000..dd648cce4f --- /dev/null +++ b/crates/onboarding/LICENSE-GPL @@ -0,0 +1 @@ +../../../LICENSE-GPL \ No newline at end of file diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs new file mode 100644 index 0000000000..1ce236f941 --- /dev/null +++ b/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::()); + + 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::(|_, window, cx| { + let Some(window) = window else { + return; + }; + + let onboarding_actions = [std::any::TypeId::of::()]; + + CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.hide_action_types(&onboarding_actions); + }); + + cx.observe_flag::(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, cx: &mut App) -> Task> { + 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 = ::global(cx); + + update_settings_file::(fs, cx, move |settings, _| { + settings.set_mode(theme_mode); + }); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SelectedPage { + Basics, + Editing, + AiSetup, +} + +struct Onboarding { + workspace: WeakEntity, + focus_handle: FocusHandle, + selected_page: SelectedPage, + _settings_subscription: Subscription, +} + +impl Onboarding { + fn new(workspace: WeakEntity, cx: &mut App) -> Entity { + cx.new(|cx| Self { + workspace, + focus_handle: cx.focus_handle(), + selected_page: SelectedPage::Basics, + _settings_subscription: cx.observe_global::(move |_, cx| cx.notify()), + }) + } + + fn render_page_nav( + &mut self, + page: SelectedPage, + _: &mut Window, + cx: &mut Context, + ) -> 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) -> 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) -> 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) -> impl IntoElement { + // div().child("editing page") + "Right" + } + + fn render_ai_setup_page(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().child("ai setup page") + } +} + +impl Render for Onboarding { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> 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 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, + _: &mut Window, + cx: &mut Context, + ) -> Option> { + Some(Onboarding::new(self.workspace.clone(), cx)) + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { + f(*event) + } +} diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index bbceb3f101..e565aba26b 100644 --- a/crates/zed/Cargo.toml +++ b/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 diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 89b9fad6bf..9d85923ca2 100644 --- a/crates/zed/src/main.rs +++ b/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, 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 { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index cc3906af4d..24c7ab5ba2 100644 --- a/crates/zed/src/zed.rs +++ b/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 })