Files
oak-gpui/crates/onboarding/src/onboarding.rs
T
Lukas Wirth fd3ca0303f workspace: Handle non-cloneable items better (#41215)
When trying to split and clone a non clone-able workspace item we now
attempt split and move instead of doing nothing. Additionally we disable
the split menu buttons if we can't split the active item at all.

Release Notes:

- Improved handling of unsplittable panes
2025-10-26 13:24:26 +00:00

677 lines
23 KiB
Rust

pub use crate::welcome::ShowWelcome;
use crate::{multibuffer_hint::MultibufferHint, welcome::WelcomePage};
use client::{Client, UserStore, zed_urls};
use db::kvp::KEY_VALUE_STORE;
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, ToastIcon};
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,
};
use workspace::{
AppState, Workspace, WorkspaceId,
dock::DockPosition,
item::{Item, ItemEvent},
notifications::NotifyResultExt as _,
open_new, register_serializable_item, with_active_or_new_workspace,
};
mod base_keymap_picker;
mod basics_page;
pub mod multibuffer_hint;
mod theme_preview;
mod welcome;
/// 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!(
zed,
[
/// Opens the onboarding view.
OpenOnboarding
]
);
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 = WelcomePage::new(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.notify();
};
db::write_and_log(cx, || {
KEY_VALUE_STORE.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
});
},
)
}
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);
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(_: &SignIn, window: &mut Window, cx: &mut App) {
let client = Client::global(cx);
window
.spawn(cx, async move |cx| {
client
.sign_in_with_optional_connect(true, cx)
.await
.notify_async_err(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(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(Self::handle_sign_in)
.on_action(Self::handle_open_account)
.on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| {
window.focus_next();
cx.notify();
}))
.on_action(cx.listener(|_, _: &menu::SelectPrevious, window, cx| {
window.focus_prev();
cx.notify();
}))
.child(
div()
.max_w(Rems(48.0))
.size_full()
.mx_auto()
.child(
v_flex()
.id("page-content")
.m_auto()
.p_12()
.size_full()
.max_w_full()
.min_w_0()
.gap_6()
.overflow_y_scroll()
.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(12.0))
.key_binding(
KeyBinding::for_action_in(
&Finish,
&self.focus_handle,
cx,
)
.size(rems_from_px(12.)),
)
.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),
)
.vertical_scrollbar_for(self.scroll_handle.clone(), window, cx),
)
}
}
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, mut f: impl 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(WelcomePage::new(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(ToastIcon::new(IconName::Check).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(ToastIcon::new(IconName::Close).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::ONBOARDING_PAGES,
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>>> {
window.spawn(cx, async move |cx| {
if let Some(_) =
persistence::ONBOARDING_PAGES.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()?;
Some(cx.background_spawn(async move {
persistence::ONBOARDING_PAGES
.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!(ONBOARDING_PAGES, 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 = ?
}
}
}
}