Onboarding refactor (#39724)

<img width="1648" height="976" alt="Screenshot 2025-10-07 at 6 57 20 PM"
src="https://github.com/user-attachments/assets/ae7289c0-8820-4fdf-ae28-84fb6bd64942"
/>

Fixes #39347

Release Notes:

- Improved onboarding UI by collapsing it to a single page

---------

Co-authored-by: dino <dinojoaocosta@gmail.com>
Co-authored-by: Lukas Wirth <me@lukaswirth.dev>
Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Mikayla Maki <mikayla@zed.dev>
This commit is contained in:
Andrew Farkas
2025-10-08 22:47:25 +00:00
committed by GitHub
co-authored by dino Lukas Wirth Mikayla Maki Anthony Eid Mikayla Maki
parent 4dbd186485
commit ba937d16e7
15 changed files with 415 additions and 1535 deletions
Generated
-5
View File
@@ -10560,20 +10560,15 @@ dependencies = [
name = "onboarding"
version = "0.1.0"
dependencies = [
"ai_onboarding",
"anyhow",
"client",
"component",
"db",
"documented",
"editor",
"fs",
"fuzzy",
"git",
"gpui",
"itertools 0.14.0",
"language",
"language_model",
"menu",
"notifications",
"picker",
-3
View File
@@ -1229,9 +1229,6 @@
"context": "Onboarding",
"use_key_equivalents": true,
"bindings": {
"ctrl-1": "onboarding::ActivateBasicsPage",
"ctrl-2": "onboarding::ActivateEditingPage",
"ctrl-3": "onboarding::ActivateAISetupPage",
"ctrl-enter": "onboarding::Finish",
"alt-shift-l": "onboarding::SignIn",
"alt-shift-a": "onboarding::OpenAccount"
+1 -4
View File
@@ -1334,10 +1334,7 @@
"context": "Onboarding",
"use_key_equivalents": true,
"bindings": {
"cmd-1": "onboarding::ActivateBasicsPage",
"cmd-2": "onboarding::ActivateEditingPage",
"cmd-3": "onboarding::ActivateAISetupPage",
"cmd-escape": "onboarding::Finish",
"cmd-enter": "onboarding::Finish",
"alt-tab": "onboarding::SignIn",
"alt-shift-a": "onboarding::OpenAccount"
}
-3
View File
@@ -1257,9 +1257,6 @@
"context": "Onboarding",
"use_key_equivalents": true,
"bindings": {
"ctrl-1": "onboarding::ActivateBasicsPage",
"ctrl-2": "onboarding::ActivateEditingPage",
"ctrl-3": "onboarding::ActivateAISetupPage",
"ctrl-enter": "onboarding::Finish",
"alt-shift-l": "onboarding::SignIn",
"shift-alt-a": "onboarding::OpenAccount"
+4 -4
View File
@@ -409,7 +409,7 @@ impl AgentConfiguration {
SwitchField::new(
"always-allow-tool-actions-switch",
"Allow running commands without asking for confirmation",
Some("Allow running commands without asking for confirmation"),
Some(
"The agent can perform potentially destructive actions without asking for your confirmation.".into(),
),
@@ -429,7 +429,7 @@ impl AgentConfiguration {
SwitchField::new(
"single-file-review",
"Enable single-file agent reviews",
Some("Enable single-file agent reviews"),
Some("Agent edits are also displayed in single-file editors for review.".into()),
single_file_review,
move |state, _window, cx| {
@@ -450,7 +450,7 @@ impl AgentConfiguration {
SwitchField::new(
"sound-notification",
"Play sound when finished generating",
Some("Play sound when finished generating"),
Some(
"Hear a notification sound when the agent is done generating changes or needs your input.".into(),
),
@@ -470,7 +470,7 @@ impl AgentConfiguration {
SwitchField::new(
"modifier-send",
"Use modifier to submit a message",
Some("Use modifier to submit a message"),
Some(
"Make a modifier (cmd-enter on macOS, ctrl-enter on Linux or Windows) required to send messages.".into(),
),
+1 -1
View File
@@ -58,7 +58,7 @@ mod prompts;
use crate::util::atomic_incr_if_not_zero;
pub use prompts::*;
pub(crate) const DEFAULT_WINDOW_SIZE: Size<Pixels> = size(px(1024.), px(700.));
pub(crate) const DEFAULT_WINDOW_SIZE: Size<Pixels> = size(px(1536.), px(864.));
/// Represents the two different phases when dispatching events.
#[derive(Default, Copy, Clone, Debug, Eq, PartialEq)]
-5
View File
@@ -15,20 +15,15 @@ path = "src/onboarding.rs"
default = []
[dependencies]
ai_onboarding.workspace = true
anyhow.workspace = true
client.workspace = true
component.workspace = true
db.workspace = true
documented.workspace = true
editor.workspace = true
fs.workspace = true
fuzzy.workspace = true
git.workspace = true
gpui.workspace = true
itertools.workspace = true
language.workspace = true
language_model.workspace = true
menu.workspace = true
notifications.workspace = true
picker.workspace = true
-427
View File
@@ -1,427 +0,0 @@
use std::sync::Arc;
use ai_onboarding::AiUpsellCard;
use client::{Client, UserStore, zed_urls};
use fs::Fs;
use gpui::{
Action, AnyView, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity,
Window, prelude::*,
};
use itertools;
use language_model::{LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry};
use project::DisableAiSettings;
use settings::{Settings, update_settings_file};
use ui::{
Badge, ButtonLike, Divider, KeyBinding, Modal, ModalFooter, ModalHeader, Section, SwitchField,
ToggleState, prelude::*, tooltip_container,
};
use util::ResultExt;
use workspace::{ModalView, Workspace};
use zed_actions::agent::OpenSettings;
const FEATURED_PROVIDERS: [&str; 4] = ["anthropic", "google", "openai", "ollama"];
fn render_llm_provider_section(
tab_index: &mut isize,
workspace: WeakEntity<Workspace>,
disabled: bool,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
v_flex()
.gap_4()
.child(
v_flex()
.child(Label::new("Or use other LLM providers").size(LabelSize::Large))
.child(
Label::new("Bring your API keys to use the available providers with Zed's UI for free.")
.color(Color::Muted),
),
)
.child(render_llm_provider_card(tab_index, workspace, disabled, window, cx))
}
fn render_privacy_card(tab_index: &mut isize, disabled: bool, cx: &mut App) -> impl IntoElement {
let (title, description) = if disabled {
(
"AI is disabled across Zed",
"Re-enable it any time in Settings.",
)
} else {
(
"Privacy is the default for Zed",
"Any use or storage of your data is with your explicit, single-use, opt-in consent.",
)
};
v_flex()
.relative()
.pt_2()
.pb_2p5()
.pl_3()
.pr_2()
.border_1()
.border_dashed()
.border_color(cx.theme().colors().border.opacity(0.5))
.bg(cx.theme().colors().surface_background.opacity(0.3))
.rounded_lg()
.overflow_hidden()
.child(
h_flex()
.gap_2()
.justify_between()
.child(Label::new(title))
.child(
h_flex()
.gap_1()
.child(
Badge::new("Privacy")
.icon(IconName::ShieldCheck)
.tooltip(move |_, cx| cx.new(|_| AiPrivacyTooltip::new()).into()),
)
.child(
Button::new("learn_more", "Learn More")
.style(ButtonStyle::Outlined)
.label_size(LabelSize::Small)
.icon(IconName::ArrowUpRight)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.on_click(|_, _, cx| {
cx.open_url(&zed_urls::ai_privacy_and_security(cx))
})
.tab_index({
*tab_index += 1;
*tab_index - 1
}),
),
),
)
.child(
Label::new(description)
.size(LabelSize::Small)
.color(Color::Muted),
)
}
fn render_llm_provider_card(
tab_index: &mut isize,
workspace: WeakEntity<Workspace>,
disabled: bool,
_: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let registry = LanguageModelRegistry::read_global(cx);
v_flex()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().surface_background.opacity(0.5))
.rounded_lg()
.overflow_hidden()
.children(itertools::intersperse_with(
FEATURED_PROVIDERS
.into_iter()
.flat_map(|provider_name| {
registry.provider(&LanguageModelProviderId::new(provider_name))
})
.enumerate()
.map(|(index, provider)| {
let group_name = SharedString::new(format!("onboarding-hover-group-{}", index));
let is_authenticated = provider.is_authenticated(cx);
ButtonLike::new(("onboarding-ai-setup-buttons", index))
.size(ButtonSize::Large)
.tab_index({
*tab_index += 1;
*tab_index - 1
})
.child(
h_flex()
.group(&group_name)
.px_0p5()
.w_full()
.gap_2()
.justify_between()
.child(
h_flex()
.gap_1()
.child(
Icon::new(provider.icon())
.color(Color::Muted)
.size(IconSize::XSmall),
)
.child(Label::new(provider.name().0)),
)
.child(
h_flex()
.gap_1()
.when(!is_authenticated, |el| {
el.visible_on_hover(group_name.clone())
.child(
Icon::new(IconName::Settings)
.color(Color::Muted)
.size(IconSize::XSmall),
)
.child(
Label::new("Configure")
.color(Color::Muted)
.size(LabelSize::Small),
)
})
.when(is_authenticated && !disabled, |el| {
el.child(
Icon::new(IconName::Check)
.color(Color::Success)
.size(IconSize::XSmall),
)
.child(
Label::new("Configured")
.color(Color::Muted)
.size(LabelSize::Small),
)
}),
),
)
.on_click({
let workspace = workspace.clone();
move |_, window, cx| {
workspace
.update(cx, |workspace, cx| {
workspace.toggle_modal(window, cx, |window, cx| {
telemetry::event!(
"Welcome AI Modal Opened",
provider = provider.name().0,
);
let modal = AiConfigurationModal::new(
provider.clone(),
window,
cx,
);
window.focus(&modal.focus_handle(cx));
modal
});
})
.log_err();
}
})
.into_any_element()
}),
|| Divider::horizontal().into_any_element(),
))
.child(Divider::horizontal())
.child(
Button::new("agent_settings", "Add Many Others")
.size(ButtonSize::Large)
.icon(IconName::Plus)
.icon_position(IconPosition::Start)
.icon_color(Color::Muted)
.icon_size(IconSize::XSmall)
.on_click(|_event, window, cx| {
window.dispatch_action(OpenSettings.boxed_clone(), cx)
})
.tab_index({
*tab_index += 1;
*tab_index - 1
}),
)
}
pub(crate) fn render_ai_setup_page(
workspace: WeakEntity<Workspace>,
user_store: Entity<UserStore>,
client: Arc<Client>,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let mut tab_index = 0;
let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
v_flex()
.gap_2()
.child(
SwitchField::new(
"enable_ai",
"Enable AI features",
None,
if is_ai_disabled {
ToggleState::Unselected
} else {
ToggleState::Selected
},
|&toggle_state, _, cx| {
let enabled = match toggle_state {
ToggleState::Indeterminate => {
return;
}
ToggleState::Unselected => true,
ToggleState::Selected => false,
};
telemetry::event!(
"Welcome AI Enabled",
toggle = if enabled { "on" } else { "off" },
);
let fs = <dyn Fs>::global(cx);
update_settings_file(fs, cx, move |settings, _| {
settings.disable_ai = Some(enabled.into());
});
},
)
.tab_index({
tab_index += 1;
tab_index - 1
}),
)
.child(render_privacy_card(&mut tab_index, is_ai_disabled, cx))
.child(
v_flex()
.mt_2()
.gap_6()
.child(
AiUpsellCard::new(client, &user_store, user_store.read(cx).plan(), cx)
.tab_index(Some({
tab_index += 1;
tab_index - 1
})),
)
.child(render_llm_provider_section(
&mut tab_index,
workspace,
is_ai_disabled,
window,
cx,
))
.when(is_ai_disabled, |this| {
this.child(
div()
.id("backdrop")
.size_full()
.absolute()
.inset_0()
.bg(cx.theme().colors().editor_background)
.opacity(0.8)
.block_mouse_except_scroll(),
)
}),
)
}
struct AiConfigurationModal {
focus_handle: FocusHandle,
selected_provider: Arc<dyn LanguageModelProvider>,
configuration_view: AnyView,
}
impl AiConfigurationModal {
fn new(
selected_provider: Arc<dyn LanguageModelProvider>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let configuration_view = selected_provider.configuration_view(
language_model::ConfigurationViewTargetAgent::ZedAgent,
window,
cx,
);
Self {
focus_handle,
configuration_view,
selected_provider,
}
}
fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
}
impl ModalView for AiConfigurationModal {}
impl EventEmitter<DismissEvent> for AiConfigurationModal {}
impl Focusable for AiConfigurationModal {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for AiConfigurationModal {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.key_context("OnboardingAiConfigurationModal")
.w(rems(34.))
.elevation_3(cx)
.track_focus(&self.focus_handle)
.on_action(
cx.listener(|this, _: &menu::Cancel, _window, cx| this.cancel(&menu::Cancel, cx)),
)
.child(
Modal::new("onboarding-ai-setup-modal", None)
.header(
ModalHeader::new()
.icon(
Icon::new(self.selected_provider.icon())
.color(Color::Muted)
.size(IconSize::Small),
)
.headline(self.selected_provider.name().0),
)
.section(Section::new().child(self.configuration_view.clone()))
.footer(
ModalFooter::new().end_slot(
Button::new("ai-onb-modal-Done", "Done")
.key_binding(
KeyBinding::for_action_in(
&menu::Cancel,
&self.focus_handle.clone(),
window,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(|this, _event, _window, cx| {
this.cancel(&menu::Cancel, cx)
})),
),
),
)
}
}
pub struct AiPrivacyTooltip {}
impl AiPrivacyTooltip {
pub fn new() -> Self {
Self {}
}
}
impl Render for AiPrivacyTooltip {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const DESCRIPTION: &str = "We believe in opt-in data sharing as the default for building AI products, rather than opt-out. We'll only use or store your data if you affirmatively send it to us. ";
tooltip_container(cx, move |this, _| {
this.child(
h_flex()
.gap_1()
.child(
Icon::new(IconName::ShieldCheck)
.size(IconSize::Small)
.color(Color::Muted),
)
.child(Label::new("Privacy First")),
)
.child(
div().max_w_64().child(
Label::new(DESCRIPTION)
.size(LabelSize::Small)
.color(Color::Muted),
),
)
})
}
}
+154 -86
View File
@@ -2,19 +2,23 @@ use std::sync::Arc;
use client::TelemetrySettings;
use fs::Fs;
use gpui::{App, IntoElement};
use gpui::{Action, App, IntoElement};
use settings::{BaseKeymap, Settings, update_settings_file};
use theme::{
Appearance, SystemAppearance, ThemeMode, ThemeName, ThemeRegistry, ThemeSelection,
ThemeSettings,
};
use ui::{
ParentElement as _, StatefulInteractiveElement, SwitchField, ToggleButtonGroup,
ToggleButtonSimple, ToggleButtonWithIcon, prelude::*, rems_from_px,
ButtonLike, ParentElement as _, StatefulInteractiveElement, SwitchField, TintColor,
ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, prelude::*,
rems_from_px,
};
use vim_mode_setting::VimModeSetting;
use crate::theme_preview::{ThemePreviewStyle, ThemePreviewTile};
use crate::{
ImportCursorSettings, ImportVsCodeSettings, SettingsImportState,
theme_preview::{ThemePreviewStyle, ThemePreviewTile},
};
const LIGHT_THEMES: [&str; 3] = ["One Light", "Ayu Light", "Gruvbox Light"];
const DARK_THEMES: [&str; 3] = ["One Dark", "Ayu Dark", "Gruvbox Dark"];
@@ -70,6 +74,7 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement
)
}),
)
.size(ToggleButtonGroupSize::Medium)
.tab_index(tab_index)
.selected_index(theme_mode as usize)
.style(ui::ToggleButtonGroupStyle::Outlined)
@@ -220,91 +225,87 @@ fn render_telemetry_section(tab_index: &mut isize, cx: &App) -> impl IntoElement
.gap_4()
.border_t_1()
.border_color(cx.theme().colors().border_variant.opacity(0.5))
.child(Label::new("Telemetry").size(LabelSize::Large))
.child(SwitchField::new(
"onboarding-telemetry-metrics",
"Help Improve Zed",
Some("Anonymous usage data helps us build the right features and improve your experience.".into()),
if TelemetrySettings::get_global(cx).metrics {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
{
let fs = fs.clone();
move |selection, _, cx| {
let enabled = match selection {
ToggleState::Selected => true,
ToggleState::Unselected => false,
ToggleState::Indeterminate => { return; },
};
.child(
SwitchField::new(
"onboarding-telemetry-metrics",
None::<&str>,
Some("Help improve Zed by sending anonymous usage data".into()),
if TelemetrySettings::get_global(cx).metrics {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
{
let fs = fs.clone();
move |selection, _, cx| {
let enabled = match selection {
ToggleState::Selected => true,
ToggleState::Unselected => false,
ToggleState::Indeterminate => {
return;
}
};
update_settings_file(
fs.clone(),
cx,
move |setting, _| {
setting.telemetry.get_or_insert_default().metrics = Some(enabled);
update_settings_file(fs.clone(), cx, move |setting, _| {
setting.telemetry.get_or_insert_default().metrics = Some(enabled);
});
// This telemetry event shouldn't fire when it's off. If it does we'll be alerted
// and can fix it in a timely manner to respect a user's choice.
telemetry::event!(
"Welcome Page Telemetry Metrics Toggled",
options = if enabled { "on" } else { "off" }
);
}
,
);
},
)
.tab_index({
*tab_index += 1;
*tab_index
}),
)
.child(
SwitchField::new(
"onboarding-telemetry-crash-reports",
None::<&str>,
Some(
"Help fix Zed by sending crash reports so we can fix critical issues fast"
.into(),
),
if TelemetrySettings::get_global(cx).diagnostics {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
{
let fs = fs.clone();
move |selection, _, cx| {
let enabled = match selection {
ToggleState::Selected => true,
ToggleState::Unselected => false,
ToggleState::Indeterminate => {
return;
}
};
// This telemetry event shouldn't fire when it's off. If it does we'll be alerted
// and can fix it in a timely manner to respect a user's choice.
telemetry::event!("Welcome Page Telemetry Metrics Toggled",
options = if enabled {
"on"
} else {
"off"
}
);
}},
).tab_index({
*tab_index += 1;
*tab_index
}))
.child(SwitchField::new(
"onboarding-telemetry-crash-reports",
"Help Fix Zed",
Some("Send crash reports so we can fix critical issues fast.".into()),
if TelemetrySettings::get_global(cx).diagnostics {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
{
let fs = fs.clone();
move |selection, _, cx| {
let enabled = match selection {
ToggleState::Selected => true,
ToggleState::Unselected => false,
ToggleState::Indeterminate => { return; },
};
update_settings_file(
fs.clone(),
cx,
move |setting, _| {
update_settings_file(fs.clone(), cx, move |setting, _| {
setting.telemetry.get_or_insert_default().diagnostics = Some(enabled);
},
});
);
// This telemetry event shouldn't fire when it's off. If it does we'll be alerted
// and can fix it in a timely manner to respect a user's choice.
telemetry::event!("Welcome Page Telemetry Diagnostics Toggled",
options = if enabled {
"on"
} else {
"off"
}
);
}
}
).tab_index({
*tab_index += 1;
*tab_index
}))
// This telemetry event shouldn't fire when it's off. If it does we'll be alerted
// and can fix it in a timely manner to respect a user's choice.
telemetry::event!(
"Welcome Page Telemetry Diagnostics Toggled",
options = if enabled { "on" } else { "off" }
);
}
},
)
.tab_index({
*tab_index += 1;
*tab_index
}),
)
}
fn render_base_keymap_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement {
@@ -372,8 +373,8 @@ fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoEleme
};
SwitchField::new(
"onboarding-vim-mode",
"Vim Mode",
Some("Coming from Neovim? Use our first-class implementation of Vim Mode.".into()),
Some("Vim Mode"),
Some("Coming from Neovim? Use our first-class implementation of Vim Mode".into()),
toggle_state,
{
let fs = <dyn Fs>::global(cx);
@@ -402,12 +403,79 @@ fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoEleme
})
}
fn render_setting_import_button(
tab_index: isize,
label: SharedString,
action: &dyn Action,
imported: bool,
) -> impl IntoElement + 'static {
let action = action.boxed_clone();
h_flex().w_full().child(
ButtonLike::new(label.clone())
.style(ButtonStyle::OutlinedTransparent)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.toggle_state(imported)
.size(ButtonSize::Medium)
.tab_index(tab_index)
.child(
h_flex()
.w_full()
.justify_between()
.when(imported, |this| {
this.child(Icon::new(IconName::Check).color(Color::Success))
})
.child(Label::new(label.clone()).mx_2().size(LabelSize::Small)),
)
.on_click(move |_, window, cx| {
telemetry::event!("Welcome Import Settings", import_source = label,);
window.dispatch_action(action.boxed_clone(), cx);
}),
)
}
fn render_import_settings_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement {
let import_state = SettingsImportState::global(cx);
let imports: [(SharedString, &dyn Action, bool); 2] = [
(
"VS Code".into(),
&ImportVsCodeSettings { skip_prompt: false },
import_state.vscode,
),
(
"Cursor".into(),
&ImportCursorSettings { skip_prompt: false },
import_state.cursor,
),
];
let [vscode, cursor] = imports.map(|(label, action, imported)| {
*tab_index += 1;
render_setting_import_button(*tab_index - 1, label, action, imported)
});
h_flex()
.child(
v_flex()
.gap_0p5()
.max_w_5_6()
.child(Label::new("Import Settings"))
.child(
Label::new("Automatically pull your settings from other editors")
.color(Color::Muted),
),
)
.child(div().w_full())
.child(h_flex().gap_1().child(vscode).child(cursor))
}
pub(crate) fn render_basics_page(cx: &mut App) -> impl IntoElement {
let mut tab_index = 0;
v_flex()
.id("basics-page")
.gap_6()
.child(render_theme_section(&mut tab_index, cx))
.child(render_base_keymap_section(&mut tab_index, cx))
.child(render_import_settings_section(&mut tab_index, cx))
.child(render_vim_mode_switch(&mut tab_index, cx))
.child(render_telemetry_section(&mut tab_index, cx))
}
-611
View File
@@ -1,611 +0,0 @@
use std::sync::Arc;
use editor::{EditorSettings, ShowMinimap};
use fs::Fs;
use gpui::{Action, App, FontFeatures, IntoElement, Pixels, SharedString, Window};
use language::language_settings::{AllLanguageSettings, FormatOnSave};
use project::project_settings::ProjectSettings;
use settings::{Settings as _, update_settings_file};
use theme::{FontFamilyName, ThemeSettings};
use ui::{
ButtonLike, PopoverMenu, SwitchField, ToggleButtonGroup, ToggleButtonGroupStyle,
ToggleButtonSimple, ToggleState, Tooltip, prelude::*,
};
use ui_input::{NumberField, font_picker};
use crate::{ImportCursorSettings, ImportVsCodeSettings, SettingsImportState};
fn read_show_mini_map(cx: &App) -> ShowMinimap {
editor::EditorSettings::get_global(cx).minimap.show
}
fn write_show_mini_map(show: ShowMinimap, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
// This is used to speed up the UI
// the UI reads the current values to get what toggle state to show on buttons
// there's a slight delay if we just call update_settings_file so we manually set
// the value here then call update_settings file to get around the delay
let mut curr_settings = EditorSettings::get_global(cx).clone();
curr_settings.minimap.show = show;
EditorSettings::override_global(curr_settings, cx);
update_settings_file(fs, cx, move |settings, _| {
telemetry::event!(
"Welcome Minimap Clicked",
from = settings.editor.minimap.clone().unwrap_or_default(),
to = show
);
settings.editor.minimap.get_or_insert_default().show = Some(show);
});
}
fn read_inlay_hints(cx: &App) -> bool {
AllLanguageSettings::get_global(cx)
.defaults
.inlay_hints
.enabled
}
fn write_inlay_hints(enabled: bool, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
let mut curr_settings = AllLanguageSettings::get_global(cx).clone();
curr_settings.defaults.inlay_hints.enabled = enabled;
AllLanguageSettings::override_global(curr_settings, cx);
update_settings_file(fs, cx, move |settings, _cx| {
settings
.project
.all_languages
.defaults
.inlay_hints
.get_or_insert_default()
.enabled = Some(enabled);
});
}
fn read_git_blame(cx: &App) -> bool {
ProjectSettings::get_global(cx).git.inline_blame.enabled
}
fn write_git_blame(enabled: bool, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
let mut curr_settings = ProjectSettings::get_global(cx).clone();
curr_settings.git.inline_blame.enabled = enabled;
ProjectSettings::override_global(curr_settings, cx);
update_settings_file(fs, cx, move |settings, _| {
settings
.git
.get_or_insert_default()
.inline_blame
.get_or_insert_default()
.enabled = Some(enabled);
});
}
fn write_ui_font_family(font: SharedString, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
update_settings_file(fs, cx, move |settings, _| {
telemetry::event!(
"Welcome Font Changed",
type = "ui font",
old = settings.theme.ui_font_family,
new = font
);
settings.theme.ui_font_family = Some(FontFamilyName(font.into()));
});
}
fn write_ui_font_size(size: Pixels, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
update_settings_file(fs, cx, move |settings, _| {
settings.theme.ui_font_size = Some(size.into());
});
}
fn write_buffer_font_size(size: Pixels, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
update_settings_file(fs, cx, move |settings, _| {
settings.theme.buffer_font_size = Some(size.into());
});
}
fn write_buffer_font_family(font_family: SharedString, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
update_settings_file(fs, cx, move |settings, _| {
telemetry::event!(
"Welcome Font Changed",
type = "editor font",
old = settings.theme.buffer_font_family,
new = font_family
);
settings.theme.buffer_font_family = Some(FontFamilyName(font_family.into()));
});
}
fn read_font_ligatures(cx: &App) -> bool {
ThemeSettings::get_global(cx)
.buffer_font
.features
.is_calt_enabled()
.unwrap_or(true)
}
fn write_font_ligatures(enabled: bool, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
let bit = if enabled { 1 } else { 0 };
update_settings_file(fs, cx, move |settings, _| {
let mut features = settings
.theme
.buffer_font_features
.as_mut()
.map(|features| features.tag_value_list().to_vec())
.unwrap_or_default();
if let Some(calt_index) = features.iter().position(|(tag, _)| tag == "calt") {
features[calt_index].1 = bit;
} else {
features.push(("calt".into(), bit));
}
settings.theme.buffer_font_features = Some(FontFeatures(Arc::new(features)));
});
}
fn read_format_on_save(cx: &App) -> bool {
match AllLanguageSettings::get_global(cx).defaults.format_on_save {
FormatOnSave::On => true,
FormatOnSave::Off => false,
}
}
fn write_format_on_save(format_on_save: bool, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
update_settings_file(fs, cx, move |settings, _| {
settings.project.all_languages.defaults.format_on_save = Some(match format_on_save {
true => FormatOnSave::On,
false => FormatOnSave::Off,
});
});
}
fn render_setting_import_button(
tab_index: isize,
label: SharedString,
icon_name: IconName,
action: &dyn Action,
imported: bool,
) -> impl IntoElement {
let action = action.boxed_clone();
h_flex().w_full().child(
ButtonLike::new(label.clone())
.full_width()
.style(ButtonStyle::Outlined)
.size(ButtonSize::Large)
.tab_index(tab_index)
.child(
h_flex()
.w_full()
.justify_between()
.child(
h_flex()
.gap_1p5()
.px_1()
.child(
Icon::new(icon_name)
.color(Color::Muted)
.size(IconSize::XSmall),
)
.child(Label::new(label.clone())),
)
.when(imported, |this| {
this.child(
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Check)
.color(Color::Success)
.size(IconSize::XSmall),
)
.child(Label::new("Imported").size(LabelSize::Small)),
)
}),
)
.on_click(move |_, window, cx| {
telemetry::event!("Welcome Import Settings", import_source = label,);
window.dispatch_action(action.boxed_clone(), cx);
}),
)
}
fn render_import_settings_section(tab_index: &mut isize, cx: &App) -> impl IntoElement {
let import_state = SettingsImportState::global(cx);
let imports: [(SharedString, IconName, &dyn Action, bool); 2] = [
(
"VS Code".into(),
IconName::EditorVsCode,
&ImportVsCodeSettings { skip_prompt: false },
import_state.vscode,
),
(
"Cursor".into(),
IconName::EditorCursor,
&ImportCursorSettings { skip_prompt: false },
import_state.cursor,
),
];
let [vscode, cursor] = imports.map(|(label, icon_name, action, imported)| {
*tab_index += 1;
render_setting_import_button(*tab_index - 1, label, icon_name, action, imported)
});
v_flex()
.gap_4()
.child(
v_flex()
.child(Label::new("Import Settings").size(LabelSize::Large))
.child(
Label::new("Automatically pull your settings from other editors.")
.color(Color::Muted),
),
)
.child(h_flex().w_full().gap_4().child(vscode).child(cursor))
}
fn render_font_customization_section(
tab_index: &mut isize,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let theme_settings = ThemeSettings::get_global(cx);
let ui_font_size = theme_settings.ui_font_size(cx);
let ui_font_family = theme_settings.ui_font.family.clone();
let buffer_font_family = theme_settings.buffer_font.family.clone();
let buffer_font_size = theme_settings.buffer_font_size(cx);
let ui_font_picker =
cx.new(|cx| font_picker(ui_font_family.clone(), write_ui_font_family, window, cx));
let buffer_font_picker = cx.new(|cx| {
font_picker(
buffer_font_family.clone(),
write_buffer_font_family,
window,
cx,
)
});
let ui_font_handle = ui::PopoverMenuHandle::default();
let buffer_font_handle = ui::PopoverMenuHandle::default();
h_flex()
.w_full()
.gap_4()
.child(
v_flex()
.w_full()
.gap_1()
.child(Label::new("UI Font"))
.child(
h_flex()
.w_full()
.justify_between()
.gap_2()
.child(
PopoverMenu::new("ui-font-picker")
.menu({
let ui_font_picker = ui_font_picker;
move |_window, _cx| Some(ui_font_picker.clone())
})
.trigger(
ButtonLike::new("ui-font-family-button")
.style(ButtonStyle::Outlined)
.size(ButtonSize::Medium)
.full_width()
.tab_index({
*tab_index += 1;
*tab_index - 1
})
.child(
h_flex()
.w_full()
.justify_between()
.child(Label::new(ui_font_family))
.child(
Icon::new(IconName::ChevronUpDown)
.color(Color::Muted)
.size(IconSize::XSmall),
),
),
)
.full_width(true)
.anchor(gpui::Corner::TopLeft)
.offset(gpui::Point {
x: px(0.0),
y: px(4.0),
})
.with_handle(ui_font_handle),
)
.child(font_picker_stepper(
"ui-font-size",
&ui_font_size,
tab_index,
write_ui_font_size,
window,
cx,
)),
),
)
.child(
v_flex()
.w_full()
.gap_1()
.child(Label::new("Editor Font"))
.child(
h_flex()
.w_full()
.justify_between()
.gap_2()
.child(
PopoverMenu::new("buffer-font-picker")
.menu({
let buffer_font_picker = buffer_font_picker;
move |_window, _cx| Some(buffer_font_picker.clone())
})
.trigger(
ButtonLike::new("buffer-font-family-button")
.style(ButtonStyle::Outlined)
.size(ButtonSize::Medium)
.full_width()
.tab_index({
*tab_index += 1;
*tab_index - 1
})
.child(
h_flex()
.w_full()
.justify_between()
.child(Label::new(buffer_font_family))
.child(
Icon::new(IconName::ChevronUpDown)
.color(Color::Muted)
.size(IconSize::XSmall),
),
),
)
.full_width(true)
.anchor(gpui::Corner::TopLeft)
.offset(gpui::Point {
x: px(0.0),
y: px(4.0),
})
.with_handle(buffer_font_handle),
)
.child(font_picker_stepper(
"buffer-font-size",
&buffer_font_size,
tab_index,
write_buffer_font_size,
window,
cx,
)),
),
)
}
fn font_picker_stepper(
id: &'static str,
font_size: &Pixels,
tab_index: &mut isize,
write_font_size: fn(Pixels, &mut App),
window: &mut Window,
cx: &mut App,
) -> NumberField<u32> {
window.with_id(id, |window| {
let optimistic_font_size: gpui::Entity<Option<u32>> = window.use_state(cx, |_, _| None);
optimistic_font_size.update(cx, |optimistic_font_size, _| {
if let Some(optimistic_font_size_val) = optimistic_font_size {
if *optimistic_font_size_val == u32::from(font_size) {
*optimistic_font_size = None;
}
}
});
let stepper_font_size = optimistic_font_size
.read(cx)
.unwrap_or_else(|| font_size.into());
NumberField::new(
SharedString::new(format!("{}-stepper", id)),
stepper_font_size,
window,
cx,
)
.on_change(move |new_value, _, cx| {
optimistic_font_size.write(cx, Some(*new_value));
write_font_size(Pixels::from(*new_value), cx);
})
.format(|value| format!("{value}px"))
.tab_index({
*tab_index += 2;
*tab_index - 2
})
.min(6)
.max(32)
})
}
fn render_popular_settings_section(
tab_index: &mut isize,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
const LIGATURE_TOOLTIP: &str =
"Font ligatures combine two characters into one. For example, turning != into ≠.";
v_flex()
.pt_6()
.gap_4()
.border_t_1()
.border_color(cx.theme().colors().border_variant.opacity(0.5))
.child(Label::new("Popular Settings").size(LabelSize::Large))
.child(render_font_customization_section(tab_index, window, cx))
.child(
SwitchField::new(
"onboarding-font-ligatures",
"Font Ligatures",
Some("Combine text characters into their associated symbols.".into()),
if read_font_ligatures(cx) {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
|toggle_state, _, cx| {
let enabled = toggle_state == &ToggleState::Selected;
telemetry::event!(
"Welcome Font Ligature",
options = if enabled { "on" } else { "off" },
);
write_font_ligatures(enabled, cx);
},
)
.tab_index({
*tab_index += 1;
*tab_index - 1
})
.tooltip(Tooltip::text(LIGATURE_TOOLTIP)),
)
.child(
SwitchField::new(
"onboarding-format-on-save",
"Format on Save",
Some("Format code automatically when saving.".into()),
if read_format_on_save(cx) {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
|toggle_state, _, cx| {
let enabled = toggle_state == &ToggleState::Selected;
telemetry::event!(
"Welcome Format On Save Changed",
options = if enabled { "on" } else { "off" },
);
write_format_on_save(enabled, cx);
},
)
.tab_index({
*tab_index += 1;
*tab_index - 1
}),
)
.child(
SwitchField::new(
"onboarding-enable-inlay-hints",
"Inlay Hints",
Some("See parameter names for function and method calls inline.".into()),
if read_inlay_hints(cx) {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
|toggle_state, _, cx| {
let enabled = toggle_state == &ToggleState::Selected;
telemetry::event!(
"Welcome Inlay Hints Changed",
options = if enabled { "on" } else { "off" },
);
write_inlay_hints(enabled, cx);
},
)
.tab_index({
*tab_index += 1;
*tab_index - 1
}),
)
.child(
SwitchField::new(
"onboarding-git-blame-switch",
"Inline Git Blame",
Some("See who committed each line on a given file.".into()),
if read_git_blame(cx) {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
|toggle_state, _, cx| {
let enabled = toggle_state == &ToggleState::Selected;
telemetry::event!(
"Welcome Git Blame Changed",
options = if enabled { "on" } else { "off" },
);
write_git_blame(enabled, cx);
},
)
.tab_index({
*tab_index += 1;
*tab_index - 1
}),
)
.child(
h_flex()
.items_start()
.justify_between()
.child(
v_flex().child(Label::new("Minimap")).child(
Label::new("See a high-level overview of your source code.")
.color(Color::Muted),
),
)
.child(
ToggleButtonGroup::single_row(
"onboarding-show-mini-map",
[
ToggleButtonSimple::new("Auto", |_, _, cx| {
write_show_mini_map(ShowMinimap::Auto, cx);
})
.tooltip(Tooltip::text(
"Show the minimap if the editor's scrollbar is visible.",
)),
ToggleButtonSimple::new("Always", |_, _, cx| {
write_show_mini_map(ShowMinimap::Always, cx);
}),
ToggleButtonSimple::new("Never", |_, _, cx| {
write_show_mini_map(ShowMinimap::Never, cx);
}),
],
)
.selected_index(match read_show_mini_map(cx) {
ShowMinimap::Auto => 0,
ShowMinimap::Always => 1,
ShowMinimap::Never => 2,
})
.tab_index(tab_index)
.style(ToggleButtonGroupStyle::Outlined)
.width(ui::rems_from_px(3. * 64.)),
),
)
}
pub(crate) fn render_editing_page(window: &mut Window, cx: &mut App) -> impl IntoElement {
let mut tab_index = 0;
v_flex()
.gap_6()
.child(render_import_settings_section(&mut tab_index, cx))
.child(render_popular_settings_section(&mut tab_index, window, cx))
}
+97 -337
View File
@@ -14,8 +14,8 @@ use serde::Deserialize;
use settings::{SettingsStore, VsCodeSettingsSource};
use std::sync::Arc;
use ui::{
Avatar, ButtonLike, FluentBuilder, Headline, KeyBinding, ParentElement as _,
StatefulInteractiveElement, Vector, VectorName, WithScrollbar, prelude::*, rems_from_px,
KeyBinding, ParentElement as _, StatefulInteractiveElement, Vector, VectorName,
WithScrollbar as _, prelude::*, rems_from_px,
};
pub use ui_input::font_picker;
use workspace::{
@@ -26,10 +26,8 @@ use workspace::{
open_new, register_serializable_item, with_active_or_new_workspace,
};
mod ai_setup_page;
mod base_keymap_picker;
mod basics_page;
mod editing_page;
pub mod multibuffer_hint;
mod theme_preview;
mod welcome;
@@ -66,12 +64,6 @@ actions!(
actions!(
onboarding,
[
/// Activates the Basics page.
ActivateBasicsPage,
/// Activates the Editing page.
ActivateEditingPage,
/// Activates the AI Setup page.
ActivateAISetupPage,
/// Finish the onboarding process.
Finish,
/// Sign in while in the onboarding flow.
@@ -216,27 +208,9 @@ pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyh
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SelectedPage {
Basics,
Editing,
AiSetup,
}
impl SelectedPage {
fn name(&self) -> &'static str {
match self {
SelectedPage::Basics => "Basics",
SelectedPage::Editing => "Editing",
SelectedPage::AiSetup => "AI Setup",
}
}
}
struct Onboarding {
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
selected_page: SelectedPage,
user_store: Entity<UserStore>,
scroll_handle: ScrollHandle,
_settings_subscription: Subscription,
@@ -259,7 +233,6 @@ impl Onboarding {
workspace: workspace.weak_handle(),
focus_handle: cx.focus_handle(),
scroll_handle: ScrollHandle::new(),
selected_page: SelectedPage::Basics,
user_store: workspace.user_store().clone(),
_settings_subscription: cx
.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
@@ -267,228 +240,8 @@ impl Onboarding {
})
}
fn set_page(
&mut self,
page: SelectedPage,
clicked: Option<&'static str>,
cx: &mut Context<Self>,
) {
if let Some(click) = clicked {
telemetry::event!(
"Welcome Tab Clicked",
from = self.selected_page.name(),
to = page.name(),
clicked = click,
);
}
self.selected_page = page;
self.scroll_handle.set_offset(Default::default());
cx.notify();
cx.emit(ItemEvent::UpdateTab);
}
fn render_nav_buttons(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> [impl IntoElement; 3] {
let pages = [
SelectedPage::Basics,
SelectedPage::Editing,
SelectedPage::AiSetup,
];
let text = ["Basics", "Editing", "AI Setup"];
let actions: [&dyn Action; 3] = [
&ActivateBasicsPage,
&ActivateEditingPage,
&ActivateAISetupPage,
];
let mut binding = actions.map(|action| {
KeyBinding::for_action_in(action, &self.focus_handle, window, cx)
.map(|kb| kb.size(rems_from_px(12.)))
});
pages.map(|page| {
let i = page as usize;
let selected = self.selected_page == page;
h_flex()
.id(text[i])
.relative()
.w_full()
.gap_2()
.px_2()
.py_0p5()
.justify_between()
.rounded_sm()
.when(selected, |this| {
this.child(
div()
.h_4()
.w_px()
.bg(cx.theme().colors().text_accent)
.absolute()
.left_0(),
)
})
.hover(|style| style.bg(cx.theme().colors().element_hover))
.child(Label::new(text[i]).map(|this| {
if selected {
this.color(Color::Default)
} else {
this.color(Color::Muted)
}
}))
.child(binding[i].take().map_or(
gpui::Empty.into_any_element(),
IntoElement::into_any_element,
))
.on_click(cx.listener(move |this, click_event, _, cx| {
let click = match click_event {
gpui::ClickEvent::Mouse(_) => "mouse",
gpui::ClickEvent::Keyboard(_) => "keyboard",
};
this.set_page(page, Some(click), cx);
}))
})
}
fn render_nav(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.h_full()
.w(rems_from_px(220.))
.flex_shrink_0()
.gap_4()
.justify_between()
.child(
v_flex()
.gap_6()
.child(
h_flex()
.px_2()
.gap_4()
.child(Vector::square(VectorName::ZedLogo, rems(2.5)))
.child(
v_flex()
.child(
Headline::new("Welcome to Zed").size(HeadlineSize::Small),
)
.child(
Label::new("The editor for what's next")
.color(Color::Muted)
.size(LabelSize::Small)
.italic(),
),
),
)
.child(
v_flex()
.gap_4()
.child(
v_flex()
.py_4()
.border_y_1()
.border_color(cx.theme().colors().border_variant.opacity(0.5))
.gap_1()
.children(self.render_nav_buttons(window, cx)),
)
.map(|this| {
if let Some(user) = self.user_store.read(cx).current_user() {
this.child(
v_flex()
.gap_1()
.child(
h_flex()
.ml_2()
.gap_2()
.max_w_full()
.w_full()
.child(Avatar::new(user.avatar_uri.clone()))
.child(
Label::new(user.github_login.clone())
.truncate(),
),
)
.child(
ButtonLike::new("open_account")
.size(ButtonSize::Medium)
.child(
h_flex()
.ml_1()
.w_full()
.justify_between()
.child(Label::new("Open Account"))
.children(
KeyBinding::for_action_in(
&OpenAccount,
&self.focus_handle,
window,
cx,
)
.map(|kb| {
kb.size(rems_from_px(12.))
}),
),
)
.on_click(|_, window, cx| {
window.dispatch_action(
OpenAccount.boxed_clone(),
cx,
);
}),
),
)
} else {
this.child(
ButtonLike::new("sign_in")
.size(ButtonSize::Medium)
.child(
h_flex()
.ml_1()
.w_full()
.justify_between()
.child(Label::new("Sign In"))
.children(
KeyBinding::for_action_in(
&SignIn,
&self.focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
),
)
.on_click(|_, window, cx| {
telemetry::event!("Welcome Sign In Clicked");
window.dispatch_action(SignIn.boxed_clone(), cx);
}),
)
}
}),
),
)
.child({
Button::new("start_building", "Start Building")
.full_width()
.style(ButtonStyle::Outlined)
.size(ButtonSize::Medium)
.key_binding(
KeyBinding::for_action_in(&Finish, &self.focus_handle, window, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
telemetry::event!("Welcome Start Building Clicked");
window.dispatch_action(Finish.boxed_clone(), cx);
})
})
}
fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) {
telemetry::event!("Welcome Skip Clicked");
telemetry::event!("Finish Setup");
go_to_welcome_page(cx);
}
@@ -509,29 +262,14 @@ impl Onboarding {
cx.open_url(&zed_urls::account_url(cx))
}
fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let client = Client::global(cx);
match self.selected_page {
SelectedPage::Basics => crate::basics_page::render_basics_page(cx).into_any_element(),
SelectedPage::Editing => {
crate::editing_page::render_editing_page(window, cx).into_any_element()
}
SelectedPage::AiSetup => crate::ai_setup_page::render_ai_setup_page(
self.workspace.clone(),
self.user_store.clone(),
client,
window,
cx,
)
.into_any_element(),
}
fn render_page(&mut self, cx: &mut Context<Self>) -> AnyElement {
crate::basics_page::render_basics_page(cx).into_any_element()
}
}
impl Render for Onboarding {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
div()
.image_cache(gpui::retain_all("onboarding-page"))
.key_context({
let mut ctx = KeyContext::new_with_defaults();
@@ -545,15 +283,6 @@ impl Render for Onboarding {
.on_action(Self::on_finish)
.on_action(Self::handle_sign_in)
.on_action(Self::handle_open_account)
.on_action(cx.listener(|this, _: &ActivateBasicsPage, _, cx| {
this.set_page(SelectedPage::Basics, Some("action"), cx);
}))
.on_action(cx.listener(|this, _: &ActivateEditingPage, _, cx| {
this.set_page(SelectedPage::Editing, Some("action"), cx);
}))
.on_action(cx.listener(|this, _: &ActivateAISetupPage, _, cx| {
this.set_page(SelectedPage::AiSetup, Some("action"), cx);
}))
.on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| {
window.focus_next();
cx.notify();
@@ -563,35 +292,68 @@ impl Render for Onboarding {
cx.notify();
}))
.child(
h_flex()
.max_w(rems_from_px(1100.))
.max_h(rems_from_px(850.))
div()
.max_w(Rems(48.0))
.w_full()
.mx_auto()
.size_full()
.m_auto()
.py_20()
.px_12()
.items_start()
.gap_12()
.child(self.render_nav(window, cx))
.gap_6()
.child(
div()
v_flex()
.m_auto()
.id("page-content")
.gap_6()
.size_full()
.pr_6()
.max_w_full()
.min_w_0()
.p_12()
.border_color(cx.theme().colors().border_variant.opacity(0.5))
.overflow_y_scroll()
.child(
v_flex()
.id("page-content")
.size_full()
.max_w_full()
.min_w_0()
.pl_12()
.border_l_1()
.border_color(cx.theme().colors().border_variant.opacity(0.5))
.overflow_y_scroll()
.child(self.render_page(window, cx))
.track_scroll(&self.scroll_handle),
h_flex()
.w_full()
.gap_4()
.child(Vector::square(VectorName::ZedLogo, rems(2.5)))
.child(
v_flex()
.child(
Headline::new("Welcome to Zed")
.size(HeadlineSize::Small),
)
.child(
Label::new("The editor for what's next")
.color(Color::Muted)
.size(LabelSize::Small)
.italic(),
),
)
.child(div().w_full())
.child({
Button::new("finish_setup", "Finish Setup")
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.width(Rems(12.0))
.key_binding(
KeyBinding::for_action_in(
&Finish,
&self.focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
window.dispatch_action(Finish.boxed_clone(), cx);
})
})
.pb_6()
.border_b_1()
.border_color(cx.theme().colors().border_variant.opacity(0.5)),
)
.vertical_scrollbar_for(self.scroll_handle.clone(), window, cx),
),
.child(self.render_page(cx))
.track_scroll(&self.scroll_handle),
)
.vertical_scrollbar_for(self.scroll_handle.clone(), window, cx),
)
}
}
@@ -628,7 +390,6 @@ impl Item for Onboarding {
Some(cx.new(|cx| Onboarding {
workspace: self.workspace.clone(),
user_store: self.user_store.clone(),
selected_page: self.selected_page,
scroll_handle: ScrollHandle::new(),
focus_handle: cx.focus_handle(),
_settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
@@ -814,25 +575,10 @@ impl workspace::SerializableItem for Onboarding {
cx: &mut App,
) -> gpui::Task<gpui::Result<Entity<Self>>> {
window.spawn(cx, async move |cx| {
if let Some(page_number) =
if let Some(_) =
persistence::ONBOARDING_PAGES.get_onboarding_page(item_id, workspace_id)?
{
let page = match page_number {
0 => Some(SelectedPage::Basics),
1 => Some(SelectedPage::Editing),
2 => Some(SelectedPage::AiSetup),
_ => None,
};
workspace.update(cx, |workspace, cx| {
let onboarding_page = Onboarding::new(workspace, cx);
if let Some(page) = page {
zlog::info!("Onboarding page {page:?} loaded");
onboarding_page.update(cx, |onboarding_page, cx| {
onboarding_page.set_page(page, None, cx);
})
}
onboarding_page
})
workspace.update(cx, |workspace, cx| Onboarding::new(workspace, cx))
} else {
Err(anyhow::anyhow!("No onboarding page to deserialize"))
}
@@ -848,10 +594,10 @@ impl workspace::SerializableItem for Onboarding {
cx: &mut ui::Context<Self>,
) -> Option<gpui::Task<gpui::Result<()>>> {
let workspace_id = workspace.database_id()?;
let page_number = self.selected_page as u16;
Some(cx.background_spawn(async move {
persistence::ONBOARDING_PAGES
.save_onboarding_page(item_id, workspace_id, page_number)
.save_onboarding_page(item_id, workspace_id)
.await
}))
}
@@ -874,17 +620,32 @@ mod persistence {
impl Domain for OnboardingPagesDb {
const NAME: &str = stringify!(OnboardingPagesDb);
const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE onboarding_pages (
workspace_id INTEGER,
item_id INTEGER UNIQUE,
page_number INTEGER,
const MIGRATIONS: &[&str] = &[
sql!(
CREATE TABLE onboarding_pages (
workspace_id INTEGER,
item_id INTEGER UNIQUE,
page_number INTEGER,
PRIMARY KEY(workspace_id, item_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
)];
PRIMARY KEY(workspace_id, item_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
),
sql!(
CREATE TABLE onboarding_pages_2 (
workspace_id INTEGER,
item_id INTEGER UNIQUE,
PRIMARY KEY(workspace_id, item_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
INSERT INTO onboarding_pages_2 SELECT workspace_id, item_id FROM onboarding_pages;
DROP TABLE onboarding_pages;
ALTER TABLE onboarding_pages_2 RENAME TO onboarding_pages;
),
];
}
db::static_connection!(ONBOARDING_PAGES, OnboardingPagesDb, [WorkspaceDb]);
@@ -893,11 +654,10 @@ mod persistence {
query! {
pub async fn save_onboarding_page(
item_id: workspace::ItemId,
workspace_id: workspace::WorkspaceId,
page_number: u16
workspace_id: workspace::WorkspaceId
) -> Result<()> {
INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id, page_number)
VALUES (?, ?, ?)
INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id)
VALUES (?, ?)
}
}
@@ -905,8 +665,8 @@ mod persistence {
pub fn get_onboarding_page(
item_id: workspace::ItemId,
workspace_id: workspace::WorkspaceId
) -> Result<Option<u16>> {
SELECT page_number
) -> Result<Option<workspace::ItemId>> {
SELECT item_id
FROM onboarding_pages
WHERE item_id = ? AND workspace_id = ?
}
+9 -1
View File
@@ -151,6 +151,7 @@ impl SectionEntry {
}
pub struct WelcomePage {
first_paint: bool,
focus_handle: FocusHandle,
}
@@ -168,6 +169,10 @@ impl WelcomePage {
impl Render for WelcomePage {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.first_paint {
window.request_animation_frame();
self.first_paint = false;
}
let (first_section, second_section) = CONTENT;
let first_section_entries = first_section.entries.len();
let last_index = first_section_entries + second_section.entries.len();
@@ -311,7 +316,10 @@ impl WelcomePage {
cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
.detach();
WelcomePage { focus_handle }
WelcomePage {
first_paint: true,
focus_handle,
}
})
}
}
+76 -13
View File
@@ -135,6 +135,9 @@ pub enum ButtonStyle {
/// a fully transparent button.
Outlined,
/// Transparent button that always has an outline.
OutlinedTransparent,
/// A more de-emphasized version of the outlined button.
OutlinedGhost,
@@ -149,11 +152,38 @@ pub enum ButtonStyle {
Transparent,
}
/// Rounding for a button that may have straight edges.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub(crate) enum ButtonLikeRounding {
All,
Left,
Right,
pub(crate) struct ButtonLikeRounding {
/// Top-left corner rounding
pub top_left: bool,
/// Top-right corner rounding
pub top_right: bool,
/// Bottom-right corner rounding
pub bottom_right: bool,
/// Bottom-left corner rounding
pub bottom_left: bool,
}
impl ButtonLikeRounding {
pub const ALL: Self = Self {
top_left: true,
top_right: true,
bottom_right: true,
bottom_left: true,
};
pub const LEFT: Self = Self {
top_left: true,
top_right: false,
bottom_right: false,
bottom_left: true,
};
pub const RIGHT: Self = Self {
top_left: false,
top_right: true,
bottom_right: true,
bottom_left: false,
};
}
#[derive(Debug, Clone)]
@@ -198,6 +228,12 @@ impl ButtonStyle {
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedTransparent => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_background,
border_color: cx.theme().colors().border_variant,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedGhost => ButtonLikeStyles {
background: transparent_black(),
border_color: cx.theme().colors().border_variant,
@@ -249,6 +285,12 @@ impl ButtonStyle {
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedTransparent => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_hover,
border_color: cx.theme().colors().border,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedGhost => ButtonLikeStyles {
background: transparent_black(),
border_color: cx.theme().colors().border,
@@ -293,6 +335,12 @@ impl ButtonStyle {
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedTransparent => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_active,
border_color: cx.theme().colors().border_variant,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedGhost => ButtonLikeStyles {
background: transparent_black(),
border_color: cx.theme().colors().border_variant,
@@ -332,6 +380,12 @@ impl ButtonStyle {
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedTransparent => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_background,
border_color: cx.theme().colors().border,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedGhost => ButtonLikeStyles {
background: transparent_black(),
border_color: cx.theme().colors().border,
@@ -374,6 +428,12 @@ impl ButtonStyle {
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedTransparent => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_disabled,
border_color: cx.theme().colors().border_disabled,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedGhost => ButtonLikeStyles {
background: transparent_black(),
border_color: cx.theme().colors().border_disabled,
@@ -455,7 +515,7 @@ impl ButtonLike {
width: None,
height: None,
size: ButtonSize::Default,
rounding: Some(ButtonLikeRounding::All),
rounding: Some(ButtonLikeRounding::ALL),
tooltip: None,
hoverable_tooltip: None,
children: SmallVec::new(),
@@ -469,15 +529,15 @@ impl ButtonLike {
}
pub fn new_rounded_left(id: impl Into<ElementId>) -> Self {
Self::new(id).rounding(ButtonLikeRounding::Left)
Self::new(id).rounding(ButtonLikeRounding::LEFT)
}
pub fn new_rounded_right(id: impl Into<ElementId>) -> Self {
Self::new(id).rounding(ButtonLikeRounding::Right)
Self::new(id).rounding(ButtonLikeRounding::RIGHT)
}
pub fn new_rounded_all(id: impl Into<ElementId>) -> Self {
Self::new(id).rounding(ButtonLikeRounding::All)
Self::new(id).rounding(ButtonLikeRounding::ALL)
}
pub fn opacity(mut self, opacity: f32) -> Self {
@@ -630,14 +690,17 @@ impl RenderOnce for ButtonLike {
.when(
matches!(
self.style,
ButtonStyle::Outlined | ButtonStyle::OutlinedGhost
ButtonStyle::Outlined
| ButtonStyle::OutlinedTransparent
| ButtonStyle::OutlinedGhost
),
|this| this.border_1(),
)
.when_some(self.rounding, |this, rounding| match rounding {
ButtonLikeRounding::All => this.rounded_sm(),
ButtonLikeRounding::Left => this.rounded_l_sm(),
ButtonLikeRounding::Right => this.rounded_r_sm(),
.when_some(self.rounding, |this, rounding| {
this.when(rounding.top_left, |this| this.rounded_tl_sm())
.when(rounding.top_right, |this| this.rounded_tr_sm())
.when(rounding.bottom_right, |this| this.rounded_br_sm())
.when(rounding.bottom_left, |this| this.rounded_bl_sm())
})
.gap(DynamicSpacing::Base04.rems(cx))
.map(|this| match self.size {
@@ -6,15 +6,41 @@ use crate::{ButtonLike, ButtonLikeRounding, ElevationIndex, TintColor, Tooltip,
/// The position of a [`ToggleButton`] within a group of buttons.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum ToggleButtonPosition {
/// The toggle button is first in the group.
First,
pub struct ToggleButtonPosition {
/// The toggle button is one of the leftmost of the group.
leftmost: bool,
/// The toggle button is one of the rightmost of the group.
rightmost: bool,
/// The toggle button is one of the topmost of the group.
topmost: bool,
/// The toggle button is one of the bottommost of the group.
bottommost: bool,
}
/// The toggle button is in the middle of the group (i.e., it is not the first or last toggle button).
Middle,
impl ToggleButtonPosition {
pub const HORIZONTAL_FIRST: Self = Self {
leftmost: true,
..Self::HORIZONTAL_MIDDLE
};
pub const HORIZONTAL_MIDDLE: Self = Self {
leftmost: false,
rightmost: false,
topmost: true,
bottommost: true,
};
pub const HORIZONTAL_LAST: Self = Self {
rightmost: true,
..Self::HORIZONTAL_MIDDLE
};
/// The toggle button is last in the group.
Last,
pub(crate) fn to_rounding(self) -> ButtonLikeRounding {
ButtonLikeRounding {
top_left: self.topmost && self.leftmost,
top_right: self.topmost && self.rightmost,
bottom_right: self.bottommost && self.rightmost,
bottom_left: self.bottommost && self.leftmost,
}
}
}
#[derive(IntoElement, RegisterComponent)]
@@ -46,15 +72,15 @@ impl ToggleButton {
}
pub fn first(self) -> Self {
self.position_in_group(ToggleButtonPosition::First)
self.position_in_group(ToggleButtonPosition::HORIZONTAL_FIRST)
}
pub fn middle(self) -> Self {
self.position_in_group(ToggleButtonPosition::Middle)
self.position_in_group(ToggleButtonPosition::HORIZONTAL_MIDDLE)
}
pub fn last(self) -> Self {
self.position_in_group(ToggleButtonPosition::Last)
self.position_in_group(ToggleButtonPosition::HORIZONTAL_LAST)
}
}
@@ -153,10 +179,8 @@ impl RenderOnce for ToggleButton {
};
self.base
.when_some(self.position_in_group, |this, position| match position {
ToggleButtonPosition::First => this.rounding(ButtonLikeRounding::Left),
ToggleButtonPosition::Middle => this.rounding(None),
ToggleButtonPosition::Last => this.rounding(ButtonLikeRounding::Right),
.when_some(self.position_in_group, |this, position| {
this.rounding(position.to_rounding())
})
.child(
Label::new(self.label)
@@ -535,7 +559,15 @@ impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> RenderOnce
ButtonLike::new((group_name.clone(), entry_index))
.full_width()
.rounding(None)
.rounding(Some(
ToggleButtonPosition {
leftmost: col_index == 0,
rightmost: col_index == COLS - 1,
topmost: row_index == 0,
bottommost: row_index == ROWS - 1,
}
.to_rounding(),
))
.when_some(self.tab_index, |this, tab_index| {
this.tab_index(tab_index + entry_index as isize)
})
+26 -20
View File
@@ -585,7 +585,7 @@ impl RenderOnce for Switch {
///
/// let switch_field = SwitchField::new(
/// "feature-toggle",
/// "Enable feature",
/// Some("Enable feature"),
/// Some("This feature adds new functionality to the app.".into()),
/// ToggleState::Unselected,
/// |state, window, cx| {
@@ -596,7 +596,7 @@ impl RenderOnce for Switch {
#[derive(IntoElement, RegisterComponent)]
pub struct SwitchField {
id: ElementId,
label: SharedString,
label: Option<SharedString>,
description: Option<SharedString>,
toggle_state: ToggleState,
on_click: Arc<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>,
@@ -609,14 +609,14 @@ pub struct SwitchField {
impl SwitchField {
pub fn new(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
label: Option<impl Into<SharedString>>,
description: Option<SharedString>,
toggle_state: impl Into<ToggleState>,
on_click: impl Fn(&ToggleState, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
label: label.into(),
label: label.map(Into::into),
description,
toggle_state: toggle_state.into(),
on_click: Arc::new(on_click),
@@ -657,11 +657,11 @@ impl SwitchField {
impl RenderOnce for SwitchField {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let tooltip = self.tooltip.map(|tooltip_fn| {
h_flex()
.gap_0p5()
.child(Label::new(self.label.clone()))
.child(
let tooltip = self
.tooltip
.zip(self.label.clone())
.map(|(tooltip_fn, label)| {
h_flex().gap_0p5().child(Label::new(label)).child(
IconButton::new("tooltip_button", IconName::Info)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
@@ -673,7 +673,7 @@ impl RenderOnce for SwitchField {
})
.on_click(|_, _, _| {}), // Intentional empty on click handler so that clicking on the info tooltip icon doesn't trigger the switch toggle
)
});
});
h_flex()
.id((self.id.clone(), "container"))
@@ -694,11 +694,17 @@ impl RenderOnce for SwitchField {
(Some(description), None) => v_flex()
.gap_0p5()
.max_w_5_6()
.child(Label::new(self.label.clone()))
.when_some(self.label, |this, label| this.child(Label::new(label)))
.child(Label::new(description.clone()).color(Color::Muted))
.into_any_element(),
(None, Some(tooltip)) => tooltip.into_any_element(),
(None, None) => Label::new(self.label.clone()).into_any_element(),
(None, None) => {
if let Some(label) = self.label.clone() {
Label::new(label).into_any_element()
} else {
gpui::Empty.into_any_element()
}
}
})
.child(
Switch::new((self.id.clone(), "switch"), self.toggle_state)
@@ -748,7 +754,7 @@ impl Component for SwitchField {
"Unselected",
SwitchField::new(
"switch_field_unselected",
"Enable notifications",
Some("Enable notifications"),
Some("Receive notifications when new messages arrive.".into()),
ToggleState::Unselected,
|_, _, _| {},
@@ -759,7 +765,7 @@ impl Component for SwitchField {
"Selected",
SwitchField::new(
"switch_field_selected",
"Enable notifications",
Some("Enable notifications"),
Some("Receive notifications when new messages arrive.".into()),
ToggleState::Selected,
|_, _, _| {},
@@ -775,7 +781,7 @@ impl Component for SwitchField {
"Default",
SwitchField::new(
"switch_field_default",
"Default color",
Some("Default color"),
Some("This uses the default switch color.".into()),
ToggleState::Selected,
|_, _, _| {},
@@ -786,7 +792,7 @@ impl Component for SwitchField {
"Accent",
SwitchField::new(
"switch_field_accent",
"Accent color",
Some("Accent color"),
Some("This uses the accent color scheme.".into()),
ToggleState::Selected,
|_, _, _| {},
@@ -802,7 +808,7 @@ impl Component for SwitchField {
"Disabled",
SwitchField::new(
"switch_field_disabled",
"Disabled field",
Some("Disabled field"),
Some("This field is disabled and cannot be toggled.".into()),
ToggleState::Selected,
|_, _, _| {},
@@ -817,7 +823,7 @@ impl Component for SwitchField {
"No Description",
SwitchField::new(
"switch_field_disabled",
"Disabled field",
Some("Disabled field"),
None,
ToggleState::Selected,
|_, _, _| {},
@@ -832,7 +838,7 @@ impl Component for SwitchField {
"Tooltip with Description",
SwitchField::new(
"switch_field_tooltip_with_desc",
"Nice Feature",
Some("Nice Feature"),
Some("Enable advanced configuration options.".into()),
ToggleState::Unselected,
|_, _, _| {},
@@ -844,7 +850,7 @@ impl Component for SwitchField {
"Tooltip without Description",
SwitchField::new(
"switch_field_tooltip_no_desc",
"Nice Feature",
Some("Nice Feature"),
None,
ToggleState::Selected,
|_, _, _| {},