diff --git a/Cargo.lock b/Cargo.lock
index baed77a49f..979d349427 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10814,6 +10814,35 @@ dependencies = [
"workspace-hack",
]
+[[package]]
+name = "onboarding_ui"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "client",
+ "command_palette_hooks",
+ "component",
+ "db",
+ "editor",
+ "feature_flags",
+ "gpui",
+ "language",
+ "log",
+ "menu",
+ "project",
+ "serde_json",
+ "settings",
+ "settings_ui",
+ "smallvec",
+ "theme",
+ "ui",
+ "util",
+ "vim_mode_setting",
+ "welcome",
+ "workspace",
+ "zed_actions",
+]
+
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -19972,6 +20001,7 @@ dependencies = [
"collab_ui",
"collections",
"command_palette",
+ "command_palette_hooks",
"component",
"copilot",
"dap",
@@ -20022,6 +20052,7 @@ dependencies = [
"nix 0.29.0",
"node_runtime",
"notifications",
+ "onboarding_ui",
"outline",
"outline_panel",
"parking_lot",
diff --git a/Cargo.toml b/Cargo.toml
index 82cbb53397..c9d848675f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -102,6 +102,7 @@ members = [
"crates/node_runtime",
"crates/notifications",
"crates/ollama",
+ "crates/onboarding_ui",
"crates/open_ai",
"crates/open_router",
"crates/outline",
@@ -314,6 +315,7 @@ multi_buffer = { path = "crates/multi_buffer" }
node_runtime = { path = "crates/node_runtime" }
notifications = { path = "crates/notifications" }
ollama = { path = "crates/ollama" }
+onboarding_ui = { path = "crates/onboarding_ui" }
open_ai = { path = "crates/open_ai" }
open_router = { path = "crates/open_router", features = ["schemars"] }
outline = { path = "crates/outline" }
diff --git a/assets/fonts/plex-sans/ZedPlexSans-Medium.ttf b/assets/fonts/plex-sans/ZedPlexSans-Medium.ttf
new file mode 100644
index 0000000000..fb75072d87
Binary files /dev/null and b/assets/fonts/plex-sans/ZedPlexSans-Medium.ttf differ
diff --git a/assets/fonts/plex-sans/ZedPlexSans-MediumItalic.ttf b/assets/fonts/plex-sans/ZedPlexSans-MediumItalic.ttf
new file mode 100644
index 0000000000..1b059bebd5
Binary files /dev/null and b/assets/fonts/plex-sans/ZedPlexSans-MediumItalic.ttf differ
diff --git a/assets/images/atom_logo.svg b/assets/images/atom_logo.svg
new file mode 100644
index 0000000000..d23ad5fe9b
--- /dev/null
+++ b/assets/images/atom_logo.svg
@@ -0,0 +1,19 @@
+
diff --git a/assets/settings/default.json b/assets/settings/default.json
index 985e322cac..0e46115ae3 100644
--- a/assets/settings/default.json
+++ b/assets/settings/default.json
@@ -25,7 +25,11 @@
// Features that can be globally enabled or disabled
"features": {
// Which edit prediction provider to use.
- "edit_prediction_provider": "zed"
+ "edit_prediction_provider": "zed",
+ // A globally enable or disable AI features.
+ //
+ // This setting supersedes all other settings related to AI features.
+ "ai_assistance": true
},
// The name of a font to use for rendering text in the editor
"buffer_font_family": "Zed Plex Mono",
diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs
index c9c173a68b..966e18de54 100644
--- a/crates/agent_ui/src/inline_assistant.rs
+++ b/crates/agent_ui/src/inline_assistant.rs
@@ -33,6 +33,7 @@ use gpui::{
App, Context, Entity, Focusable, Global, HighlightStyle, Subscription, Task, UpdateGlobal,
WeakEntity, Window, point,
};
+use language::language_settings;
use language::{Buffer, Point, Selection, TransactionId};
use language_model::{
ConfigurationError, ConfiguredModel, LanguageModelRegistry, report_assistant_event,
@@ -1768,7 +1769,7 @@ impl CodeActionProvider for AssistantCodeActionProvider {
_: &mut Window,
cx: &mut App,
) -> Task>> {
- if !AgentSettings::get_global(cx).enabled {
+ if !AgentSettings::get_global(cx).enabled || !language_settings::ai_enabled(cx) {
return Task::ready(Ok(Vec::new()));
}
diff --git a/crates/gpui_macros/src/styles.rs b/crates/gpui_macros/src/styles.rs
index 36d46cfb51..f62a275cb6 100644
--- a/crates/gpui_macros/src/styles.rs
+++ b/crates/gpui_macros/src/styles.rs
@@ -405,6 +405,23 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream {
self
}
+ /// Sets the box shadow of the element.
+ ///
+ /// A hairline shadow is a very thin shadow that is often used
+ /// to create a subtle depth effect under an element.
+ #visibility fn shadow_hairline(mut self) -> Self {
+ use gpui::{BoxShadow, hsla, point, px};
+ use std::vec;
+
+ self.style().box_shadow = Some(vec![BoxShadow {
+ color: hsla(0.0, 0.0, 0.0, 0.16),
+ offset: point(px(0.), px(1.)),
+ blur_radius: px(0.),
+ spread_radius: px(0.),
+ }]);
+ self
+ }
+
/// Sets the box shadow of the element.
/// [Docs](https://tailwindcss.com/docs/box-shadow)
#visibility fn shadow_2xs(mut self) -> Self {
diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs
index 9b0abb1537..c4e908df79 100644
--- a/crates/language/src/language_settings.rs
+++ b/crates/language/src/language_settings.rs
@@ -16,8 +16,10 @@ use serde::{
de::{self, IntoDeserializer, MapAccess, SeqAccess, Visitor},
};
+use fs::Fs;
use settings::{
ParameterizedJsonSchema, Settings, SettingsLocation, SettingsSources, SettingsStore,
+ update_settings_file,
};
use shellexpand;
use std::{borrow::Cow, num::NonZeroU32, path::Path, slice, sync::Arc};
@@ -54,11 +56,18 @@ pub fn all_language_settings<'a>(
AllLanguageSettings::get(location, cx)
}
+/// Returns whether AI assistance is globally enabled or disabled.
+pub fn ai_enabled(cx: &App) -> bool {
+ all_language_settings(None, cx).ai_assistance
+}
+
/// The settings for all languages.
#[derive(Debug, Clone)]
pub struct AllLanguageSettings {
/// The edit prediction settings.
pub edit_predictions: EditPredictionSettings,
+ /// Whether AI assistance is enabled.
+ pub ai_assistance: bool,
pub defaults: LanguageSettings,
languages: HashMap,
pub(crate) file_types: FxHashMap, GlobSet>,
@@ -646,6 +655,8 @@ pub struct CopilotSettingsContent {
pub struct FeaturesContent {
/// Determines which edit prediction provider to use.
pub edit_prediction_provider: Option,
+ /// Whether AI assistance is enabled.
+ pub ai_assistance: Option,
}
/// Controls the soft-wrapping behavior in the editor.
@@ -1122,6 +1133,26 @@ impl AllLanguageSettings {
pub fn edit_predictions_mode(&self) -> EditPredictionsMode {
self.edit_predictions.mode
}
+
+ /// Returns whether AI assistance is enabled.
+ pub fn is_ai_assistance_enabled(&self) -> bool {
+ self.ai_assistance
+ }
+
+ /// Sets AI assistance to the specified state and updates the settings file.
+ pub fn set_ai_assistance(enabled: bool, fs: Arc, cx: &mut App) {
+ let current_state = Self::get_global(cx).ai_assistance;
+
+ if current_state == enabled {
+ return;
+ }
+
+ update_settings_file::(fs, cx, move |file, _| {
+ file.features
+ .get_or_insert(Default::default())
+ .ai_assistance = Some(enabled);
+ });
+ }
}
fn merge_with_editorconfig(settings: &mut LanguageSettings, cfg: &EditorconfigProperties) {
@@ -1247,6 +1278,12 @@ impl settings::Settings for AllLanguageSettings {
.map(|settings| settings.enabled_in_text_threads)
.unwrap_or(true);
+ let mut ai_assistance = default_value
+ .features
+ .as_ref()
+ .and_then(|f| f.ai_assistance)
+ .unwrap_or(true);
+
let mut file_types: FxHashMap, GlobSet> = FxHashMap::default();
for (language, patterns) in &default_value.file_types {
@@ -1268,6 +1305,14 @@ impl settings::Settings for AllLanguageSettings {
edit_prediction_provider = Some(provider);
}
+ if let Some(user_ai_assistance) = user_settings
+ .features
+ .as_ref()
+ .and_then(|f| f.ai_assistance)
+ {
+ ai_assistance = user_ai_assistance;
+ }
+
if let Some(edit_predictions) = user_settings.edit_predictions.as_ref() {
edit_predictions_mode = edit_predictions.mode;
enabled_in_text_threads = edit_predictions.enabled_in_text_threads;
@@ -1359,6 +1404,7 @@ impl settings::Settings for AllLanguageSettings {
copilot: copilot_settings,
enabled_in_text_threads,
},
+ ai_assistance,
defaults,
languages,
file_types,
diff --git a/crates/onboarding_ui/Cargo.toml b/crates/onboarding_ui/Cargo.toml
new file mode 100644
index 0000000000..456d81f64d
--- /dev/null
+++ b/crates/onboarding_ui/Cargo.toml
@@ -0,0 +1,42 @@
+[package]
+name = "onboarding_ui"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/onboarding_ui.rs"
+
+[features]
+test-support = []
+
+[dependencies]
+anyhow.workspace = true
+client.workspace = true
+command_palette_hooks.workspace = true
+component.workspace = true
+db.workspace = true
+feature_flags.workspace = true
+gpui.workspace = true
+language.workspace = true
+log.workspace = true
+menu.workspace = true
+project.workspace = true
+serde_json.workspace = true
+settings.workspace = true
+settings_ui.workspace = true
+smallvec.workspace = true
+theme.workspace = true
+ui.workspace = true
+util.workspace = true
+vim_mode_setting.workspace = true
+welcome.workspace = true
+workspace.workspace = true
+zed_actions.workspace = true
+
+[dev-dependencies]
+editor = { workspace = true, features = ["test-support"] }
diff --git a/crates/onboarding_ui/src/components/callout_row.rs b/crates/onboarding_ui/src/components/callout_row.rs
new file mode 100644
index 0000000000..2d7f600c0e
--- /dev/null
+++ b/crates/onboarding_ui/src/components/callout_row.rs
@@ -0,0 +1,86 @@
+use component::{example_group_with_title, single_example};
+use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
+use smallvec::SmallVec;
+use ui::{Label, prelude::*};
+
+#[derive(IntoElement, RegisterComponent)]
+pub struct CalloutRow {
+ title: SharedString,
+ lines: SmallVec<[SharedString; 4]>,
+}
+
+impl CalloutRow {
+ pub fn new(title: impl Into) -> Self {
+ Self {
+ title: title.into(),
+ lines: SmallVec::new(),
+ }
+ }
+
+ pub fn line(mut self, line: impl Into) -> Self {
+ self.lines.push(line.into());
+ self
+ }
+}
+
+impl RenderOnce for CalloutRow {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ div().px_2().child(
+ v_flex()
+ .p_3()
+ .gap_1()
+ .bg(cx.theme().colors().surface_background)
+ .border_1()
+ .border_color(cx.theme().colors().border_variant)
+ .rounded_md()
+ .child(Label::new(self.title).weight(gpui::FontWeight::MEDIUM))
+ .children(
+ self.lines
+ .into_iter()
+ .map(|line| Label::new(line).size(LabelSize::Small).color(Color::Muted)),
+ ),
+ )
+ }
+}
+
+impl Component for CalloutRow {
+ fn scope() -> ComponentScope {
+ ComponentScope::Layout
+ }
+
+ fn sort_name() -> &'static str {
+ "RowCallout"
+ }
+
+ fn preview(_window: &mut Window, _cx: &mut App) -> Option {
+ let examples = example_group_with_title(
+ "CalloutRow Examples",
+ vec![
+ single_example(
+ "Privacy Notice",
+ CalloutRow::new("We don't use your code to train AI models")
+ .line("You choose which providers you enable, and they have their own privacy policies.")
+ .line("Read more about our privacy practices in our Privacy Policy.")
+ .into_any_element(),
+ ),
+ single_example(
+ "Single Line",
+ CalloutRow::new("Important Notice")
+ .line("This is a single line of information.")
+ .into_any_element(),
+ ),
+ single_example(
+ "Multi Line",
+ CalloutRow::new("Getting Started")
+ .line("Welcome to Zed! Here are some things to know:")
+ .line("• Use Cmd+P to quickly open files")
+ .line("• Use Cmd+Shift+P to access the command palette")
+ .line("• Check out the documentation for more tips")
+ .into_any_element(),
+ ),
+ ],
+ );
+
+ Some(v_flex().p_4().gap_4().child(examples).into_any_element())
+ }
+}
diff --git a/crates/onboarding_ui/src/components/checkbox_row.rs b/crates/onboarding_ui/src/components/checkbox_row.rs
new file mode 100644
index 0000000000..a2e0957335
--- /dev/null
+++ b/crates/onboarding_ui/src/components/checkbox_row.rs
@@ -0,0 +1,134 @@
+use component::{example_group_with_title, single_example};
+use gpui::StatefulInteractiveElement as _;
+use gpui::{AnyElement, App, ClickEvent, IntoElement, RenderOnce, Window};
+use ui::prelude::*;
+
+#[derive(IntoElement, RegisterComponent)]
+pub struct CheckboxRow {
+ label: SharedString,
+ description: Option,
+ checked: bool,
+ on_click: Option>,
+}
+
+impl CheckboxRow {
+ pub fn new(label: impl Into) -> Self {
+ Self {
+ label: label.into(),
+ description: None,
+ checked: false,
+ on_click: None,
+ }
+ }
+
+ pub fn description(mut self, description: impl Into) -> Self {
+ self.description = Some(description.into());
+ self
+ }
+
+ pub fn checked(mut self, checked: bool) -> Self {
+ self.checked = checked;
+ self
+ }
+
+ pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
+ self.on_click = Some(Box::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for CheckboxRow {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let checked = self.checked;
+ let on_click = self.on_click;
+
+ let checkbox = gpui::div()
+ .w_4()
+ .h_4()
+ .rounded_sm()
+ .border_1()
+ .border_color(cx.theme().colors().border)
+ .when(checked, |this| {
+ this.bg(cx.theme().colors().element_selected)
+ .border_color(cx.theme().colors().border_selected)
+ })
+ .hover(|this| this.bg(cx.theme().colors().element_hover))
+ .child(gpui::div().when(checked, |this| {
+ this.size_full()
+ .flex()
+ .items_center()
+ .justify_center()
+ .child(Icon::new(IconName::Check))
+ }));
+
+ let main_row = if let Some(on_click) = on_click {
+ gpui::div()
+ .id("checkbox-row")
+ .h_flex()
+ .gap_2()
+ .items_center()
+ .child(checkbox)
+ .child(Label::new(self.label))
+ .cursor_pointer()
+ .on_click(move |_event, window, cx| on_click(window, cx))
+ } else {
+ gpui::div()
+ .id("checkbox-row")
+ .h_flex()
+ .gap_2()
+ .items_center()
+ .child(checkbox)
+ .child(Label::new(self.label))
+ };
+
+ v_flex()
+ .px_5()
+ .py_1()
+ .gap_1()
+ .child(main_row)
+ .when_some(self.description, |this, desc| {
+ this.child(
+ gpui::div()
+ .ml_6()
+ .child(Label::new(desc).size(LabelSize::Small).color(Color::Muted)),
+ )
+ })
+ }
+}
+
+impl Component for CheckboxRow {
+ fn scope() -> ComponentScope {
+ ComponentScope::Layout
+ }
+
+ fn sort_name() -> &'static str {
+ "RowCheckbox"
+ }
+
+ fn preview(_window: &mut Window, _cx: &mut App) -> Option {
+ let examples = example_group_with_title(
+ "CheckboxRow Examples",
+ vec![
+ single_example(
+ "Unchecked",
+ CheckboxRow::new("Enable Vim Mode").into_any_element(),
+ ),
+ single_example(
+ "Checked",
+ CheckboxRow::new("Send Crash Reports")
+ .checked(true)
+ .into_any_element(),
+ ),
+ single_example(
+ "With Description",
+ CheckboxRow::new("Send Telemetry")
+ .description("Help improve Zed by sending anonymous usage data")
+ .checked(true)
+ .into_any_element(),
+ ),
+ ],
+ );
+
+ Some(v_flex().p_4().gap_4().child(examples).into_any_element())
+ }
+}
diff --git a/crates/onboarding_ui/src/components/header_row.rs b/crates/onboarding_ui/src/components/header_row.rs
new file mode 100644
index 0000000000..e13d48f84d
--- /dev/null
+++ b/crates/onboarding_ui/src/components/header_row.rs
@@ -0,0 +1,78 @@
+use component::{example_group_with_title, single_example};
+use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
+use ui::{Label, prelude::*};
+
+#[derive(IntoElement, RegisterComponent)]
+pub struct HeaderRow {
+ label: SharedString,
+ end_slot: Option,
+}
+
+impl HeaderRow {
+ pub fn new(label: impl Into) -> Self {
+ Self {
+ label: label.into(),
+ end_slot: None,
+ }
+ }
+
+ pub fn end_slot(mut self, slot: impl IntoElement) -> Self {
+ self.end_slot = Some(slot.into_any_element());
+ self
+ }
+}
+
+impl RenderOnce for HeaderRow {
+ fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+ h_flex()
+ .h(px(32.))
+ .w_full()
+ .px_5()
+ .justify_between()
+ .child(Label::new(self.label))
+ .when_some(self.end_slot, |this, slot| this.child(slot))
+ }
+}
+
+impl Component for HeaderRow {
+ fn scope() -> ComponentScope {
+ ComponentScope::Layout
+ }
+
+ fn sort_name() -> &'static str {
+ "RowHeader"
+ }
+
+ fn preview(_window: &mut Window, _cx: &mut App) -> Option {
+ let examples = example_group_with_title(
+ "HeaderRow Examples",
+ vec![
+ single_example(
+ "Simple Header",
+ HeaderRow::new("Pick a Theme").into_any_element(),
+ ),
+ single_example(
+ "Header with Button",
+ HeaderRow::new("Pick a Theme")
+ .end_slot(
+ Button::new("more_themes", "More Themes")
+ .style(ButtonStyle::Subtle)
+ .color(Color::Muted),
+ )
+ .into_any_element(),
+ ),
+ single_example(
+ "Header with Icon Button",
+ HeaderRow::new("Settings")
+ .end_slot(
+ IconButton::new("refresh", IconName::RotateCw)
+ .style(ButtonStyle::Subtle),
+ )
+ .into_any_element(),
+ ),
+ ],
+ );
+
+ Some(v_flex().p_4().gap_4().child(examples).into_any_element())
+ }
+}
diff --git a/crates/onboarding_ui/src/components/mod.rs b/crates/onboarding_ui/src/components/mod.rs
new file mode 100644
index 0000000000..77ead73641
--- /dev/null
+++ b/crates/onboarding_ui/src/components/mod.rs
@@ -0,0 +1,11 @@
+mod callout_row;
+mod checkbox_row;
+mod header_row;
+mod selectable_tile;
+mod selectable_tile_row;
+
+pub use callout_row::CalloutRow;
+pub use checkbox_row::CheckboxRow;
+pub use header_row::HeaderRow;
+pub use selectable_tile::SelectableTile;
+pub use selectable_tile_row::SelectableTileRow;
diff --git a/crates/onboarding_ui/src/components/selectable_tile.rs b/crates/onboarding_ui/src/components/selectable_tile.rs
new file mode 100644
index 0000000000..b33b186270
--- /dev/null
+++ b/crates/onboarding_ui/src/components/selectable_tile.rs
@@ -0,0 +1,165 @@
+use component::{example_group_with_title, single_example};
+use gpui::{ClickEvent, transparent_black};
+use smallvec::SmallVec;
+use ui::{Vector, VectorName, prelude::*, utils::CornerSolver};
+
+#[derive(IntoElement, RegisterComponent)]
+pub struct SelectableTile {
+ id: ElementId,
+ width: DefiniteLength,
+ height: DefiniteLength,
+ parent_focused: bool,
+ selected: bool,
+ children: SmallVec<[AnyElement; 2]>,
+ on_click: Option>,
+}
+
+impl SelectableTile {
+ pub fn new(
+ id: impl Into,
+ width: impl Into,
+ height: impl Into,
+ ) -> Self {
+ Self {
+ id: id.into(),
+ width: width.into(),
+ height: height.into(),
+ parent_focused: false,
+ selected: false,
+ children: SmallVec::new(),
+ on_click: None,
+ }
+ }
+
+ pub fn w(mut self, width: impl Into) -> Self {
+ self.width = width.into();
+ self
+ }
+
+ pub fn h(mut self, height: impl Into) -> Self {
+ self.height = height.into();
+ self
+ }
+
+ pub fn parent_focused(mut self, focused: bool) -> Self {
+ self.parent_focused = focused;
+ self
+ }
+
+ pub fn selected(mut self, selected: bool) -> Self {
+ self.selected = selected;
+ self
+ }
+
+ pub fn on_click(
+ mut self,
+ handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_click = Some(Box::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for SelectableTile {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let ring_corner_radius = px(8.);
+ let ring_width = px(1.);
+ let padding = px(2.);
+ let content_border_width = px(0.);
+ let content_border_radius = CornerSolver::child_radius(
+ ring_corner_radius,
+ ring_width,
+ padding,
+ content_border_width,
+ );
+
+ let mut element = h_flex()
+ .id(self.id)
+ .w(self.width)
+ .h(self.height)
+ .overflow_hidden()
+ .rounded(ring_corner_radius)
+ .border(ring_width)
+ .border_color(if self.selected && self.parent_focused {
+ cx.theme().status().info
+ } else if self.selected {
+ cx.theme().colors().border
+ } else {
+ transparent_black()
+ })
+ .p(padding)
+ .child(
+ h_flex()
+ .size_full()
+ .rounded(content_border_radius)
+ .items_center()
+ .justify_center()
+ .shadow_hairline()
+ .bg(cx.theme().colors().surface_background)
+ .children(self.children),
+ );
+
+ if let Some(on_click) = self.on_click {
+ element = element.on_click(move |event, window, cx| {
+ on_click(event, window, cx);
+ });
+ }
+
+ element
+ }
+}
+
+impl ParentElement for SelectableTile {
+ fn extend(&mut self, elements: impl IntoIterator- ) {
+ self.children.extend(elements)
+ }
+}
+
+impl Component for SelectableTile {
+ fn scope() -> ComponentScope {
+ ComponentScope::Layout
+ }
+
+ fn preview(_window: &mut Window, _cx: &mut App) -> Option {
+ let states = example_group(vec![
+ single_example(
+ "Default",
+ SelectableTile::new("default", px(40.), px(40.))
+ .parent_focused(false)
+ .selected(false)
+ .child(div().p_4().child(Vector::new(
+ VectorName::ZedLogo,
+ rems(18. / 16.),
+ rems(18. / 16.),
+ )))
+ .into_any_element(),
+ ),
+ single_example(
+ "Selected",
+ SelectableTile::new("selected", px(40.), px(40.))
+ .parent_focused(false)
+ .selected(true)
+ .child(div().p_4().child(Vector::new(
+ VectorName::ZedLogo,
+ rems(18. / 16.),
+ rems(18. / 16.),
+ )))
+ .into_any_element(),
+ ),
+ single_example(
+ "Selected & Parent Focused",
+ SelectableTile::new("selected_focused", px(40.), px(40.))
+ .parent_focused(true)
+ .selected(true)
+ .child(div().p_4().child(Vector::new(
+ VectorName::ZedLogo,
+ rems(18. / 16.),
+ rems(18. / 16.),
+ )))
+ .into_any_element(),
+ ),
+ ]);
+
+ Some(v_flex().p_4().gap_4().child(states).into_any_element())
+ }
+}
diff --git a/crates/onboarding_ui/src/components/selectable_tile_row.rs b/crates/onboarding_ui/src/components/selectable_tile_row.rs
new file mode 100644
index 0000000000..f903e4a663
--- /dev/null
+++ b/crates/onboarding_ui/src/components/selectable_tile_row.rs
@@ -0,0 +1,124 @@
+use super::selectable_tile::SelectableTile;
+use component::{example_group_with_title, single_example};
+use gpui::{
+ AnyElement, App, IntoElement, RenderOnce, StatefulInteractiveElement, Window, prelude::*,
+};
+use smallvec::SmallVec;
+use ui::{Label, prelude::*};
+
+#[derive(IntoElement, RegisterComponent)]
+pub struct SelectableTileRow {
+ gap: Pixels,
+ tiles: SmallVec<[SelectableTile; 8]>,
+}
+
+impl SelectableTileRow {
+ pub fn new() -> Self {
+ Self {
+ gap: px(12.),
+ tiles: SmallVec::new(),
+ }
+ }
+
+ pub fn gap(mut self, gap: impl Into) -> Self {
+ self.gap = gap.into();
+ self
+ }
+
+ pub fn tile(mut self, tile: SelectableTile) -> Self {
+ self.tiles.push(tile);
+ self
+ }
+}
+
+impl RenderOnce for SelectableTileRow {
+ fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+ h_flex().w_full().px_5().gap(self.gap).children(self.tiles)
+ }
+}
+
+impl Component for SelectableTileRow {
+ fn scope() -> ComponentScope {
+ ComponentScope::Layout
+ }
+
+ fn sort_name() -> &'static str {
+ "RowSelectableTile"
+ }
+
+ fn preview(_window: &mut Window, _cx: &mut App) -> Option {
+ let examples = example_group_with_title(
+ "SelectableTileRow Examples",
+ vec![
+ single_example(
+ "Theme Tiles",
+ SelectableTileRow::new()
+ .gap(px(12.))
+ .tile(
+ SelectableTile::new("tile1", px(100.), px(80.))
+ .selected(true)
+ .child(
+ div()
+ .size_full()
+ .bg(gpui::red())
+ .flex()
+ .items_center()
+ .justify_center()
+ .child(Label::new("Dark")),
+ ),
+ )
+ .tile(
+ SelectableTile::new("tile2", px(100.), px(80.)).child(
+ div()
+ .size_full()
+ .bg(gpui::green())
+ .flex()
+ .items_center()
+ .justify_center()
+ .child(Label::new("Light")),
+ ),
+ )
+ .tile(
+ SelectableTile::new("tile3", px(100.), px(80.))
+ .parent_focused(true)
+ .child(
+ div()
+ .size_full()
+ .bg(gpui::blue())
+ .flex()
+ .items_center()
+ .justify_center()
+ .child(Label::new("Auto")),
+ ),
+ )
+ .into_any_element(),
+ ),
+ single_example(
+ "Icon Tiles",
+ SelectableTileRow::new()
+ .gap(px(8.))
+ .tile(
+ SelectableTile::new("icon1", px(48.), px(48.))
+ .selected(true)
+ .child(Icon::new(IconName::Code).size(IconSize::Medium)),
+ )
+ .tile(
+ SelectableTile::new("icon2", px(48.), px(48.))
+ .child(Icon::new(IconName::Terminal).size(IconSize::Medium)),
+ )
+ .tile(
+ SelectableTile::new("icon3", px(48.), px(48.))
+ .child(Icon::new(IconName::FileCode).size(IconSize::Medium)),
+ )
+ .tile(
+ SelectableTile::new("icon4", px(48.), px(48.))
+ .child(Icon::new(IconName::Settings).size(IconSize::Medium)),
+ )
+ .into_any_element(),
+ ),
+ ],
+ );
+
+ Some(v_flex().p_4().gap_4().child(examples).into_any_element())
+ }
+}
diff --git a/crates/onboarding_ui/src/juicy_button.rs b/crates/onboarding_ui/src/juicy_button.rs
new file mode 100644
index 0000000000..39fb132b96
--- /dev/null
+++ b/crates/onboarding_ui/src/juicy_button.rs
@@ -0,0 +1,94 @@
+use gpui::{FontWeight, *};
+use ui::prelude::*;
+
+#[derive(IntoElement)]
+pub struct JuicyButton {
+ base: Div,
+ label: SharedString,
+ keybinding: Option,
+ on_click: Option>,
+}
+
+impl JuicyButton {
+ pub fn new(label: impl Into) -> Self {
+ Self {
+ base: div(),
+ label: label.into(),
+ keybinding: None,
+ on_click: None,
+ }
+ }
+
+ pub fn keybinding(mut self, keybinding: Option) -> Self {
+ if let Some(kb) = keybinding {
+ self.keybinding = Some(kb.into_any_element());
+ }
+ self
+ }
+}
+
+impl Clickable for JuicyButton {
+ fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
+ self.on_click = Some(Box::new(handler));
+ self
+ }
+
+ fn cursor_style(mut self, style: gpui::CursorStyle) -> Self {
+ self.base = self.base.cursor(style);
+ self
+ }
+}
+
+impl RenderOnce for JuicyButton {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let mut children = vec![
+ h_flex()
+ .flex_1()
+ .items_center()
+ .justify_center()
+ .child(
+ div()
+ .text_size(px(14.))
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(cx.theme().colors().text)
+ .child(self.label),
+ )
+ .into_any_element(),
+ ];
+
+ if let Some(keybinding) = self.keybinding {
+ children.push(
+ div()
+ .flex_none()
+ .bg(gpui::white().opacity(0.2))
+ .rounded_md()
+ .px_1()
+ .child(keybinding)
+ .into_any_element(),
+ );
+ }
+
+ self.base
+ .id("juicy-button")
+ .w_full()
+ .h(rems(2.))
+ .px(rems(1.5))
+ .rounded(px(6.))
+ .bg(cx.theme().colors().icon.opacity(0.12))
+ .shadow_hairline()
+ .hover(|style| {
+ style.bg(cx.theme().colors().icon.opacity(0.12)) // Darker blue on hover
+ })
+ .active(|style| {
+ style
+ .bg(rgb(0x1e40af)) // Even darker on active
+ .shadow_md()
+ })
+ .cursor_pointer()
+ .flex()
+ .items_center()
+ .justify_between()
+ .when_some(self.on_click, |div, on_click| div.on_click(on_click))
+ .children(children)
+ }
+}
diff --git a/crates/onboarding_ui/src/onboarding_ui.rs b/crates/onboarding_ui/src/onboarding_ui.rs
new file mode 100644
index 0000000000..5901b9c249
--- /dev/null
+++ b/crates/onboarding_ui/src/onboarding_ui.rs
@@ -0,0 +1,1290 @@
+#![allow(unused, dead_code)]
+mod components;
+mod juicy_button;
+mod persistence;
+mod theme_preview;
+
+use self::components::{CalloutRow, CheckboxRow, HeaderRow, SelectableTile, SelectableTileRow};
+use self::juicy_button::JuicyButton;
+use client::{Client, TelemetrySettings};
+use command_palette_hooks::CommandPaletteFilter;
+use feature_flags::FeatureFlagAppExt as _;
+use gpui::{
+ Action, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, KeyBinding, Subscription,
+ Task, UpdateGlobal, WeakEntity, actions, prelude::*, svg, transparent_black,
+};
+use menu;
+use persistence::ONBOARDING_DB;
+
+use language::language_settings::{AllLanguageSettings, ai_enabled, all_language_settings};
+use project::Project;
+use serde_json;
+use settings::{Settings, SettingsStore, update_settings_file};
+use settings_ui::SettingsUiFeatureFlag;
+use std::collections::HashSet;
+use std::sync::Arc;
+use theme::{Theme, ThemeRegistry, ThemeSettings};
+use ui::{
+ CheckboxWithLabel, ContentGroup, FocusOutline, KeybindingHint, ListItem, ToggleState, Vector,
+ VectorName, prelude::*,
+};
+use util::ResultExt;
+use vim_mode_setting::VimModeSetting;
+use welcome::{BaseKeymap, WelcomePage};
+use workspace::{
+ Workspace, WorkspaceId,
+ item::{Item, ItemEvent, SerializableItem},
+ notifications::NotifyResultExt,
+};
+use zed_actions;
+
+actions!(
+ onboarding,
+ [
+ ShowOnboarding,
+ JumpToBasics,
+ JumpToEditing,
+ JumpToAiSetup,
+ JumpToWelcome,
+ NextPage,
+ PreviousPage,
+ ToggleFocus,
+ ResetOnboarding,
+ ]
+);
+
+pub fn init(cx: &mut App) {
+ cx.observe_new(|workspace: &mut Workspace, _, _cx| {
+ workspace.register_action(|workspace, _: &ShowOnboarding, window, cx| {
+ let client = workspace.client().clone();
+ let onboarding = cx.new(|cx| OnboardingUI::new(workspace, client, cx));
+ workspace.add_item_to_active_pane(Box::new(onboarding), None, true, window, cx);
+ });
+ })
+ .detach();
+
+ workspace::register_serializable_item::(cx);
+
+ feature_gate_onboarding_ui_actions(cx);
+}
+
+fn feature_gate_onboarding_ui_actions(cx: &mut App) {
+ const ONBOARDING_ACTION_NAMESPACE: &str = "onboarding_ui";
+
+ CommandPaletteFilter::update_global(cx, |filter, _cx| {
+ filter.hide_namespace(ONBOARDING_ACTION_NAMESPACE);
+ });
+
+ cx.observe_flag::({
+ move |is_enabled, cx| {
+ CommandPaletteFilter::update_global(cx, |filter, _cx| {
+ if is_enabled {
+ filter.show_namespace(ONBOARDING_ACTION_NAMESPACE);
+ } else {
+ filter.hide_namespace(ONBOARDING_ACTION_NAMESPACE);
+ }
+ });
+ }
+ })
+ .detach();
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum OnboardingPage {
+ Basics,
+ Editing,
+ AiSetup,
+ Welcome,
+}
+
+impl OnboardingPage {
+ fn next(&self) -> Option {
+ match self {
+ Self::Basics => Some(Self::Editing),
+ Self::Editing => Some(Self::AiSetup),
+ Self::AiSetup => Some(Self::Welcome),
+ Self::Welcome => None,
+ }
+ }
+
+ fn previous(&self) -> Option {
+ match self {
+ Self::Basics => None,
+ Self::Editing => Some(Self::Basics),
+ Self::AiSetup => Some(Self::Editing),
+ Self::Welcome => Some(Self::AiSetup),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum NavigationFocusItem {
+ SignIn,
+ Basics,
+ Editing,
+ AiSetup,
+ Welcome,
+ Next,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PageFocusItem(pub usize);
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FocusArea {
+ Navigation,
+ PageContent,
+}
+
+pub struct OnboardingUI {
+ focus_handle: FocusHandle,
+ current_page: OnboardingPage,
+ nav_focus: NavigationFocusItem,
+ page_focus: [PageFocusItem; 4],
+ completed_pages: HashSet,
+ focus_area: FocusArea,
+
+ // Workspace reference for Item trait
+ workspace: WeakEntity,
+ workspace_id: Option,
+ client: Arc,
+ welcome_page: Option>,
+ _settings_subscription: Option,
+}
+
+impl OnboardingUI {}
+
+impl EventEmitter for OnboardingUI {}
+
+impl Focusable for OnboardingUI {
+ fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+#[derive(Clone)]
+pub enum OnboardingEvent {
+ PageCompleted(OnboardingPage),
+}
+
+impl Render for OnboardingUI {
+ fn render(
+ &mut self,
+ window: &mut gpui::Window,
+ cx: &mut Context,
+ ) -> impl gpui::IntoElement {
+ div()
+ .bg(cx.theme().colors().editor_background)
+ .size_full()
+ .key_context("OnboardingUI")
+ .on_action(cx.listener(Self::select_next))
+ .on_action(cx.listener(Self::select_previous))
+ .on_action(cx.listener(Self::confirm))
+ .on_action(cx.listener(Self::cancel))
+ .on_action(cx.listener(Self::toggle_focus))
+ .on_action(cx.listener(Self::handle_enable_ai_assistance))
+ .on_action(cx.listener(Self::handle_disable_ai_assistance))
+ .flex()
+ .items_center()
+ .justify_center()
+ .overflow_hidden()
+ .child(
+ h_flex()
+ .id("onboarding-ui")
+ .key_context("Onboarding")
+ .track_focus(&self.focus_handle)
+ .on_action(cx.listener(Self::handle_jump_to_basics))
+ .on_action(cx.listener(Self::handle_jump_to_editing))
+ .on_action(cx.listener(Self::handle_jump_to_ai_setup))
+ .on_action(cx.listener(Self::handle_jump_to_welcome))
+ .on_action(cx.listener(Self::handle_next_page))
+ .on_action(cx.listener(Self::handle_previous_page))
+ .w(px(984.))
+ .overflow_hidden()
+ .gap(px(24.))
+ .child(
+ h_flex()
+ .h(px(500.))
+ .w_full()
+ .overflow_hidden()
+ .gap(px(48.))
+ .child(self.render_navigation(window, cx))
+ .child(
+ v_flex()
+ .h_full()
+ .flex_1()
+ .overflow_hidden()
+ .child(self.render_active_page(window, cx)),
+ ),
+ ),
+ )
+ }
+}
+
+impl OnboardingUI {
+ pub fn new(workspace: &Workspace, client: Arc, cx: &mut Context) -> Self {
+ let settings_subscription = cx.observe_global::(|_, cx| {
+ cx.notify();
+ });
+
+ Self {
+ focus_handle: cx.focus_handle(),
+ current_page: OnboardingPage::Basics,
+ nav_focus: NavigationFocusItem::Basics,
+ page_focus: [PageFocusItem(0); 4],
+ completed_pages: HashSet::new(),
+ focus_area: FocusArea::Navigation,
+ workspace: workspace.weak_handle(),
+ workspace_id: workspace.database_id(),
+ client,
+ welcome_page: None,
+ _settings_subscription: Some(settings_subscription),
+ }
+ }
+
+ fn completed_pages_to_string(&self) -> String {
+ let mut result = String::new();
+ for i in 0..4 {
+ let page = match i {
+ 0 => OnboardingPage::Basics,
+ 1 => OnboardingPage::Editing,
+ 2 => OnboardingPage::AiSetup,
+ 3 => OnboardingPage::Welcome,
+ _ => unreachable!(),
+ };
+ result.push(if self.completed_pages.contains(&page) {
+ '1'
+ } else {
+ '0'
+ });
+ }
+ result
+ }
+
+ fn completed_pages_from_string(s: &str) -> HashSet {
+ let mut result = HashSet::new();
+ for (i, ch) in s.chars().take(4).enumerate() {
+ if ch == '1' {
+ let page = match i {
+ 0 => OnboardingPage::Basics,
+ 1 => OnboardingPage::Editing,
+ 2 => OnboardingPage::AiSetup,
+ 3 => OnboardingPage::Welcome,
+ _ => continue,
+ };
+ result.insert(page);
+ }
+ }
+ result
+ }
+
+ fn jump_to_page(
+ &mut self,
+ page: OnboardingPage,
+ _window: &mut gpui::Window,
+ cx: &mut Context,
+ ) {
+ self.current_page = page;
+ cx.emit(ItemEvent::UpdateTab);
+ cx.notify();
+ }
+
+ fn next_page(&mut self, _window: &mut gpui::Window, cx: &mut Context) {
+ if let Some(next) = self.current_page.next() {
+ self.current_page = next;
+ cx.notify();
+ }
+ }
+
+ fn previous_page(&mut self, _window: &mut gpui::Window, cx: &mut Context) {
+ if let Some(prev) = self.current_page.previous() {
+ self.current_page = prev;
+ cx.notify();
+ }
+ }
+
+ fn reset(&mut self, _window: &mut gpui::Window, cx: &mut Context) {
+ self.current_page = OnboardingPage::Basics;
+ self.focus_area = FocusArea::Navigation;
+ self.completed_pages = HashSet::new();
+ cx.notify();
+ }
+
+ fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) {
+ match self.focus_area {
+ FocusArea::Navigation => {
+ self.nav_focus = match self.nav_focus {
+ NavigationFocusItem::SignIn => NavigationFocusItem::Basics,
+ NavigationFocusItem::Basics => NavigationFocusItem::Editing,
+ NavigationFocusItem::Editing => NavigationFocusItem::AiSetup,
+ NavigationFocusItem::AiSetup => NavigationFocusItem::Welcome,
+ NavigationFocusItem::Welcome => NavigationFocusItem::Next,
+ NavigationFocusItem::Next => NavigationFocusItem::SignIn,
+ };
+ }
+ FocusArea::PageContent => {
+ let page_index = match self.current_page {
+ OnboardingPage::Basics => 0,
+ OnboardingPage::Editing => 1,
+ OnboardingPage::AiSetup => 2,
+ OnboardingPage::Welcome => 3,
+ };
+ // Bounds checking for page items
+ let max_items = match self.current_page {
+ OnboardingPage::Basics => 14, // 4 themes + 7 keymaps + 3 checkboxes
+ OnboardingPage::Editing => 3, // 3 buttons
+ OnboardingPage::AiSetup => 2, // Will have 2 items
+ OnboardingPage::Welcome => 1, // Will have 1 item
+ };
+
+ if self.page_focus[page_index].0 < max_items - 1 {
+ self.page_focus[page_index].0 += 1;
+ } else {
+ // Wrap to start
+ self.page_focus[page_index].0 = 0;
+ }
+ }
+ }
+ cx.notify();
+ }
+
+ fn select_previous(
+ &mut self,
+ _: &menu::SelectPrevious,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ match self.focus_area {
+ FocusArea::Navigation => {
+ self.nav_focus = match self.nav_focus {
+ NavigationFocusItem::SignIn => NavigationFocusItem::Next,
+ NavigationFocusItem::Basics => NavigationFocusItem::SignIn,
+ NavigationFocusItem::Editing => NavigationFocusItem::Basics,
+ NavigationFocusItem::AiSetup => NavigationFocusItem::Editing,
+ NavigationFocusItem::Welcome => NavigationFocusItem::AiSetup,
+ NavigationFocusItem::Next => NavigationFocusItem::Welcome,
+ };
+ }
+ FocusArea::PageContent => {
+ let page_index = match self.current_page {
+ OnboardingPage::Basics => 0,
+ OnboardingPage::Editing => 1,
+ OnboardingPage::AiSetup => 2,
+ OnboardingPage::Welcome => 3,
+ };
+ // Bounds checking for page items
+ let max_items = match self.current_page {
+ OnboardingPage::Basics => 14, // 4 themes + 7 keymaps + 3 checkboxes
+ OnboardingPage::Editing => 3, // 3 buttons
+ OnboardingPage::AiSetup => 2, // Will have 2 items
+ OnboardingPage::Welcome => 1, // Will have 1 item
+ };
+
+ if self.page_focus[page_index].0 > 0 {
+ self.page_focus[page_index].0 -= 1;
+ } else {
+ // Wrap to end
+ self.page_focus[page_index].0 = max_items - 1;
+ }
+ }
+ }
+ cx.notify();
+ }
+
+ fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) {
+ match self.focus_area {
+ FocusArea::Navigation => {
+ match self.nav_focus {
+ NavigationFocusItem::SignIn => {
+ // Handle sign in action
+ // TODO: Implement sign in action
+ }
+ NavigationFocusItem::Basics => {
+ self.jump_to_page(OnboardingPage::Basics, window, cx)
+ }
+ NavigationFocusItem::Editing => {
+ self.jump_to_page(OnboardingPage::Editing, window, cx)
+ }
+ NavigationFocusItem::AiSetup => {
+ self.jump_to_page(OnboardingPage::AiSetup, window, cx)
+ }
+ NavigationFocusItem::Welcome => {
+ self.jump_to_page(OnboardingPage::Welcome, window, cx)
+ }
+ NavigationFocusItem::Next => {
+ // Handle next button action
+ self.next_page(window, cx);
+ }
+ }
+ // After confirming navigation item (except Next), switch focus to page content
+ if self.nav_focus != NavigationFocusItem::Next {
+ self.focus_area = FocusArea::PageContent;
+ }
+ }
+ FocusArea::PageContent => {
+ // Handle page-specific item selection
+ let page_index = match self.current_page {
+ OnboardingPage::Basics => 0,
+ OnboardingPage::Editing => 1,
+ OnboardingPage::AiSetup => 2,
+ OnboardingPage::Welcome => 3,
+ };
+ let item_index = self.page_focus[page_index].0;
+
+ // Trigger the action for the focused item
+ match self.current_page {
+ OnboardingPage::Basics => {
+ match item_index {
+ 0..=3 => {
+ // Theme selection
+ cx.notify();
+ }
+ 4..=10 => {
+ // Keymap selection
+ cx.notify();
+ }
+ 11..=13 => {
+ // Checkbox toggles (handled by their own listeners)
+ cx.notify();
+ }
+ _ => {}
+ }
+ }
+ OnboardingPage::Editing => {
+ // Similar handling for editing page
+ cx.notify();
+ }
+ _ => {
+ cx.notify();
+ }
+ }
+ }
+ }
+ cx.notify();
+ }
+
+ fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context) {
+ match self.focus_area {
+ FocusArea::PageContent => {
+ // Switch focus back to navigation
+ self.focus_area = FocusArea::Navigation;
+ }
+ FocusArea::Navigation => {
+ // If already in navigation, maybe close the onboarding?
+ // For now, just stay in navigation
+ }
+ }
+ cx.notify();
+ }
+
+ fn toggle_focus(&mut self, _: &ToggleFocus, _window: &mut Window, cx: &mut Context) {
+ self.focus_area = match self.focus_area {
+ FocusArea::Navigation => FocusArea::PageContent,
+ FocusArea::PageContent => FocusArea::Navigation,
+ };
+ cx.notify();
+ }
+
+ fn mark_page_completed(
+ &mut self,
+ page: OnboardingPage,
+ _window: &mut gpui::Window,
+ cx: &mut Context,
+ ) {
+ self.completed_pages.insert(page);
+ cx.notify();
+ }
+
+ fn set_ai_assistance(
+ &mut self,
+ selection: &ToggleState,
+ _: &mut Window,
+ cx: &mut Context,
+ ) {
+ let enabled = selection == &ToggleState::Selected;
+
+ if let Some(workspace) = self.workspace.upgrade() {
+ let fs = workspace.read(cx).app_state().fs.clone();
+ AllLanguageSettings::set_ai_assistance(enabled, fs, cx);
+
+ cx.notify();
+ }
+ }
+
+ fn handle_jump_to_basics(
+ &mut self,
+ _: &JumpToBasics,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.jump_to_page(OnboardingPage::Basics, window, cx);
+ }
+
+ fn handle_jump_to_editing(
+ &mut self,
+ _: &JumpToEditing,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.jump_to_page(OnboardingPage::Editing, window, cx);
+ }
+
+ fn handle_jump_to_ai_setup(
+ &mut self,
+ _: &JumpToAiSetup,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.jump_to_page(OnboardingPage::AiSetup, window, cx);
+ }
+
+ fn handle_jump_to_welcome(
+ &mut self,
+ _: &JumpToWelcome,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.jump_to_page(OnboardingPage::Welcome, window, cx);
+ }
+
+ fn handle_next_page(&mut self, _: &NextPage, window: &mut Window, cx: &mut Context) {
+ self.next_page(window, cx);
+ }
+
+ fn handle_enable_ai_assistance(
+ &mut self,
+ _: &zed_actions::EnableAiAssistance,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ if let Some(workspace) = self.workspace.upgrade() {
+ let fs = workspace.read(cx).app_state().fs.clone();
+ update_settings_file::(fs, cx, move |file, _| {
+ file.features
+ .get_or_insert(Default::default())
+ .ai_assistance = Some(true);
+ });
+ cx.notify();
+ }
+ }
+
+ fn handle_disable_ai_assistance(
+ &mut self,
+ _: &zed_actions::DisableAiAssistance,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ if let Some(workspace) = self.workspace.upgrade() {
+ let fs = workspace.read(cx).app_state().fs.clone();
+ update_settings_file::(fs, cx, move |file, _| {
+ file.features
+ .get_or_insert(Default::default())
+ .ai_assistance = Some(false);
+ });
+ cx.notify();
+ }
+ }
+
+ fn handle_previous_page(
+ &mut self,
+ _: &PreviousPage,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.previous_page(window, cx);
+ }
+
+ fn render_navigation(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> impl gpui::IntoElement {
+ let client = self.client.clone();
+
+ v_flex()
+ .h_full()
+ .w(px(256.))
+ .gap_2()
+ .justify_between()
+ .child(
+ v_flex()
+ .w_full()
+ .gap_px()
+ .child(
+ h_flex()
+ .w_full()
+ .justify_between()
+ .py(px(24.))
+ .pl(px(24.))
+ .pr(px(12.))
+ .child(
+ Vector::new(VectorName::ZedLogo, rems(2.), rems(2.))
+ .color(Color::Custom(cx.theme().colors().icon.opacity(0.5))),
+ )
+ .child(
+ Button::new("sign_in", "Sign in")
+ .color(Color::Muted)
+ .label_size(LabelSize::Small)
+ .when(
+ self.focus_area == FocusArea::Navigation
+ && self.nav_focus == NavigationFocusItem::SignIn,
+ |this| this.color(Color::Accent),
+ )
+ .size(ButtonSize::Compact)
+ .on_click(cx.listener(move |_, _, window, cx| {
+ let client = client.clone();
+ window
+ .spawn(cx, async move |cx| {
+ client
+ .authenticate_and_connect(true, &cx)
+ .await
+ .into_response()
+ .notify_async_err(cx);
+ })
+ .detach();
+ })),
+ ),
+ )
+ .child(
+ v_flex()
+ .gap_px()
+ .py(px(16.))
+ .gap(px(2.))
+ .child(self.render_nav_item(
+ OnboardingPage::Basics,
+ "The Basics",
+ "1",
+ cx,
+ ))
+ .child(self.render_nav_item(
+ OnboardingPage::Editing,
+ "Editing Experience",
+ "2",
+ cx,
+ ))
+ .child(self.render_nav_item(
+ OnboardingPage::AiSetup,
+ "AI Setup",
+ "3",
+ cx,
+ ))
+ .child(self.render_nav_item(
+ OnboardingPage::Welcome,
+ "Welcome",
+ "4",
+ cx,
+ )),
+ ),
+ )
+ .child(self.render_bottom_controls(window, cx))
+ }
+
+ fn render_nav_item(
+ &mut self,
+ page: OnboardingPage,
+ label: impl Into,
+ shortcut: impl Into,
+ cx: &mut Context,
+ ) -> impl gpui::IntoElement {
+ let is_selected = self.current_page == page;
+ let label = label.into();
+ let shortcut = shortcut.into();
+ let id = ElementId::Name(label.clone());
+ let corner_radius = px(4.);
+
+ let item_focused = match page {
+ OnboardingPage::Basics => self.nav_focus == NavigationFocusItem::Basics,
+ OnboardingPage::Editing => self.nav_focus == NavigationFocusItem::Editing,
+ OnboardingPage::AiSetup => self.nav_focus == NavigationFocusItem::AiSetup,
+ OnboardingPage::Welcome => self.nav_focus == NavigationFocusItem::Welcome,
+ };
+
+ let area_focused = self.focus_area == FocusArea::Navigation;
+
+ FocusOutline::new(corner_radius, item_focused, px(2.))
+ .active(area_focused && item_focused)
+ .child(
+ h_flex()
+ .id(id)
+ .h(rems(1.625))
+ .w_full()
+ .rounded(corner_radius)
+ .px_3()
+ .when(is_selected, |this| {
+ this.bg(cx.theme().colors().border_focused.opacity(0.16))
+ })
+ .child(
+ h_flex()
+ .flex_1()
+ .justify_between()
+ .items_center()
+ .child(
+ Label::new(label)
+ .weight(FontWeight::MEDIUM)
+ .color(Color::Muted)
+ .when(item_focused, |this| this.color(Color::Default)),
+ )
+ .child(
+ Label::new(format!("⌘{}", shortcut.clone()))
+ .color(Color::Placeholder)
+ .size(LabelSize::XSmall),
+ ),
+ )
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.jump_to_page(page, window, cx);
+ })),
+ )
+ }
+
+ fn render_bottom_controls(
+ &mut self,
+ window: &mut gpui::Window,
+ cx: &mut Context,
+ ) -> impl gpui::IntoElement {
+ h_flex().w_full().p(px(12.)).child(
+ JuicyButton::new(if self.current_page == OnboardingPage::Welcome {
+ "Get Started"
+ } else {
+ "Next"
+ })
+ .keybinding(ui::KeyBinding::for_action_in(
+ &NextPage,
+ &self.focus_handle,
+ window,
+ cx,
+ ))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.next_page(window, cx);
+ })),
+ )
+ }
+
+ fn render_active_page(&mut self, _window: &mut Window, cx: &mut Context) -> AnyElement {
+ match self.current_page {
+ OnboardingPage::Basics => self.render_basics_page(cx),
+ OnboardingPage::Editing => self.render_editing_page(cx),
+ OnboardingPage::AiSetup => self.render_ai_setup_page(cx),
+ OnboardingPage::Welcome => self.render_welcome_page(cx),
+ }
+ }
+
+ fn render_basics_page(&mut self, cx: &mut Context) -> AnyElement {
+ let page_index = 0; // Basics page index
+ let focused_item = self.page_focus[page_index].0;
+ let is_page_focused = self.focus_area == FocusArea::PageContent;
+
+ use theme_preview::ThemePreviewTile;
+
+ // Get available themes
+ let theme_registry = ThemeRegistry::default_global(cx);
+ let theme_names = theme_registry.list_names();
+ let current_theme = cx.theme().clone();
+
+ v_flex()
+ .id("theme-selector")
+ .h_full()
+ .w_full()
+ .overflow_y_scroll()
+ .child({
+ let vim_enabled = VimModeSetting::get_global(cx).0;
+ CheckboxRow::new("Enable Vim Mode")
+ .checked(vim_enabled)
+ .on_click(move |_window, cx| {
+ let current = VimModeSetting::get_global(cx).0;
+ SettingsStore::update_global(cx, move |store, cx| {
+ let mut settings = store.raw_user_settings().clone();
+ settings["vim_mode"] = serde_json::json!(!current);
+ store.set_user_settings(&settings.to_string(), cx).ok();
+ });
+ })
+ })
+ // Theme selector section
+ .child(
+ v_flex()
+ .w_full()
+ .overflow_hidden()
+ .child(
+ HeaderRow::new("Pick a Theme").end_slot(
+ Button::new("more_themes", "More Themes")
+ .style(ButtonStyle::Subtle)
+ .color(Color::Muted)
+ .on_click(cx.listener(|_, _, window, cx| {
+ window.dispatch_action(
+ zed_actions::theme_selector::Toggle::default()
+ .boxed_clone(),
+ cx,
+ );
+ })),
+ ),
+ )
+ .child(
+ h_flex().w_full().overflow_hidden().gap_3().children(
+ vec![
+ ("One Dark", "One Dark"),
+ ("Gruvbox Dark", "Gruvbox Dark"),
+ ("One Light", "One Light"),
+ ("Gruvbox Light", "Gruvbox Light"),
+ ]
+ .into_iter()
+ .enumerate()
+ .map(|(i, (label, theme_name))| {
+ let is_selected = current_theme.name == *theme_name;
+ let is_focused = is_page_focused && focused_item == i;
+
+ v_flex()
+ .flex_1()
+ .gap_1p5()
+ .justify_center()
+ .text_center()
+ .child(
+ div()
+ .id(("theme", i))
+ .rounded(px(8.))
+ .h(px(90.))
+ .w_full()
+ .overflow_hidden()
+ .border_1()
+ .border_color(if is_focused {
+ cx.theme().colors().border_focused
+ } else {
+ transparent_black()
+ })
+ .child(
+ if let Ok(theme) = theme_registry.get(theme_name) {
+ ThemePreviewTile::new(theme, is_selected, 0.5)
+ .into_any_element()
+ } else {
+ div()
+ .size_full()
+ .bg(cx.theme().colors().surface_background)
+ .rounded_md()
+ .into_any_element()
+ },
+ )
+ .on_click(cx.listener(move |this, _, window, cx| {
+ SettingsStore::update_global(
+ cx,
+ move |store, cx| {
+ let mut settings =
+ store.raw_user_settings().clone();
+ settings["theme"] =
+ serde_json::json!(theme_name);
+ store
+ .set_user_settings(
+ &settings.to_string(),
+ cx,
+ )
+ .ok();
+ },
+ );
+ cx.notify();
+ })),
+ )
+ .child(
+ div()
+ .text_color(cx.theme().colors().text)
+ .text_size(px(12.))
+ .child(label),
+ )
+ }),
+ ),
+ ),
+ )
+ // Keymap selector section
+ .child(
+ v_flex()
+ .gap_1()
+ .child(HeaderRow::new("Pick a Keymap"))
+ .child(
+ h_flex().gap_2().children(
+ vec![
+ ("Zed", VectorName::ZedLogo, 4),
+ ("Atom", VectorName::AtomLogo, 5),
+ ("JetBrains", VectorName::ZedLogo, 6),
+ ("Sublime", VectorName::ZedLogo, 7),
+ ("VSCode", VectorName::ZedLogo, 8),
+ ("Emacs", VectorName::ZedLogo, 9),
+ ("TextMate", VectorName::ZedLogo, 10),
+ ]
+ .into_iter()
+ .map(|(label, icon, index)| {
+ let is_focused = is_page_focused && focused_item == index;
+ let current_keymap = BaseKeymap::get_global(cx).to_string();
+ let is_selected = current_keymap == label;
+
+ v_flex()
+ .w(px(72.))
+ .gap_1()
+ .items_center()
+ .justify_center()
+ .text_center()
+ .child(
+ h_flex()
+ .id(("keymap", index))
+ .size(px(48.))
+ .rounded(px(8.))
+ .items_center()
+ .justify_center()
+ .border_1()
+ .border_color(if is_selected {
+ cx.theme().colors().border_selected
+ } else {
+ transparent_black()
+ })
+ .when(is_focused, |this| {
+ this.border_color(
+ cx.theme().colors().border_focused,
+ )
+ })
+ .when(is_selected, |this| {
+ this.bg(cx.theme().status().info.opacity(0.08))
+ })
+ .child(
+ h_flex()
+ .size(px(34.))
+ .rounded(px(6.))
+ .border_2()
+ .border_color(cx.theme().colors().border)
+ .items_center()
+ .justify_center()
+ .shadow_hairline()
+ .child(
+ Vector::new(icon, rems(1.25), rems(1.25))
+ .color(if is_selected {
+ Color::Info
+ } else {
+ Color::Default
+ }),
+ ),
+ )
+ .on_click(cx.listener(move |this, _, window, cx| {
+ SettingsStore::update_global(
+ cx,
+ move |store, cx| {
+ let base_keymap = match label {
+ "Zed" => "None",
+ "Atom" => "Atom",
+ "JetBrains" => "JetBrains",
+ "Sublime" => "SublimeText",
+ "VSCode" => "VSCode",
+ "Emacs" => "Emacs",
+ "TextMate" => "TextMate",
+ _ => "VSCode",
+ };
+ let mut settings =
+ store.raw_user_settings().clone();
+ settings["base_keymap"] =
+ serde_json::json!(base_keymap);
+ store
+ .set_user_settings(
+ &settings.to_string(),
+ cx,
+ )
+ .ok();
+ },
+ );
+ cx.notify();
+ })),
+ )
+ .child(
+ div()
+ .text_color(cx.theme().colors().text)
+ .text_size(px(12.))
+ .child(label),
+ )
+ }),
+ ),
+ ),
+ )
+ // Settings checkboxes
+ .child(
+ v_flex()
+ .gap_1()
+ .child(HeaderRow::new("Help Improve Zed"))
+ .child({
+ let telemetry_enabled = TelemetrySettings::get_global(cx).metrics;
+ CheckboxRow::new("Send Telemetry")
+ .description("Help improve Zed by sending anonymous usage data")
+ .checked(telemetry_enabled)
+ .on_click(move |_window, cx| {
+ let current = TelemetrySettings::get_global(cx).metrics;
+ SettingsStore::update_global(cx, move |store, cx| {
+ let mut settings = store.raw_user_settings().clone();
+ if settings.get("telemetry").is_none() {
+ settings["telemetry"] = serde_json::json!({});
+ }
+ settings["telemetry"]["metrics"] = serde_json::json!(!current);
+ store.set_user_settings(&settings.to_string(), cx).ok();
+ });
+ })
+ })
+ .child({
+ let crash_reports_enabled = TelemetrySettings::get_global(cx).diagnostics;
+ CheckboxRow::new("Send Crash Reports")
+ .description("We use crash reports to help us fix issues")
+ .checked(crash_reports_enabled)
+ .on_click(move |_window, cx| {
+ let current = TelemetrySettings::get_global(cx).diagnostics;
+ SettingsStore::update_global(cx, move |store, cx| {
+ let mut settings = store.raw_user_settings().clone();
+ if settings.get("telemetry").is_none() {
+ settings["telemetry"] = serde_json::json!({});
+ }
+ settings["telemetry"]["diagnostics"] =
+ serde_json::json!(!current);
+ store.set_user_settings(&settings.to_string(), cx).ok();
+ });
+ })
+ }),
+ )
+ .into_any_element()
+ }
+
+ fn render_editing_page(&mut self, cx: &mut Context) -> AnyElement {
+ let page_index = 1; // Editing page index
+ let focused_item = self.page_focus[page_index].0;
+ let is_page_focused = self.focus_area == FocusArea::PageContent;
+
+ v_flex()
+ .h_full()
+ .w_full()
+ .items_center()
+ .justify_center()
+ .gap_4()
+ .child(
+ Label::new("Editing Features")
+ .size(LabelSize::Large)
+ .color(Color::Default),
+ )
+ .child(
+ v_flex()
+ .gap_2()
+ .mt_4()
+ .child(
+ Button::new("try_multi_cursor", "Try Multi-cursor Editing")
+ .style(ButtonStyle::Filled)
+ .when(is_page_focused && focused_item == 0, |this| {
+ this.color(Color::Accent)
+ })
+ .on_click(cx.listener(|_, _, _, cx| {
+ cx.notify();
+ })),
+ )
+ .child(
+ Button::new("learn_shortcuts", "Learn Keyboard Shortcuts")
+ .style(ButtonStyle::Filled)
+ .when(is_page_focused && focused_item == 1, |this| {
+ this.color(Color::Accent)
+ })
+ .on_click(cx.listener(|_, _, _, cx| {
+ cx.notify();
+ })),
+ )
+ .child(
+ Button::new("explore_actions", "Explore Command Palette")
+ .style(ButtonStyle::Filled)
+ .when(is_page_focused && focused_item == 2, |this| {
+ this.color(Color::Accent)
+ })
+ .on_click(cx.listener(|_, _, _, cx| {
+ cx.notify();
+ })),
+ ),
+ )
+ .into_any_element()
+ }
+
+ fn render_ai_setup_page(&mut self, cx: &mut Context) -> AnyElement {
+ let page_index = 2; // AI Setup page index
+ let focused_item = self.page_focus[page_index].0;
+ let is_page_focused = self.focus_area == FocusArea::PageContent;
+
+ let ai_enabled = all_language_settings(None, cx).is_ai_assistance_enabled();
+
+ v_flex()
+ .h_full()
+ .w_full()
+ .gap_4()
+ .child(
+ h_flex()
+ .justify_start()
+ .child(
+ CheckboxWithLabel::new(
+ "disable_ai",
+ Label::new("Enable AI Features"),
+ if ai_enabled {
+ ToggleState::Selected
+ } else {
+ ToggleState::Unselected
+ },
+ cx.listener(Self::set_ai_assistance),
+ )))
+ .child(
+ CalloutRow::new("We don't use your code to train AI models")
+ .line("You choose which providers you enable, and they have their own privacy policies.")
+ .line("Read more about our privacy practices in our Privacy Policy.")
+ )
+ .child(
+ HeaderRow::new("Choose your AI Providers")
+ )
+ .into_any_element()
+ }
+
+ fn render_welcome_page(&mut self, cx: &mut Context) -> AnyElement {
+ // Lazy-initialize the welcome page if needed
+ if self.welcome_page.is_none() {
+ if let Some(workspace) = self.workspace.upgrade() {
+ let _ = workspace.update(cx, |workspace, cx| {
+ self.welcome_page = Some(WelcomePage::new(workspace, cx));
+ });
+ }
+ }
+
+ // Render the welcome page if it exists, otherwise show a fallback
+ if let Some(welcome_page) = &self.welcome_page {
+ welcome_page.clone().into_any_element()
+ } else {
+ // Fallback UI if we couldn't create the welcome page
+ v_flex()
+ .h_full()
+ .w_full()
+ .items_center()
+ .justify_center()
+ .child(
+ Label::new("Unable to load welcome page")
+ .size(LabelSize::Default)
+ .color(Color::Error),
+ )
+ .into_any_element()
+ }
+ }
+}
+
+impl Item for OnboardingUI {
+ type Event = ItemEvent;
+
+ fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
+ "Onboarding".into()
+ }
+
+ fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
+ f(event.clone())
+ }
+
+ fn added_to_workspace(
+ &mut self,
+ workspace: &mut Workspace,
+ _window: &mut Window,
+ _cx: &mut Context,
+ ) {
+ self.workspace_id = workspace.database_id();
+ }
+
+ fn show_toolbar(&self) -> bool {
+ false
+ }
+
+ fn clone_on_split(
+ &self,
+ _workspace_id: Option,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Option> {
+ let weak_workspace = self.workspace.clone();
+ let client = self.client.clone();
+ if let Some(workspace) = weak_workspace.upgrade() {
+ workspace.update(cx, |workspace, cx| {
+ Some(cx.new(|cx| OnboardingUI::new(workspace, client, cx)))
+ })
+ } else {
+ None
+ }
+ }
+}
+
+impl SerializableItem for OnboardingUI {
+ fn serialized_item_kind() -> &'static str {
+ "OnboardingUI"
+ }
+
+ fn deserialize(
+ _project: Entity,
+ workspace: WeakEntity,
+ workspace_id: WorkspaceId,
+ item_id: u64,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> Task>> {
+ window.spawn(cx, async move |cx| {
+ let (current_page, completed_pages) = if let Some((page_str, completed_str)) =
+ ONBOARDING_DB.get_state(item_id, workspace_id)?
+ {
+ let page = match page_str.as_str() {
+ "basics" => OnboardingPage::Basics,
+ "editing" => OnboardingPage::Editing,
+ "ai_setup" => OnboardingPage::AiSetup,
+ "welcome" => OnboardingPage::Welcome,
+ _ => OnboardingPage::Basics,
+ };
+ let completed = OnboardingUI::completed_pages_from_string(&completed_str);
+ (page, completed)
+ } else {
+ (OnboardingPage::Basics, HashSet::new())
+ };
+
+ cx.update(|window, cx| {
+ let workspace = workspace
+ .upgrade()
+ .ok_or_else(|| anyhow::anyhow!("workspace dropped"))?;
+
+ workspace.update(cx, |workspace, cx| {
+ let client = workspace.client().clone();
+ Ok(cx.new(|cx| {
+ let mut onboarding = OnboardingUI::new(workspace, client, cx);
+ onboarding.current_page = current_page;
+ onboarding.completed_pages = completed_pages;
+ onboarding
+ }))
+ })
+ })?
+ })
+ }
+
+ fn serialize(
+ &mut self,
+ _workspace: &mut Workspace,
+ item_id: u64,
+ _closing: bool,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) -> Option>> {
+ let workspace_id = self.workspace_id?;
+ let current_page = match self.current_page {
+ OnboardingPage::Basics => "basics",
+ OnboardingPage::Editing => "editing",
+ OnboardingPage::AiSetup => "ai_setup",
+ OnboardingPage::Welcome => "welcome",
+ }
+ .to_string();
+ let completed_pages = self.completed_pages_to_string();
+
+ Some(cx.background_spawn(async move {
+ ONBOARDING_DB
+ .save_state(item_id, workspace_id, current_page, completed_pages)
+ .await
+ }))
+ }
+
+ fn cleanup(
+ _workspace_id: WorkspaceId,
+ _item_ids: Vec,
+ _window: &mut Window,
+ _cx: &mut App,
+ ) -> Task> {
+ Task::ready(Ok(()))
+ }
+
+ fn should_serialize(&self, _event: &ItemEvent) -> bool {
+ true
+ }
+}
diff --git a/crates/onboarding_ui/src/persistence.rs b/crates/onboarding_ui/src/persistence.rs
new file mode 100644
index 0000000000..944d5c58ae
--- /dev/null
+++ b/crates/onboarding_ui/src/persistence.rs
@@ -0,0 +1,53 @@
+use anyhow::Result;
+use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql};
+
+use workspace::{WorkspaceDb, WorkspaceId};
+
+define_connection! {
+ pub static ref ONBOARDING_DB: OnboardingDb =
+ &[sql!(
+ CREATE TABLE onboarding_state (
+ workspace_id INTEGER,
+ item_id INTEGER UNIQUE,
+ current_page TEXT,
+ completed_pages TEXT,
+ PRIMARY KEY(workspace_id, item_id),
+ FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+ ON DELETE CASCADE
+ ) STRICT;
+ )];
+}
+
+impl OnboardingDb {
+ pub async fn save_state(
+ &self,
+ item_id: u64,
+ workspace_id: WorkspaceId,
+ current_page: String,
+ completed_pages: String,
+ ) -> Result<()> {
+ let query =
+ "INSERT INTO onboarding_state(item_id, workspace_id, current_page, completed_pages)
+ VALUES (?1, ?2, ?3, ?4)
+ ON CONFLICT DO UPDATE SET
+ current_page = ?3,
+ completed_pages = ?4";
+ self.write(move |conn| {
+ let mut statement = Statement::prepare(conn, query)?;
+ let mut next_index = statement.bind(&item_id, 1)?;
+ next_index = statement.bind(&workspace_id, next_index)?;
+ next_index = statement.bind(¤t_page, next_index)?;
+ statement.bind(&completed_pages, next_index)?;
+ statement.exec()
+ })
+ .await
+ }
+
+ query! {
+ pub fn get_state(item_id: u64, workspace_id: WorkspaceId) -> Result