logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled

Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree
with a 3-file patch applied (no upstream history).

Patch (crates/gpui_linux/src/linux/wayland/):
  - serial.rs: add SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; add latest_serial_of()
  - window.rs: activate() uses keyboard-enter serial (Mutter focus gate)

Mutter honors window activation only when the token carries the keyboard-
focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial.
See docs/tray-window-focus-wayland.md in logiguard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 02:22:17 +03:30
commit b72a46db68
3984 changed files with 1583326 additions and 0 deletions

View 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"]}

View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View 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(),
)),
)
}
}

View 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))
}

View 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()
}
}

View 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 = ?
}
}
}
}

View 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(),
)
}
}