logiguard fork v3: full patch set on verified 8c74db0 tree
Includes prior-session patches (carry forward so the app compiles): - crates/gpui/build.rs: cross-compile manifest fix - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method - crates/gpui/src/window.rs: Window::activate_with_token public API - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix Plus the focus-serial fix: - serial.rs: SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; latest_serial_of() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
46
crates/onboarding/Cargo.toml
Normal file
46
crates/onboarding/Cargo.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "onboarding"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/onboarding.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
client.workspace = true
|
||||
cloud_api_types.workspace = true
|
||||
collections.workspace = true
|
||||
component.workspace = true
|
||||
db.workspace = true
|
||||
documented.workspace = true
|
||||
fs.workspace = true
|
||||
fuzzy.workspace = true
|
||||
gpui.workspace = true
|
||||
menu.workspace = true
|
||||
notifications.workspace = true
|
||||
picker.workspace = true
|
||||
project.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
settings.workspace = true
|
||||
telemetry.workspace = true
|
||||
theme.workspace = true
|
||||
theme_settings.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
vim_mode_setting.workspace = true
|
||||
workspace.workspace = true
|
||||
zed_actions.workspace = true
|
||||
zlog.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
db = {workspace = true, features = ["test-support"]}
|
||||
1
crates/onboarding/LICENSE-GPL
Symbolic link
1
crates/onboarding/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
229
crates/onboarding/src/base_keymap_picker.rs
Normal file
229
crates/onboarding/src/base_keymap_picker.rs
Normal file
@@ -0,0 +1,229 @@
|
||||
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
|
||||
use gpui::{
|
||||
App, Context, DismissEvent, Entity, EventEmitter, Focusable, Render, Task, WeakEntity, Window,
|
||||
actions,
|
||||
};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use project::Fs;
|
||||
use settings::{BaseKeymap, Settings, update_settings_file};
|
||||
use std::sync::Arc;
|
||||
use ui::{ListItem, ListItemSpacing, prelude::*};
|
||||
use util::ResultExt;
|
||||
use workspace::{ModalView, Workspace, ui::HighlightedLabel};
|
||||
|
||||
actions!(
|
||||
zed,
|
||||
[
|
||||
/// Toggles the base keymap selector modal.
|
||||
ToggleBaseKeymapSelector
|
||||
]
|
||||
);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
|
||||
workspace.register_action(toggle);
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn toggle(
|
||||
workspace: &mut Workspace,
|
||||
_: &ToggleBaseKeymapSelector,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
let fs = workspace.app_state().fs.clone();
|
||||
workspace.toggle_modal(window, cx, |window, cx| {
|
||||
BaseKeymapSelector::new(
|
||||
BaseKeymapSelectorDelegate::new(cx.entity().downgrade(), fs, cx),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
pub struct BaseKeymapSelector {
|
||||
picker: Entity<Picker<BaseKeymapSelectorDelegate>>,
|
||||
}
|
||||
|
||||
impl Focusable for BaseKeymapSelector {
|
||||
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
|
||||
self.picker.focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for BaseKeymapSelector {}
|
||||
impl ModalView for BaseKeymapSelector {}
|
||||
|
||||
impl BaseKeymapSelector {
|
||||
pub fn new(
|
||||
delegate: BaseKeymapSelectorDelegate,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<BaseKeymapSelector>,
|
||||
) -> Self {
|
||||
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
|
||||
Self { picker }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BaseKeymapSelector {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex().w(rems(34.)).child(self.picker.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BaseKeymapSelectorDelegate {
|
||||
selector: WeakEntity<BaseKeymapSelector>,
|
||||
matches: Vec<StringMatch>,
|
||||
selected_index: usize,
|
||||
fs: Arc<dyn Fs>,
|
||||
}
|
||||
|
||||
impl BaseKeymapSelectorDelegate {
|
||||
fn new(
|
||||
selector: WeakEntity<BaseKeymapSelector>,
|
||||
fs: Arc<dyn Fs>,
|
||||
cx: &mut Context<BaseKeymapSelector>,
|
||||
) -> Self {
|
||||
let base = BaseKeymap::get(None, cx);
|
||||
let selected_index = BaseKeymap::OPTIONS
|
||||
.iter()
|
||||
.position(|(_, value)| value == base)
|
||||
.unwrap_or(0);
|
||||
Self {
|
||||
selector,
|
||||
matches: Vec::new(),
|
||||
selected_index,
|
||||
fs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for BaseKeymapSelectorDelegate {
|
||||
type ListItem = ui::ListItem;
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
"Select a base keymap...".into()
|
||||
}
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.matches.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
_window: &mut Window,
|
||||
_: &mut Context<Picker<BaseKeymapSelectorDelegate>>,
|
||||
) {
|
||||
self.selected_index = ix;
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<BaseKeymapSelectorDelegate>>,
|
||||
) -> Task<()> {
|
||||
let background = cx.background_executor().clone();
|
||||
let candidates = BaseKeymap::names()
|
||||
.enumerate()
|
||||
.map(|(id, name)| StringMatchCandidate::new(id, name))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let matches = if query.is_empty() {
|
||||
candidates
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, candidate)| StringMatch {
|
||||
candidate_id: index,
|
||||
string: candidate.string,
|
||||
positions: Vec::new(),
|
||||
score: 0.0,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
match_strings(
|
||||
&candidates,
|
||||
&query,
|
||||
false,
|
||||
true,
|
||||
100,
|
||||
&Default::default(),
|
||||
background,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
this.update(cx, |this, _| {
|
||||
this.delegate.matches = matches;
|
||||
this.delegate.selected_index = this
|
||||
.delegate
|
||||
.selected_index
|
||||
.min(this.delegate.matches.len().saturating_sub(1));
|
||||
})
|
||||
.log_err();
|
||||
})
|
||||
}
|
||||
|
||||
fn confirm(
|
||||
&mut self,
|
||||
_: bool,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<BaseKeymapSelectorDelegate>>,
|
||||
) {
|
||||
if let Some(selection) = self.matches.get(self.selected_index) {
|
||||
let base_keymap = BaseKeymap::from_names(&selection.string);
|
||||
|
||||
telemetry::event!(
|
||||
"Settings Changed",
|
||||
setting = "keymap",
|
||||
value = base_keymap.to_string()
|
||||
);
|
||||
|
||||
update_settings_file(self.fs.clone(), cx, move |setting, _| {
|
||||
setting.base_keymap = Some(base_keymap.into())
|
||||
});
|
||||
}
|
||||
|
||||
self.selector
|
||||
.update(cx, |_, cx| {
|
||||
cx.emit(DismissEvent);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<BaseKeymapSelectorDelegate>>) {
|
||||
self.selector
|
||||
.update(cx, |_, cx| {
|
||||
cx.emit(DismissEvent);
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
let keymap_match = &self.matches.get(ix)?;
|
||||
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.toggle_state(selected)
|
||||
.child(HighlightedLabel::new(
|
||||
keymap_match.string.clone(),
|
||||
keymap_match.positions.clone(),
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
715
crates/onboarding/src/basics_page.rs
Normal file
715
crates/onboarding/src/basics_page.rs
Normal file
@@ -0,0 +1,715 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use client::{Client, TelemetrySettings, UserStore, zed_urls};
|
||||
use cloud_api_types::Plan;
|
||||
use collections::HashMap;
|
||||
use fs::Fs;
|
||||
use gpui::{Action, Animation, AnimationExt, App, Entity, IntoElement, TaskExt, pulsating_between};
|
||||
use project::agent_server_store::AllAgentServersSettings;
|
||||
use project::project_settings::ProjectSettings;
|
||||
use project::{AgentRegistryStore, RegistryAgent};
|
||||
use settings::{
|
||||
BaseKeymap, CustomAgentServerSettings, Settings, SettingsStore, update_settings_file,
|
||||
};
|
||||
use theme::{Appearance, SystemAppearance, ThemeRegistry};
|
||||
use theme_settings::{ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings};
|
||||
use ui::{
|
||||
AgentSetupButton, Divider, StatefulInteractiveElement, SwitchField, TintColor,
|
||||
ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip,
|
||||
prelude::*,
|
||||
};
|
||||
use vim_mode_setting::VimModeSetting;
|
||||
|
||||
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"];
|
||||
const FAMILY_NAMES: [SharedString; 3] = [
|
||||
SharedString::new_static("One"),
|
||||
SharedString::new_static("Ayu"),
|
||||
SharedString::new_static("Gruvbox"),
|
||||
];
|
||||
|
||||
fn get_theme_family_themes(theme_name: &str) -> Option<(&'static str, &'static str)> {
|
||||
for i in 0..LIGHT_THEMES.len() {
|
||||
if LIGHT_THEMES[i] == theme_name || DARK_THEMES[i] == theme_name {
|
||||
return Some((LIGHT_THEMES[i], DARK_THEMES[i]));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement {
|
||||
let theme_selection = ThemeSettings::get_global(cx).theme.clone();
|
||||
let system_appearance = theme::SystemAppearance::global(cx);
|
||||
|
||||
let theme_mode = theme_selection
|
||||
.mode()
|
||||
.unwrap_or_else(|| match *system_appearance {
|
||||
Appearance::Light => ThemeAppearanceMode::Light,
|
||||
Appearance::Dark => ThemeAppearanceMode::Dark,
|
||||
});
|
||||
|
||||
return v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex().justify_between().child(Label::new("Theme")).child(
|
||||
ToggleButtonGroup::single_row(
|
||||
"theme-selector-onboarding-dark-light",
|
||||
[
|
||||
ThemeAppearanceMode::Light,
|
||||
ThemeAppearanceMode::Dark,
|
||||
ThemeAppearanceMode::System,
|
||||
]
|
||||
.map(|mode| {
|
||||
const MODE_NAMES: [SharedString; 3] = [
|
||||
SharedString::new_static("Light"),
|
||||
SharedString::new_static("Dark"),
|
||||
SharedString::new_static("System"),
|
||||
];
|
||||
ToggleButtonSimple::new(
|
||||
MODE_NAMES[mode as usize].clone(),
|
||||
move |_, _, cx| {
|
||||
write_mode_change(mode, cx);
|
||||
|
||||
telemetry::event!(
|
||||
"Welcome Theme mode Changed",
|
||||
from = theme_mode,
|
||||
to = mode
|
||||
);
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
.size(ToggleButtonGroupSize::Medium)
|
||||
.tab_index(tab_index)
|
||||
.selected_index(theme_mode as usize)
|
||||
.style(ui::ToggleButtonGroupStyle::Outlined)
|
||||
.width(rems_from_px(3. * 64.)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.children(render_theme_previews(tab_index, &theme_selection, cx)),
|
||||
);
|
||||
|
||||
fn render_theme_previews(
|
||||
tab_index: &mut isize,
|
||||
theme_selection: &ThemeSelection,
|
||||
cx: &mut App,
|
||||
) -> [impl IntoElement; 3] {
|
||||
let system_appearance = SystemAppearance::global(cx);
|
||||
let theme_registry = ThemeRegistry::global(cx);
|
||||
|
||||
let theme_seed = 0xBEEF as f32;
|
||||
let theme_mode = theme_selection
|
||||
.mode()
|
||||
.unwrap_or_else(|| match *system_appearance {
|
||||
Appearance::Light => ThemeAppearanceMode::Light,
|
||||
Appearance::Dark => ThemeAppearanceMode::Dark,
|
||||
});
|
||||
let appearance = match theme_mode {
|
||||
ThemeAppearanceMode::Light => Appearance::Light,
|
||||
ThemeAppearanceMode::Dark => Appearance::Dark,
|
||||
ThemeAppearanceMode::System => *system_appearance,
|
||||
};
|
||||
let current_theme_name: SharedString = theme_selection.name(appearance).0.into();
|
||||
|
||||
let theme_names = match appearance {
|
||||
Appearance::Light => LIGHT_THEMES,
|
||||
Appearance::Dark => DARK_THEMES,
|
||||
};
|
||||
|
||||
let themes = theme_names.map(|theme| theme_registry.get(theme).unwrap());
|
||||
|
||||
[0, 1, 2].map(|index| {
|
||||
let theme = &themes[index];
|
||||
let is_selected = theme.name == current_theme_name;
|
||||
let name = theme.name.clone();
|
||||
let colors = cx.theme().colors();
|
||||
|
||||
v_flex()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.id(name)
|
||||
.relative()
|
||||
.w_full()
|
||||
.border_2()
|
||||
.border_color(colors.border_transparent)
|
||||
.rounded(ThemePreviewTile::ROOT_RADIUS)
|
||||
.map(|this| {
|
||||
if is_selected {
|
||||
this.border_color(colors.border_selected)
|
||||
} else {
|
||||
this.opacity(0.8).hover(|s| s.border_color(colors.border))
|
||||
}
|
||||
})
|
||||
.tab_index({
|
||||
*tab_index += 1;
|
||||
*tab_index - 1
|
||||
})
|
||||
.focus(|mut style| {
|
||||
style.border_color = Some(colors.border_focused);
|
||||
style
|
||||
})
|
||||
.on_click({
|
||||
let theme_name = theme.name.clone();
|
||||
let current_theme_name = current_theme_name.clone();
|
||||
|
||||
move |_, _, cx| {
|
||||
write_theme_change(theme_name.clone(), theme_mode, cx);
|
||||
telemetry::event!(
|
||||
"Welcome Theme Changed",
|
||||
from = current_theme_name,
|
||||
to = theme_name
|
||||
);
|
||||
}
|
||||
})
|
||||
.map(|this| {
|
||||
if theme_mode == ThemeAppearanceMode::System {
|
||||
let (light, dark) = (
|
||||
theme_registry.get(LIGHT_THEMES[index]).unwrap(),
|
||||
theme_registry.get(DARK_THEMES[index]).unwrap(),
|
||||
);
|
||||
this.child(
|
||||
ThemePreviewTile::new(light, theme_seed)
|
||||
.style(ThemePreviewStyle::SideBySide(dark)),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
ThemePreviewTile::new(theme.clone(), theme_seed)
|
||||
.style(ThemePreviewStyle::Bordered),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Label::new(FAMILY_NAMES[index].clone())
|
||||
.color(Color::Muted)
|
||||
.size(LabelSize::Small),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn write_mode_change(mode: ThemeAppearanceMode, cx: &mut App) {
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
update_settings_file(fs, cx, move |settings, _cx| {
|
||||
theme_settings::set_mode(settings, mode);
|
||||
});
|
||||
}
|
||||
|
||||
fn write_theme_change(
|
||||
theme: impl Into<Arc<str>>,
|
||||
theme_mode: ThemeAppearanceMode,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
let theme = theme.into();
|
||||
update_settings_file(fs, cx, move |settings, cx| match theme_mode {
|
||||
ThemeAppearanceMode::System => {
|
||||
let (light_theme, dark_theme) =
|
||||
get_theme_family_themes(&theme).unwrap_or((theme.as_ref(), theme.as_ref()));
|
||||
|
||||
settings.theme.theme = Some(settings::ThemeSelection::Dynamic {
|
||||
mode: ThemeAppearanceMode::System,
|
||||
light: ThemeName(light_theme.into()),
|
||||
dark: ThemeName(dark_theme.into()),
|
||||
});
|
||||
}
|
||||
ThemeAppearanceMode::Light => theme_settings::set_theme(
|
||||
settings,
|
||||
theme,
|
||||
Appearance::Light,
|
||||
*SystemAppearance::global(cx),
|
||||
),
|
||||
ThemeAppearanceMode::Dark => theme_settings::set_theme(
|
||||
settings,
|
||||
theme,
|
||||
Appearance::Dark,
|
||||
*SystemAppearance::global(cx),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn render_telemetry_section(tab_index: &mut isize, cx: &App) -> impl IntoElement {
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.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);
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_base_keymap_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement {
|
||||
let base_keymap = match BaseKeymap::get_global(cx) {
|
||||
BaseKeymap::VSCode => Some(0),
|
||||
BaseKeymap::JetBrains => Some(1),
|
||||
BaseKeymap::SublimeText => Some(2),
|
||||
BaseKeymap::Atom => Some(3),
|
||||
BaseKeymap::Emacs => Some(4),
|
||||
BaseKeymap::Cursor => Some(5),
|
||||
BaseKeymap::TextMate | BaseKeymap::None => None,
|
||||
};
|
||||
|
||||
return v_flex().gap_2().child(Label::new("Base Keymap")).child(
|
||||
ToggleButtonGroup::two_rows(
|
||||
"base_keymap_selection",
|
||||
[
|
||||
ToggleButtonWithIcon::new("VS Code", IconName::EditorVsCode, |_, _, cx| {
|
||||
write_keymap_base(BaseKeymap::VSCode, cx);
|
||||
}),
|
||||
ToggleButtonWithIcon::new("JetBrains", IconName::EditorJetBrains, |_, _, cx| {
|
||||
write_keymap_base(BaseKeymap::JetBrains, cx);
|
||||
}),
|
||||
ToggleButtonWithIcon::new("Sublime Text", IconName::EditorSublime, |_, _, cx| {
|
||||
write_keymap_base(BaseKeymap::SublimeText, cx);
|
||||
}),
|
||||
],
|
||||
[
|
||||
ToggleButtonWithIcon::new("Atom", IconName::EditorAtom, |_, _, cx| {
|
||||
write_keymap_base(BaseKeymap::Atom, cx);
|
||||
}),
|
||||
ToggleButtonWithIcon::new("Emacs", IconName::EditorEmacs, |_, _, cx| {
|
||||
write_keymap_base(BaseKeymap::Emacs, cx);
|
||||
}),
|
||||
ToggleButtonWithIcon::new("Cursor", IconName::EditorCursor, |_, _, cx| {
|
||||
write_keymap_base(BaseKeymap::Cursor, cx);
|
||||
}),
|
||||
],
|
||||
)
|
||||
.when_some(base_keymap, |this, base_keymap| {
|
||||
this.selected_index(base_keymap)
|
||||
})
|
||||
.full_width()
|
||||
.tab_index(tab_index)
|
||||
.size(ui::ToggleButtonGroupSize::Medium)
|
||||
.style(ui::ToggleButtonGroupStyle::Outlined),
|
||||
);
|
||||
|
||||
fn write_keymap_base(keymap_base: BaseKeymap, cx: &App) {
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
|
||||
update_settings_file(fs, cx, move |setting, _| {
|
||||
setting.base_keymap = Some(keymap_base.into());
|
||||
});
|
||||
|
||||
telemetry::event!("Welcome Keymap Changed", keymap = keymap_base);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoElement {
|
||||
let toggle_state = if VimModeSetting::get_global(cx).0 {
|
||||
ui::ToggleState::Selected
|
||||
} else {
|
||||
ui::ToggleState::Unselected
|
||||
};
|
||||
SwitchField::new(
|
||||
"onboarding-vim-mode",
|
||||
Some("Vim Mode"),
|
||||
Some("Coming from Neovim? Use our first-class implementation of Vim Mode".into()),
|
||||
toggle_state,
|
||||
{
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
move |&selection, _, cx| {
|
||||
let vim_mode = match selection {
|
||||
ToggleState::Selected => true,
|
||||
ToggleState::Unselected => false,
|
||||
ToggleState::Indeterminate => {
|
||||
return;
|
||||
}
|
||||
};
|
||||
update_settings_file(fs.clone(), cx, move |setting, _| {
|
||||
setting.vim_mode = Some(vim_mode);
|
||||
});
|
||||
|
||||
telemetry::event!(
|
||||
"Welcome Vim Mode Toggled",
|
||||
options = if vim_mode { "on" } else { "off" },
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.tab_index({
|
||||
*tab_index += 1;
|
||||
*tab_index - 1
|
||||
})
|
||||
}
|
||||
|
||||
fn render_worktree_auto_trust_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoElement {
|
||||
let toggle_state = if ProjectSettings::get_global(cx).session.trust_all_worktrees {
|
||||
ui::ToggleState::Selected
|
||||
} else {
|
||||
ui::ToggleState::Unselected
|
||||
};
|
||||
|
||||
let tooltip_description = "Zed can only allow services like language servers, project settings, and MCP servers to run after you mark a new project as trusted.";
|
||||
|
||||
SwitchField::new(
|
||||
"onboarding-auto-trust-worktrees",
|
||||
Some("Trust All Projects By Default"),
|
||||
Some("Automatically mark all new projects as trusted to unlock all Zed's features".into()),
|
||||
toggle_state,
|
||||
{
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
move |&selection, _, cx| {
|
||||
let trust = match selection {
|
||||
ToggleState::Selected => true,
|
||||
ToggleState::Unselected => false,
|
||||
ToggleState::Indeterminate => {
|
||||
return;
|
||||
}
|
||||
};
|
||||
update_settings_file(fs.clone(), cx, move |setting, _| {
|
||||
setting.session.get_or_insert_default().trust_all_worktrees = Some(trust);
|
||||
});
|
||||
|
||||
telemetry::event!(
|
||||
"Welcome Page Worktree Auto Trust Toggled",
|
||||
options = if trust { "on" } else { "off" }
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.tab_index({
|
||||
*tab_index += 1;
|
||||
*tab_index - 1
|
||||
})
|
||||
.tooltip(Tooltip::text(tooltip_description))
|
||||
}
|
||||
|
||||
fn render_setting_import_button(
|
||||
tab_index: isize,
|
||||
label: SharedString,
|
||||
action: &dyn Action,
|
||||
imported: bool,
|
||||
) -> impl IntoElement + 'static {
|
||||
let action = action.boxed_clone();
|
||||
|
||||
Button::new(label.clone(), label.clone())
|
||||
.style(ButtonStyle::OutlinedGhost)
|
||||
.size(ButtonSize::Medium)
|
||||
.label_size(LabelSize::Small)
|
||||
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
.toggle_state(imported)
|
||||
.tab_index(tab_index)
|
||||
.when(imported, |this| {
|
||||
this.end_icon(Icon::new(IconName::Check).size(IconSize::Small))
|
||||
.color(Color::Success)
|
||||
})
|
||||
.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()
|
||||
.gap_2()
|
||||
.flex_wrap()
|
||||
.justify_between()
|
||||
.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(h_flex().gap_1().child(vscode).child(cursor))
|
||||
}
|
||||
|
||||
pub(crate) const FEATURED_AGENT_IDS: &[&str] =
|
||||
&["claude-acp", "codex-acp", "github-copilot-cli", "cursor"];
|
||||
|
||||
fn render_registry_agent_button(
|
||||
agent: &RegistryAgent,
|
||||
installed: bool,
|
||||
cx: &mut App,
|
||||
) -> impl IntoElement {
|
||||
let agent_id = agent.id().to_string();
|
||||
let element_id = format!("{}-onboarding", agent_id);
|
||||
|
||||
let icon = match agent.icon_path() {
|
||||
Some(icon_path) => Icon::from_external_svg(icon_path.clone()),
|
||||
None => Icon::new(IconName::Sparkle),
|
||||
}
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Muted);
|
||||
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
|
||||
let state_element = if installed {
|
||||
Icon::new(IconName::Check)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Success)
|
||||
.into_any_element()
|
||||
} else {
|
||||
Label::new("Install")
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted)
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
AgentSetupButton::new(element_id)
|
||||
.icon(icon)
|
||||
.name(agent.name().clone())
|
||||
.state(state_element)
|
||||
.disabled(installed)
|
||||
.on_click(move |_, _, cx| {
|
||||
telemetry::event!("Welcome Agent Install Clicked", agent = agent_id.as_str());
|
||||
let agent_id = agent_id.clone();
|
||||
update_settings_file(fs.clone(), cx, move |settings, _| {
|
||||
let agent_servers = settings.agent_servers.get_or_insert_default();
|
||||
agent_servers.entry(agent_id).or_insert_with(|| {
|
||||
CustomAgentServerSettings::Registry {
|
||||
env: Default::default(),
|
||||
default_mode: None,
|
||||
default_model: None,
|
||||
favorite_models: Vec::new(),
|
||||
default_config_options: HashMap::default(),
|
||||
favorite_config_option_values: HashMap::default(),
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn render_zed_agent_button(user_store: &Entity<UserStore>, cx: &mut App) -> impl IntoElement {
|
||||
let client = Client::global(cx);
|
||||
let status = *client.status().borrow();
|
||||
|
||||
let plan = user_store.read(cx).plan();
|
||||
let is_free = matches!(plan, Some(Plan::ZedFree) | None);
|
||||
let is_pro = matches!(plan, Some(Plan::ZedPro));
|
||||
let is_trial = matches!(plan, Some(Plan::ZedProTrial));
|
||||
|
||||
let is_signed_out = status.is_signed_out()
|
||||
|| matches!(
|
||||
status,
|
||||
client::Status::AuthenticationError | client::Status::ConnectionError
|
||||
);
|
||||
let is_signing_in = status.is_signing_in();
|
||||
let is_signed_in = !is_signed_out;
|
||||
|
||||
let state_element = if is_signed_out {
|
||||
Label::new("Sign In")
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted)
|
||||
.into_any_element()
|
||||
} else if is_signing_in {
|
||||
Label::new("Signing In…")
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted)
|
||||
.with_animation(
|
||||
"signing-in",
|
||||
Animation::new(Duration::from_secs(2))
|
||||
.repeat()
|
||||
.with_easing(pulsating_between(0.4, 0.8)),
|
||||
|label, delta| label.alpha(delta),
|
||||
)
|
||||
.into_any_element()
|
||||
} else if is_signed_in && is_free {
|
||||
Label::new("Start Free Trial")
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted)
|
||||
.into_any_element()
|
||||
} else {
|
||||
Icon::new(IconName::Check)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Success)
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
AgentSetupButton::new("zed-agent-onboarding")
|
||||
.icon(
|
||||
Icon::new(IconName::ZedAgent)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.name("Zed Agent")
|
||||
.state(state_element)
|
||||
.disabled(is_trial || is_pro)
|
||||
.map(|this| {
|
||||
if is_signed_in && is_free {
|
||||
this.on_click(move |_, _window, cx| {
|
||||
telemetry::event!("Start Trial Clicked", state = "post-sign-in");
|
||||
cx.open_url(&zed_urls::start_trial_url(cx))
|
||||
})
|
||||
} else {
|
||||
this.on_click(move |_, _, cx| {
|
||||
telemetry::event!("Welcome Zed Agent Sign In Clicked");
|
||||
let client = Client::global(cx);
|
||||
cx.spawn(async move |cx| client.sign_in_with_optional_connect(true, cx).await)
|
||||
.detach_and_log_err(cx);
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn render_ai_section(user_store: &Entity<UserStore>, cx: &mut App) -> impl IntoElement {
|
||||
let registry_agents = AgentRegistryStore::try_global(cx)
|
||||
.map(|store| store.read(cx).agents().to_vec())
|
||||
.unwrap_or_default();
|
||||
|
||||
let installed_agents = cx
|
||||
.global::<SettingsStore>()
|
||||
.get::<AllAgentServersSettings>(None)
|
||||
.clone();
|
||||
|
||||
let column_count = 1 + FEATURED_AGENT_IDS.len() as u16;
|
||||
|
||||
let grid = FEATURED_AGENT_IDS.iter().fold(
|
||||
div()
|
||||
.w_full()
|
||||
.mt_1p5()
|
||||
.grid()
|
||||
.grid_cols(column_count)
|
||||
.gap_2()
|
||||
.child(render_zed_agent_button(user_store, cx)),
|
||||
|grid, agent_id| {
|
||||
let Some(agent) = registry_agents
|
||||
.iter()
|
||||
.find(|a| a.id().as_ref() == *agent_id)
|
||||
else {
|
||||
return grid;
|
||||
};
|
||||
let is_installed = installed_agents.contains_key(*agent_id);
|
||||
grid.child(render_registry_agent_button(agent, is_installed, cx))
|
||||
},
|
||||
);
|
||||
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.child(Label::new("Agent Setup"))
|
||||
.child(
|
||||
Label::new("Install your favorite agents and start your first thread.")
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(grid)
|
||||
}
|
||||
|
||||
pub(crate) fn render_basics_page(user_store: &Entity<UserStore>, 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_ai_section(user_store, cx))
|
||||
.child(render_import_settings_section(&mut tab_index, cx))
|
||||
.child(render_vim_mode_switch(&mut tab_index, cx))
|
||||
.child(render_worktree_auto_trust_switch(&mut tab_index, cx))
|
||||
.child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
|
||||
.child(render_telemetry_section(&mut tab_index, cx))
|
||||
}
|
||||
187
crates/onboarding/src/multibuffer_hint.rs
Normal file
187
crates/onboarding/src/multibuffer_hint.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use db::kvp::KeyValueStore;
|
||||
use gpui::{App, EntityId, EventEmitter, Subscription};
|
||||
use ui::{IconButtonShape, Tooltip, prelude::*};
|
||||
use workspace::item::{ItemBufferKind, ItemEvent, ItemHandle};
|
||||
use workspace::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView};
|
||||
|
||||
pub struct MultibufferHint {
|
||||
shown_on: HashSet<EntityId>,
|
||||
active_item: Option<Box<dyn ItemHandle>>,
|
||||
subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
const NUMBER_OF_HINTS: usize = 10;
|
||||
|
||||
const SHOWN_COUNT_KEY: &str = "MULTIBUFFER_HINT_SHOWN_COUNT";
|
||||
|
||||
impl Default for MultibufferHint {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MultibufferHint {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
shown_on: Default::default(),
|
||||
active_item: None,
|
||||
subscription: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MultibufferHint {
|
||||
fn counter(cx: &App) -> &'static AtomicUsize {
|
||||
static SHOWN_COUNT: OnceLock<AtomicUsize> = OnceLock::new();
|
||||
SHOWN_COUNT.get_or_init(|| {
|
||||
let value: usize = KeyValueStore::global(cx)
|
||||
.read_kvp(SHOWN_COUNT_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
AtomicUsize::new(value)
|
||||
})
|
||||
}
|
||||
|
||||
fn shown_count(cx: &App) -> usize {
|
||||
Self::counter(cx).load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn increment_count(cx: &mut App) {
|
||||
Self::set_count(Self::shown_count(cx) + 1, cx)
|
||||
}
|
||||
|
||||
pub(crate) fn set_count(count: usize, cx: &mut App) {
|
||||
Self::counter(cx).store(count, Ordering::Relaxed);
|
||||
|
||||
let kvp = KeyValueStore::global(cx);
|
||||
db::write_and_log(cx, move || async move {
|
||||
kvp.write_kvp(SHOWN_COUNT_KEY.to_string(), format!("{}", count))
|
||||
.await
|
||||
});
|
||||
}
|
||||
|
||||
fn dismiss(&mut self, cx: &mut App) {
|
||||
Self::set_count(NUMBER_OF_HINTS, cx)
|
||||
}
|
||||
|
||||
/// Determines the toolbar location for this [`MultibufferHint`].
|
||||
fn determine_toolbar_location(&mut self, cx: &mut Context<Self>) -> ToolbarItemLocation {
|
||||
if Self::shown_count(cx) >= NUMBER_OF_HINTS {
|
||||
return ToolbarItemLocation::Hidden;
|
||||
}
|
||||
|
||||
let Some(active_pane_item) = self.active_item.as_ref() else {
|
||||
return ToolbarItemLocation::Hidden;
|
||||
};
|
||||
|
||||
if active_pane_item.buffer_kind(cx) == ItemBufferKind::Singleton
|
||||
|| active_pane_item.breadcrumbs(cx).is_none()
|
||||
|| !active_pane_item.can_save(cx)
|
||||
{
|
||||
return ToolbarItemLocation::Hidden;
|
||||
}
|
||||
|
||||
if self.shown_on.insert(active_pane_item.item_id()) {
|
||||
Self::increment_count(cx);
|
||||
}
|
||||
|
||||
ToolbarItemLocation::Secondary
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ToolbarItemEvent> for MultibufferHint {}
|
||||
|
||||
impl ToolbarItemView for MultibufferHint {
|
||||
fn set_active_pane_item(
|
||||
&mut self,
|
||||
active_pane_item: Option<&dyn ItemHandle>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> ToolbarItemLocation {
|
||||
cx.notify();
|
||||
self.active_item = active_pane_item.map(|item| item.boxed_clone());
|
||||
|
||||
let Some(active_pane_item) = active_pane_item else {
|
||||
return ToolbarItemLocation::Hidden;
|
||||
};
|
||||
|
||||
let this = cx.entity().downgrade();
|
||||
self.subscription = Some(active_pane_item.subscribe_to_item_events(
|
||||
window,
|
||||
cx,
|
||||
Box::new(move |event, _, cx| {
|
||||
if let ItemEvent::UpdateBreadcrumbs = event {
|
||||
this.update(cx, |this, cx| {
|
||||
cx.notify();
|
||||
let location = this.determine_toolbar_location(cx);
|
||||
cx.emit(ToolbarItemEvent::ChangeLocation(location))
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
));
|
||||
|
||||
self.determine_toolbar_location(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for MultibufferHint {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.px_2()
|
||||
.py_0p5()
|
||||
.justify_between()
|
||||
.bg(cx.theme().status().info_background.opacity(0.5))
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.rounded_sm()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Icon::new(IconName::Info)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(Label::new(
|
||||
"Edit and save files directly in the results multibuffer!",
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("open_docs", "Learn More")
|
||||
.end_icon(
|
||||
Icon::new(IconName::ArrowUpRight)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.on_click(move |_event, _, cx| {
|
||||
cx.open_url("https://zed.dev/docs/multibuffers")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("dismiss", IconName::Close)
|
||||
.shape(IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.on_click(cx.listener(|this, _event, _, cx| {
|
||||
this.dismiss(cx);
|
||||
cx.emit(ToolbarItemEvent::ChangeLocation(
|
||||
ToolbarItemLocation::Hidden,
|
||||
))
|
||||
}))
|
||||
.tooltip(Tooltip::text("Dismiss Hint")),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
716
crates/onboarding/src/onboarding.rs
Normal file
716
crates/onboarding/src/onboarding.rs
Normal file
@@ -0,0 +1,716 @@
|
||||
use crate::multibuffer_hint::MultibufferHint;
|
||||
use client::{Client, UserStore, zed_urls};
|
||||
use cloud_api_types::Plan;
|
||||
use db::kvp::KeyValueStore;
|
||||
use fs::Fs;
|
||||
use gpui::{
|
||||
Action, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, Global, IntoElement, KeyContext, Render, ScrollHandle, SharedString,
|
||||
Subscription, Task, WeakEntity, Window, actions,
|
||||
};
|
||||
use notifications::status_toast::StatusToast;
|
||||
use project::agent_server_store::AllAgentServersSettings;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use settings::{SettingsStore, VsCodeSettingsSource};
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
Divider, KeyBinding, ParentElement as _, StatefulInteractiveElement, Vector, VectorName,
|
||||
WithScrollbar as _, prelude::*, rems_from_px,
|
||||
};
|
||||
|
||||
pub use workspace::welcome::ShowWelcome;
|
||||
use workspace::welcome::WelcomePage;
|
||||
use workspace::{
|
||||
AppState, Workspace, WorkspaceId,
|
||||
dock::DockPosition,
|
||||
item::{Item, ItemEvent},
|
||||
notifications::NotifyResultExt as _,
|
||||
open_new, register_serializable_item, with_active_or_new_workspace,
|
||||
};
|
||||
use zed_actions::OpenOnboarding;
|
||||
|
||||
mod base_keymap_picker;
|
||||
mod basics_page;
|
||||
pub mod multibuffer_hint;
|
||||
mod theme_preview;
|
||||
|
||||
/// Imports settings from Visual Studio Code.
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = zed)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ImportVsCodeSettings {
|
||||
#[serde(default)]
|
||||
pub skip_prompt: bool,
|
||||
}
|
||||
|
||||
/// Imports settings from Cursor editor.
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = zed)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ImportCursorSettings {
|
||||
#[serde(default)]
|
||||
pub skip_prompt: bool,
|
||||
}
|
||||
|
||||
pub const FIRST_OPEN: &str = "first_open";
|
||||
pub const DOCS_URL: &str = "https://zed.dev/docs/";
|
||||
|
||||
actions!(
|
||||
onboarding,
|
||||
[
|
||||
/// Finish the onboarding process.
|
||||
Finish,
|
||||
/// Sign in while in the onboarding flow.
|
||||
SignIn,
|
||||
/// Open the user account in zed.dev while in the onboarding flow.
|
||||
OpenAccount,
|
||||
/// Resets the welcome screen hints to their initial state.
|
||||
ResetHints
|
||||
]
|
||||
);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
cx.observe_new(|workspace: &mut Workspace, _, _cx| {
|
||||
workspace
|
||||
.register_action(|_workspace, _: &ResetHints, _, cx| MultibufferHint::set_count(0, cx));
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.on_action(|_: &OpenOnboarding, cx| {
|
||||
with_active_or_new_workspace(cx, |workspace, window, cx| {
|
||||
workspace
|
||||
.with_local_workspace(window, cx, |workspace, window, cx| {
|
||||
let existing = workspace
|
||||
.active_pane()
|
||||
.read(cx)
|
||||
.items()
|
||||
.find_map(|item| item.downcast::<Onboarding>());
|
||||
|
||||
if let Some(existing) = existing {
|
||||
workspace.activate_item(&existing, true, true, window, cx);
|
||||
} else {
|
||||
let settings_page = Onboarding::new(workspace, cx);
|
||||
workspace.add_item_to_active_pane(
|
||||
Box::new(settings_page),
|
||||
None,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
});
|
||||
|
||||
cx.on_action(|_: &ShowWelcome, cx| {
|
||||
with_active_or_new_workspace(cx, |workspace, window, cx| {
|
||||
workspace
|
||||
.with_local_workspace(window, cx, |workspace, window, cx| {
|
||||
let existing = workspace
|
||||
.active_pane()
|
||||
.read(cx)
|
||||
.items()
|
||||
.find_map(|item| item.downcast::<WelcomePage>());
|
||||
|
||||
if let Some(existing) = existing {
|
||||
workspace.activate_item(&existing, true, true, window, cx);
|
||||
} else {
|
||||
let settings_page = cx
|
||||
.new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx));
|
||||
workspace.add_item_to_active_pane(
|
||||
Box::new(settings_page),
|
||||
None,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
});
|
||||
|
||||
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
|
||||
workspace.register_action(|_workspace, action: &ImportVsCodeSettings, window, cx| {
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
let action = *action;
|
||||
|
||||
let workspace = cx.weak_entity();
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx: &mut AsyncWindowContext| {
|
||||
handle_import_vscode_settings(
|
||||
workspace,
|
||||
VsCodeSettingsSource::VsCode,
|
||||
action.skip_prompt,
|
||||
fs,
|
||||
cx,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
workspace.register_action(|_workspace, action: &ImportCursorSettings, window, cx| {
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
let action = *action;
|
||||
|
||||
let workspace = cx.weak_entity();
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx: &mut AsyncWindowContext| {
|
||||
handle_import_vscode_settings(
|
||||
workspace,
|
||||
VsCodeSettingsSource::Cursor,
|
||||
action.skip_prompt,
|
||||
fs,
|
||||
cx,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
|
||||
base_keymap_picker::init(cx);
|
||||
|
||||
register_serializable_item::<Onboarding>(cx);
|
||||
register_serializable_item::<WelcomePage>(cx);
|
||||
}
|
||||
|
||||
pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyhow::Result<()>> {
|
||||
telemetry::event!("Onboarding Page Opened");
|
||||
open_new(
|
||||
Default::default(),
|
||||
app_state,
|
||||
cx,
|
||||
|workspace, window, cx| {
|
||||
{
|
||||
workspace.toggle_dock(DockPosition::Left, window, cx);
|
||||
let onboarding_page = Onboarding::new(workspace, cx);
|
||||
workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
|
||||
|
||||
window.focus(&onboarding_page.focus_handle(cx), cx);
|
||||
|
||||
cx.notify();
|
||||
};
|
||||
let kvp = KeyValueStore::global(cx);
|
||||
db::write_and_log(cx, move || async move {
|
||||
kvp.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
|
||||
.await
|
||||
});
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
struct Onboarding {
|
||||
workspace: WeakEntity<Workspace>,
|
||||
focus_handle: FocusHandle,
|
||||
user_store: Entity<UserStore>,
|
||||
scroll_handle: ScrollHandle,
|
||||
_settings_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl Onboarding {
|
||||
fn new(workspace: &Workspace, cx: &mut App) -> Entity<Self> {
|
||||
let font_family_cache = theme::FontFamilyCache::global(cx);
|
||||
|
||||
let installed_agents = cx
|
||||
.global::<SettingsStore>()
|
||||
.get::<AllAgentServersSettings>(None)
|
||||
.clone();
|
||||
let client = Client::global(cx);
|
||||
let status = *client.status().borrow();
|
||||
let plan = workspace.user_store().read(cx).plan();
|
||||
let zed_agent_state = if status.is_signed_out()
|
||||
|| matches!(
|
||||
status,
|
||||
client::Status::AuthenticationError | client::Status::ConnectionError
|
||||
) {
|
||||
"signed_out"
|
||||
} else if status.is_signing_in() {
|
||||
"signing_in"
|
||||
} else {
|
||||
match plan {
|
||||
Some(Plan::ZedPro) => "pro",
|
||||
Some(Plan::ZedProTrial) => "trial",
|
||||
Some(Plan::ZedBusiness) => "business",
|
||||
Some(Plan::ZedStudent) => "student",
|
||||
Some(Plan::ZedFree) | None => "free",
|
||||
}
|
||||
};
|
||||
let agents_installed = basics_page::FEATURED_AGENT_IDS
|
||||
.iter()
|
||||
.filter(|id| installed_agents.contains_key(**id))
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
telemetry::event!(
|
||||
"Welcome Agent Setup Viewed",
|
||||
zed_agent = zed_agent_state,
|
||||
agents_installed = agents_installed,
|
||||
);
|
||||
|
||||
cx.new(|cx| {
|
||||
cx.spawn(async move |this, cx| {
|
||||
font_family_cache.prefetch(cx).await;
|
||||
this.update(cx, |_, cx| {
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
workspace: workspace.weak_handle(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
scroll_handle: ScrollHandle::new(),
|
||||
user_store: workspace.user_store().clone(),
|
||||
_settings_subscription: cx
|
||||
.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) {
|
||||
telemetry::event!("Finish Setup");
|
||||
go_to_welcome_page(cx);
|
||||
}
|
||||
|
||||
fn handle_sign_in(&mut self, _: &SignIn, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let client = Client::global(cx);
|
||||
let workspace = self.workspace.clone();
|
||||
|
||||
window
|
||||
.spawn(cx, async move |mut cx| {
|
||||
client
|
||||
.sign_in_with_optional_connect(true, &cx)
|
||||
.await
|
||||
.notify_workspace_async_err(workspace, &mut cx);
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn handle_open_account(_: &OpenAccount, _: &mut Window, cx: &mut App) {
|
||||
cx.open_url(&zed_urls::account_url(cx))
|
||||
}
|
||||
|
||||
fn render_page(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||
crate::basics_page::render_basics_page(&self.user_store, cx).into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Onboarding {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.image_cache(gpui::retain_all("onboarding-page"))
|
||||
.key_context({
|
||||
let mut ctx = KeyContext::new_with_defaults();
|
||||
ctx.add("Onboarding");
|
||||
ctx.add("menu");
|
||||
ctx
|
||||
})
|
||||
.track_focus(&self.focus_handle)
|
||||
.size_full()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.on_action(Self::on_finish)
|
||||
.on_action(cx.listener(Self::handle_sign_in))
|
||||
.on_action(Self::handle_open_account)
|
||||
.on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| {
|
||||
window.focus_next(cx);
|
||||
cx.notify();
|
||||
}))
|
||||
.on_action(cx.listener(|_, _: &menu::SelectPrevious, window, cx| {
|
||||
window.focus_prev(cx);
|
||||
cx.notify();
|
||||
}))
|
||||
.vertical_scrollbar_for(&self.scroll_handle, window, cx)
|
||||
.child(
|
||||
div()
|
||||
.id("page-content")
|
||||
.size_full()
|
||||
.overflow_y_scroll()
|
||||
.child(
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.max_w(rems_from_px(780.))
|
||||
.w_full()
|
||||
.mx_auto()
|
||||
.p_12()
|
||||
.gap_6()
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_4()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.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({
|
||||
Button::new("finish_setup", "Finish Setup")
|
||||
.style(ButtonStyle::Filled)
|
||||
.size(ButtonSize::Medium)
|
||||
.width(rems_from_px(200.))
|
||||
.key_binding(KeyBinding::for_action_in(
|
||||
&Finish,
|
||||
&self.focus_handle,
|
||||
cx,
|
||||
))
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Finish.boxed_clone(), cx);
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
|
||||
.child(self.render_page(cx)),
|
||||
)
|
||||
.track_scroll(&self.scroll_handle),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ItemEvent> for Onboarding {}
|
||||
|
||||
impl Focusable for Onboarding {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Item for Onboarding {
|
||||
type Event = ItemEvent;
|
||||
|
||||
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
|
||||
"Onboarding".into()
|
||||
}
|
||||
|
||||
fn telemetry_event_text(&self) -> Option<&'static str> {
|
||||
Some("Onboarding Page Opened")
|
||||
}
|
||||
|
||||
fn show_toolbar(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn can_split(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn clone_on_split(
|
||||
&self,
|
||||
_workspace_id: Option<WorkspaceId>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Option<Entity<Self>>> {
|
||||
Task::ready(Some(cx.new(|cx| Onboarding {
|
||||
workspace: self.workspace.clone(),
|
||||
user_store: self.user_store.clone(),
|
||||
scroll_handle: ScrollHandle::new(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
_settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
|
||||
})))
|
||||
}
|
||||
|
||||
fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
|
||||
f(*event)
|
||||
}
|
||||
}
|
||||
|
||||
fn go_to_welcome_page(cx: &mut App) {
|
||||
with_active_or_new_workspace(cx, |workspace, window, cx| {
|
||||
let Some((onboarding_id, onboarding_idx)) = workspace
|
||||
.active_pane()
|
||||
.read(cx)
|
||||
.items()
|
||||
.enumerate()
|
||||
.find_map(|(idx, item)| {
|
||||
let _ = item.downcast::<Onboarding>()?;
|
||||
Some((item.item_id(), idx))
|
||||
})
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
workspace.active_pane().update(cx, |pane, cx| {
|
||||
// Get the index here to get around the borrow checker
|
||||
let idx = pane.items().enumerate().find_map(|(idx, item)| {
|
||||
let _ = item.downcast::<WelcomePage>()?;
|
||||
Some(idx)
|
||||
});
|
||||
|
||||
if let Some(idx) = idx {
|
||||
pane.activate_item(idx, true, true, window, cx);
|
||||
} else {
|
||||
let item = Box::new(
|
||||
cx.new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx)),
|
||||
);
|
||||
pane.add_item(item, true, true, Some(onboarding_idx), window, cx);
|
||||
}
|
||||
|
||||
pane.remove_item(onboarding_id, false, false, window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn handle_import_vscode_settings(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
source: VsCodeSettingsSource,
|
||||
skip_prompt: bool,
|
||||
fs: Arc<dyn Fs>,
|
||||
cx: &mut AsyncWindowContext,
|
||||
) {
|
||||
use util::truncate_and_remove_front;
|
||||
|
||||
let vscode_settings =
|
||||
match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
|
||||
Ok(vscode_settings) => vscode_settings,
|
||||
Err(err) => {
|
||||
zlog::error!("{err:?}");
|
||||
let _ = cx.prompt(
|
||||
gpui::PromptLevel::Info,
|
||||
&format!("Could not find or load a {source} settings file"),
|
||||
None,
|
||||
&["Ok"],
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !skip_prompt {
|
||||
let prompt = cx.prompt(
|
||||
gpui::PromptLevel::Warning,
|
||||
&format!(
|
||||
"Importing {} settings may overwrite your existing settings. \
|
||||
Will import settings from {}",
|
||||
vscode_settings.source,
|
||||
truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
|
||||
),
|
||||
None,
|
||||
&["Ok", "Cancel"],
|
||||
);
|
||||
let result = cx.spawn(async move |_| prompt.await.ok()).await;
|
||||
if result != Some(0) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Ok(result_channel) = cx.update(|_, cx| {
|
||||
let source = vscode_settings.source;
|
||||
let path = vscode_settings.path.clone();
|
||||
let result_channel = cx
|
||||
.global::<SettingsStore>()
|
||||
.import_vscode_settings(fs, vscode_settings);
|
||||
zlog::info!("Imported {source} settings from {}", path.display());
|
||||
result_channel
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let result = result_channel.await;
|
||||
workspace
|
||||
.update_in(cx, |workspace, _, cx| match result {
|
||||
Ok(_) => {
|
||||
let confirmation_toast = StatusToast::new(
|
||||
format!("Your {} settings were successfully imported.", source),
|
||||
cx,
|
||||
|this, _| {
|
||||
this.icon(
|
||||
Icon::new(IconName::Check)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Success),
|
||||
)
|
||||
.dismiss_button(true)
|
||||
},
|
||||
);
|
||||
SettingsImportState::update(cx, |state, _| match source {
|
||||
VsCodeSettingsSource::VsCode => {
|
||||
state.vscode = true;
|
||||
}
|
||||
VsCodeSettingsSource::Cursor => {
|
||||
state.cursor = true;
|
||||
}
|
||||
});
|
||||
workspace.toggle_status_toast(confirmation_toast, cx);
|
||||
}
|
||||
Err(_) => {
|
||||
let error_toast = StatusToast::new(
|
||||
"Failed to import settings. See log for details",
|
||||
cx,
|
||||
|this, _| {
|
||||
this.icon(
|
||||
Icon::new(IconName::Close)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Error),
|
||||
)
|
||||
.action("Open Log", |window, cx| {
|
||||
window.dispatch_action(workspace::OpenLog.boxed_clone(), cx)
|
||||
})
|
||||
.dismiss_button(true)
|
||||
},
|
||||
);
|
||||
workspace.toggle_status_toast(error_toast, cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[derive(Default, Copy, Clone)]
|
||||
pub struct SettingsImportState {
|
||||
pub cursor: bool,
|
||||
pub vscode: bool,
|
||||
}
|
||||
|
||||
impl Global for SettingsImportState {}
|
||||
|
||||
impl SettingsImportState {
|
||||
pub fn global(cx: &App) -> Self {
|
||||
cx.try_global().cloned().unwrap_or_default()
|
||||
}
|
||||
pub fn update<R>(cx: &mut App, f: impl FnOnce(&mut Self, &mut App) -> R) -> R {
|
||||
cx.update_default_global(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl workspace::SerializableItem for Onboarding {
|
||||
fn serialized_item_kind() -> &'static str {
|
||||
"OnboardingPage"
|
||||
}
|
||||
|
||||
fn cleanup(
|
||||
workspace_id: workspace::WorkspaceId,
|
||||
alive_items: Vec<workspace::ItemId>,
|
||||
_window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> gpui::Task<gpui::Result<()>> {
|
||||
workspace::delete_unloaded_items(
|
||||
alive_items,
|
||||
workspace_id,
|
||||
"onboarding_pages",
|
||||
&persistence::OnboardingPagesDb::global(cx),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
fn deserialize(
|
||||
_project: Entity<project::Project>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
workspace_id: workspace::WorkspaceId,
|
||||
item_id: workspace::ItemId,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> gpui::Task<gpui::Result<Entity<Self>>> {
|
||||
let db = persistence::OnboardingPagesDb::global(cx);
|
||||
window.spawn(cx, async move |cx| {
|
||||
if let Some(_) = db.get_onboarding_page(item_id, workspace_id)? {
|
||||
workspace.update(cx, |workspace, cx| Onboarding::new(workspace, cx))
|
||||
} else {
|
||||
Err(anyhow::anyhow!("No onboarding page to deserialize"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn serialize(
|
||||
&mut self,
|
||||
workspace: &mut Workspace,
|
||||
item_id: workspace::ItemId,
|
||||
_closing: bool,
|
||||
_window: &mut Window,
|
||||
cx: &mut ui::Context<Self>,
|
||||
) -> Option<gpui::Task<gpui::Result<()>>> {
|
||||
let workspace_id = workspace.database_id()?;
|
||||
|
||||
let db = persistence::OnboardingPagesDb::global(cx);
|
||||
Some(
|
||||
cx.background_spawn(
|
||||
async move { db.save_onboarding_page(item_id, workspace_id).await },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn should_serialize(&self, event: &Self::Event) -> bool {
|
||||
event == &ItemEvent::UpdateTab
|
||||
}
|
||||
}
|
||||
|
||||
mod persistence {
|
||||
use db::{
|
||||
query,
|
||||
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
|
||||
sqlez_macros::sql,
|
||||
};
|
||||
use workspace::WorkspaceDb;
|
||||
|
||||
pub struct OnboardingPagesDb(ThreadSafeConnection);
|
||||
|
||||
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,
|
||||
|
||||
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!(OnboardingPagesDb, [WorkspaceDb]);
|
||||
|
||||
impl OnboardingPagesDb {
|
||||
query! {
|
||||
pub async fn save_onboarding_page(
|
||||
item_id: workspace::ItemId,
|
||||
workspace_id: workspace::WorkspaceId
|
||||
) -> Result<()> {
|
||||
INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id)
|
||||
VALUES (?, ?)
|
||||
}
|
||||
}
|
||||
|
||||
query! {
|
||||
pub fn get_onboarding_page(
|
||||
item_id: workspace::ItemId,
|
||||
workspace_id: workspace::WorkspaceId
|
||||
) -> Result<Option<workspace::ItemId>> {
|
||||
SELECT item_id
|
||||
FROM onboarding_pages
|
||||
WHERE item_id = ? AND workspace_id = ?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
381
crates/onboarding/src/theme_preview.rs
Normal file
381
crates/onboarding/src/theme_preview.rs
Normal file
@@ -0,0 +1,381 @@
|
||||
#![allow(unused, dead_code)]
|
||||
use gpui::{Hsla, Length};
|
||||
use std::{
|
||||
cell::LazyCell,
|
||||
sync::{Arc, LazyLock, OnceLock},
|
||||
};
|
||||
use theme::{Theme, ThemeColors, ThemeRegistry};
|
||||
use ui::{
|
||||
IntoElement, RenderOnce, component_prelude::Documented, prelude::*, utils::inner_corner_radius,
|
||||
};
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum ThemePreviewStyle {
|
||||
Bordered,
|
||||
Borderless,
|
||||
SideBySide(Arc<Theme>),
|
||||
}
|
||||
|
||||
/// Shows a preview of a theme as an abstract illustration
|
||||
/// of a thumbnail-sized editor.
|
||||
#[derive(IntoElement, RegisterComponent, Documented)]
|
||||
pub struct ThemePreviewTile {
|
||||
theme: Arc<Theme>,
|
||||
seed: f32,
|
||||
style: ThemePreviewStyle,
|
||||
}
|
||||
|
||||
static CHILD_RADIUS: LazyLock<Pixels> = LazyLock::new(|| {
|
||||
inner_corner_radius(
|
||||
ThemePreviewTile::ROOT_RADIUS,
|
||||
ThemePreviewTile::ROOT_BORDER,
|
||||
ThemePreviewTile::ROOT_PADDING,
|
||||
ThemePreviewTile::CHILD_BORDER,
|
||||
)
|
||||
});
|
||||
|
||||
impl ThemePreviewTile {
|
||||
pub const SKELETON_HEIGHT_DEFAULT: Pixels = px(2.);
|
||||
pub const SIDEBAR_SKELETON_ITEM_COUNT: usize = 8;
|
||||
pub const SIDEBAR_WIDTH_DEFAULT: DefiniteLength = relative(0.25);
|
||||
pub const ROOT_RADIUS: Pixels = px(8.0);
|
||||
pub const ROOT_BORDER: Pixels = px(2.0);
|
||||
pub const ROOT_PADDING: Pixels = px(2.0);
|
||||
pub const CHILD_BORDER: Pixels = px(1.0);
|
||||
|
||||
pub fn new(theme: Arc<Theme>, seed: f32) -> Self {
|
||||
Self {
|
||||
theme,
|
||||
seed,
|
||||
style: ThemePreviewStyle::Bordered,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn style(mut self, style: ThemePreviewStyle) -> Self {
|
||||
self.style = style;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn item_skeleton(w: Length, h: Length, bg: Hsla) -> impl IntoElement {
|
||||
div().w(w).h(h).rounded_full().bg(bg)
|
||||
}
|
||||
|
||||
pub fn render_sidebar_skeleton_items(
|
||||
seed: f32,
|
||||
colors: &ThemeColors,
|
||||
skeleton_height: impl Into<Length> + Clone,
|
||||
) -> [impl IntoElement; Self::SIDEBAR_SKELETON_ITEM_COUNT] {
|
||||
let skeleton_height = skeleton_height.into();
|
||||
std::array::from_fn(|index| {
|
||||
let width = {
|
||||
let value = (seed * 1000.0 + index as f32 * 10.0).sin() * 0.5 + 0.5;
|
||||
0.5 + value * 0.45
|
||||
};
|
||||
Self::item_skeleton(
|
||||
relative(width).into(),
|
||||
skeleton_height,
|
||||
colors.text.alpha(0.45),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn render_pseudo_code_skeleton(
|
||||
seed: f32,
|
||||
theme: Arc<Theme>,
|
||||
skeleton_height: impl Into<Length>,
|
||||
) -> impl IntoElement {
|
||||
let colors = theme.colors();
|
||||
let syntax = theme.syntax();
|
||||
|
||||
let keyword_color = syntax.style_for_name("keyword").and_then(|s| s.color);
|
||||
let function_color = syntax.style_for_name("function").and_then(|s| s.color);
|
||||
let string_color = syntax.style_for_name("string").and_then(|s| s.color);
|
||||
let comment_color = syntax.style_for_name("comment").and_then(|s| s.color);
|
||||
let variable_color = syntax.style_for_name("variable").and_then(|s| s.color);
|
||||
let type_color = syntax.style_for_name("type").and_then(|s| s.color);
|
||||
let punctuation_color = syntax.style_for_name("punctuation").and_then(|s| s.color);
|
||||
|
||||
let syntax_colors = [
|
||||
keyword_color,
|
||||
function_color,
|
||||
string_color,
|
||||
variable_color,
|
||||
type_color,
|
||||
punctuation_color,
|
||||
comment_color,
|
||||
];
|
||||
|
||||
let skeleton_height = skeleton_height.into();
|
||||
|
||||
let line_width = |line_idx: usize, block_idx: usize| -> f32 {
|
||||
let val =
|
||||
(seed * 100.0 + line_idx as f32 * 20.0 + block_idx as f32 * 5.0).sin() * 0.5 + 0.5;
|
||||
0.05 + val * 0.2
|
||||
};
|
||||
|
||||
let indentation = |line_idx: usize| -> f32 {
|
||||
let step = line_idx % 6;
|
||||
if step < 3 {
|
||||
step as f32 * 0.1
|
||||
} else {
|
||||
(5 - step) as f32 * 0.1
|
||||
}
|
||||
};
|
||||
|
||||
let pick_color = |line_idx: usize, block_idx: usize| -> Hsla {
|
||||
let idx = ((seed * 10.0 + line_idx as f32 * 7.0 + block_idx as f32 * 3.0).sin() * 3.5)
|
||||
.abs() as usize
|
||||
% syntax_colors.len();
|
||||
syntax_colors[idx].unwrap_or(colors.text)
|
||||
};
|
||||
|
||||
let line_count = 10;
|
||||
|
||||
let lines = (0..line_count)
|
||||
.map(|line_idx| {
|
||||
let block_count = (((seed * 30.0 + line_idx as f32 * 12.0).sin() * 0.5 + 0.5) * 3.0)
|
||||
.round() as usize
|
||||
+ 2;
|
||||
|
||||
let indent = indentation(line_idx);
|
||||
|
||||
let blocks = (0..block_count)
|
||||
.map(|block_idx| {
|
||||
let width = line_width(line_idx, block_idx);
|
||||
let color = pick_color(line_idx, block_idx);
|
||||
Self::item_skeleton(relative(width).into(), skeleton_height, color)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
h_flex().gap_0p5().ml(relative(indent)).children(blocks)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
v_flex().size_full().p_1().gap_1p5().children(lines)
|
||||
}
|
||||
|
||||
pub fn render_sidebar(
|
||||
seed: f32,
|
||||
colors: &ThemeColors,
|
||||
width: impl Into<Length> + Clone,
|
||||
skeleton_height: impl Into<Length>,
|
||||
) -> impl IntoElement {
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w(width)
|
||||
.p_2()
|
||||
.gap_1()
|
||||
.bg(colors.panel_background)
|
||||
.children(Self::render_sidebar_skeleton_items(
|
||||
seed,
|
||||
colors,
|
||||
skeleton_height.into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn render_pane(
|
||||
seed: f32,
|
||||
theme: Arc<Theme>,
|
||||
skeleton_height: impl Into<Length>,
|
||||
) -> impl IntoElement {
|
||||
div()
|
||||
.p_2()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.bg(theme.colors().editor_background)
|
||||
.child(Self::render_pseudo_code_skeleton(
|
||||
seed,
|
||||
theme,
|
||||
skeleton_height.into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn render_editor(
|
||||
seed: f32,
|
||||
theme: Arc<Theme>,
|
||||
sidebar_width: impl Into<Length> + Clone,
|
||||
skeleton_height: impl Into<Length> + Clone,
|
||||
) -> impl IntoElement {
|
||||
div()
|
||||
.flex()
|
||||
.size_full()
|
||||
.bg(theme.colors().background.alpha(1.00))
|
||||
.child(Self::render_sidebar(
|
||||
seed,
|
||||
theme.colors(),
|
||||
sidebar_width,
|
||||
skeleton_height.clone(),
|
||||
))
|
||||
.child(Self::render_pane(seed, theme, skeleton_height))
|
||||
}
|
||||
|
||||
fn render_borderless(seed: f32, theme: Arc<Theme>) -> impl IntoElement {
|
||||
Self::render_editor(
|
||||
seed,
|
||||
theme,
|
||||
Self::SIDEBAR_WIDTH_DEFAULT,
|
||||
Self::SKELETON_HEIGHT_DEFAULT,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_border(seed: f32, theme: Arc<Theme>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.p(Self::ROOT_PADDING)
|
||||
.rounded(Self::ROOT_RADIUS)
|
||||
.child(
|
||||
div()
|
||||
.size_full()
|
||||
.rounded(*CHILD_RADIUS)
|
||||
.border(Self::CHILD_BORDER)
|
||||
.border_color(theme.colors().border)
|
||||
.child(Self::render_editor(
|
||||
seed,
|
||||
theme.clone(),
|
||||
Self::SIDEBAR_WIDTH_DEFAULT,
|
||||
Self::SKELETON_HEIGHT_DEFAULT,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_side_by_side(
|
||||
seed: f32,
|
||||
theme: Arc<Theme>,
|
||||
other_theme: Arc<Theme>,
|
||||
border_color: Hsla,
|
||||
) -> impl IntoElement {
|
||||
let sidebar_width = relative(0.20);
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.p(Self::ROOT_PADDING)
|
||||
.rounded(Self::ROOT_RADIUS)
|
||||
.child(
|
||||
h_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.rounded(*CHILD_RADIUS)
|
||||
.border(Self::CHILD_BORDER)
|
||||
.border_color(border_color)
|
||||
.overflow_hidden()
|
||||
.child(div().size_full().child(Self::render_editor(
|
||||
seed,
|
||||
theme,
|
||||
sidebar_width,
|
||||
Self::SKELETON_HEIGHT_DEFAULT,
|
||||
)))
|
||||
.child(
|
||||
div()
|
||||
.size_full()
|
||||
.absolute()
|
||||
.left_1_2()
|
||||
.bg(other_theme.colors().editor_background)
|
||||
.child(Self::render_editor(
|
||||
seed,
|
||||
other_theme,
|
||||
sidebar_width,
|
||||
Self::SKELETON_HEIGHT_DEFAULT,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ThemePreviewTile {
|
||||
fn render(self, _window: &mut ui::Window, _cx: &mut ui::App) -> impl IntoElement {
|
||||
match self.style {
|
||||
ThemePreviewStyle::Bordered => {
|
||||
Self::render_border(self.seed, self.theme).into_any_element()
|
||||
}
|
||||
ThemePreviewStyle::Borderless => {
|
||||
Self::render_borderless(self.seed, self.theme).into_any_element()
|
||||
}
|
||||
ThemePreviewStyle::SideBySide(other_theme) => Self::render_side_by_side(
|
||||
self.seed,
|
||||
self.theme,
|
||||
other_theme,
|
||||
_cx.theme().colors().border,
|
||||
)
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for ThemePreviewTile {
|
||||
fn scope() -> ComponentScope {
|
||||
ComponentScope::Onboarding
|
||||
}
|
||||
|
||||
fn name() -> &'static str {
|
||||
"Theme Preview Tile"
|
||||
}
|
||||
|
||||
fn sort_name() -> &'static str {
|
||||
"Theme Preview Tile"
|
||||
}
|
||||
|
||||
fn description() -> Option<&'static str> {
|
||||
Some(Self::DOCS)
|
||||
}
|
||||
|
||||
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
|
||||
let theme_registry = ThemeRegistry::global(cx);
|
||||
|
||||
let one_dark = theme_registry.get("One Dark");
|
||||
let one_light = theme_registry.get("One Light");
|
||||
let gruvbox_dark = theme_registry.get("Gruvbox Dark");
|
||||
let gruvbox_light = theme_registry.get("Gruvbox Light");
|
||||
|
||||
let themes_to_preview = vec![
|
||||
one_dark.clone().ok(),
|
||||
one_light.ok(),
|
||||
gruvbox_dark.ok(),
|
||||
gruvbox_light.ok(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some(
|
||||
v_flex()
|
||||
.gap_6()
|
||||
.p_4()
|
||||
.children({
|
||||
if let Some(one_dark) = one_dark.ok() {
|
||||
vec![example_group(vec![single_example(
|
||||
"Default",
|
||||
div()
|
||||
.w(px(240.))
|
||||
.h(px(180.))
|
||||
.child(ThemePreviewTile::new(one_dark, 0.42))
|
||||
.into_any_element(),
|
||||
)])]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
})
|
||||
.child(
|
||||
example_group(vec![single_example(
|
||||
"Default Themes",
|
||||
h_flex()
|
||||
.gap_4()
|
||||
.children(
|
||||
themes_to_preview
|
||||
.into_iter()
|
||||
.map(|theme| {
|
||||
div()
|
||||
.w(px(200.))
|
||||
.h(px(140.))
|
||||
.child(ThemePreviewTile::new(theme, 0.42))
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_any_element(),
|
||||
)])
|
||||
.grow(),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user