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, 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::{ Avatar, ButtonLike, FluentBuilder, Headline, KeyBinding, ParentElement as _, StatefulInteractiveElement, Vector, VectorName, 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 ai_setup_page; mod base_keymap_picker; mod basics_page; mod editing_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, [ /// Activates the Basics page. ActivateBasicsPage, /// Activates the Editing page. ActivateEditingPage, /// Activates the AI Setup page. ActivateAISetupPage, /// Finish the onboarding process. Finish, /// Sign in while in the onboarding flow. 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::()); 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::()); 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 = ::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 = ::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::(cx); register_serializable_item::(cx); } pub fn show_onboarding_view(app_state: Arc, cx: &mut App) -> Task> { 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()) }); }, ) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SelectedPage { Basics, Editing, AiSetup, } impl SelectedPage { fn name(&self) -> &'static str { match self { SelectedPage::Basics => "Basics", SelectedPage::Editing => "Editing", SelectedPage::AiSetup => "AI Setup", } } } struct Onboarding { workspace: WeakEntity, focus_handle: FocusHandle, selected_page: SelectedPage, user_store: Entity, _settings_subscription: Subscription, } impl Onboarding { fn new(workspace: &Workspace, cx: &mut App) -> Entity { cx.new(|cx| Self { workspace: workspace.weak_handle(), focus_handle: cx.focus_handle(), selected_page: SelectedPage::Basics, user_store: workspace.user_store().clone(), _settings_subscription: cx.observe_global::(move |_, cx| cx.notify()), }) } fn set_page( &mut self, page: SelectedPage, clicked: Option<&'static str>, cx: &mut Context, ) { if let Some(click) = clicked { telemetry::event!( "Welcome Tab Clicked", from = self.selected_page.name(), to = page.name(), clicked = click, ); } self.selected_page = page; cx.notify(); cx.emit(ItemEvent::UpdateTab); } fn render_nav_buttons( &mut self, window: &mut Window, cx: &mut Context, ) -> [impl IntoElement; 3] { let pages = [ SelectedPage::Basics, SelectedPage::Editing, SelectedPage::AiSetup, ]; let text = ["Basics", "Editing", "AI Setup"]; let actions: [&dyn Action; 3] = [ &ActivateBasicsPage, &ActivateEditingPage, &ActivateAISetupPage, ]; let mut binding = actions.map(|action| { KeyBinding::for_action_in(action, &self.focus_handle, window, cx) .map(|kb| kb.size(rems_from_px(12.))) }); pages.map(|page| { let i = page as usize; let selected = self.selected_page == page; h_flex() .id(text[i]) .relative() .w_full() .gap_2() .px_2() .py_0p5() .justify_between() .rounded_sm() .when(selected, |this| { this.child( div() .h_4() .w_px() .bg(cx.theme().colors().text_accent) .absolute() .left_0(), ) }) .hover(|style| style.bg(cx.theme().colors().element_hover)) .child(Label::new(text[i]).map(|this| { if selected { this.color(Color::Default) } else { this.color(Color::Muted) } })) .child(binding[i].take().map_or( gpui::Empty.into_any_element(), IntoElement::into_any_element, )) .on_click(cx.listener(move |this, click_event, _, cx| { let click = match click_event { gpui::ClickEvent::Mouse(_) => "mouse", gpui::ClickEvent::Keyboard(_) => "keyboard", }; this.set_page(page, Some(click), cx); })) }) } fn render_nav(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let ai_setup_page = matches!(self.selected_page, SelectedPage::AiSetup); v_flex() .h_full() .w(rems_from_px(220.)) .flex_shrink_0() .gap_4() .justify_between() .child( v_flex() .gap_6() .child( h_flex() .px_2() .gap_4() .child(Vector::square(VectorName::ZedLogo, rems(2.5))) .child( v_flex() .child( Headline::new("Welcome to Zed").size(HeadlineSize::Small), ) .child( Label::new("The editor for what's next") .color(Color::Muted) .size(LabelSize::Small) .italic(), ), ), ) .child( v_flex() .gap_4() .child( v_flex() .py_4() .border_y_1() .border_color(cx.theme().colors().border_variant.opacity(0.5)) .gap_1() .children(self.render_nav_buttons(window, cx)), ) .map(|this| { let keybinding = KeyBinding::for_action_in( &Finish, &self.focus_handle, window, cx, ) .map(|kb| kb.size(rems_from_px(12.))); if ai_setup_page { this.child( ButtonLike::new("start_building") .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) .child( h_flex() .ml_1() .w_full() .justify_between() .child(Label::new("Start Building")) .children(keybinding), ) .on_click(|_, window, cx| { window.dispatch_action(Finish.boxed_clone(), cx); }), ) } else { this.child( ButtonLike::new("skip_all") .size(ButtonSize::Medium) .child( h_flex() .ml_1() .w_full() .justify_between() .child( Label::new("Skip All").color(Color::Muted), ) .children(keybinding), ) .on_click(|_, window, cx| { window.dispatch_action(Finish.boxed_clone(), cx); }), ) } }), ), ) .child( if let Some(user) = self.user_store.read(cx).current_user() { v_flex() .gap_1() .child( h_flex() .ml_2() .gap_2() .max_w_full() .w_full() .child(Avatar::new(user.avatar_uri.clone())) .child(Label::new(user.github_login.clone()).truncate()), ) .child( ButtonLike::new("open_account") .size(ButtonSize::Medium) .child( h_flex() .ml_1() .w_full() .justify_between() .child(Label::new("Open Account")) .children( KeyBinding::for_action_in( &OpenAccount, &self.focus_handle, window, cx, ) .map(|kb| kb.size(rems_from_px(12.))), ), ) .on_click(|_, window, cx| { window.dispatch_action(OpenAccount.boxed_clone(), cx); }), ) .into_any_element() } else { Button::new("sign_in", "Sign In") .full_width() .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) .key_binding( KeyBinding::for_action_in(&SignIn, &self.focus_handle, window, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(|_, window, cx| { window.dispatch_action(SignIn.boxed_clone(), cx); }) .into_any_element() }, ) } fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) { telemetry::event!("Welcome Skip Clicked"); 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, window: &mut Window, cx: &mut Context) -> AnyElement { let client = Client::global(cx); match self.selected_page { SelectedPage::Basics => crate::basics_page::render_basics_page(cx).into_any_element(), SelectedPage::Editing => { crate::editing_page::render_editing_page(window, cx).into_any_element() } SelectedPage::AiSetup => crate::ai_setup_page::render_ai_setup_page( self.workspace.clone(), self.user_store.clone(), client, window, cx, ) .into_any_element(), } } } impl Render for Onboarding { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { h_flex() .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(|this, _: &ActivateBasicsPage, _, cx| { this.set_page(SelectedPage::Basics, Some("action"), cx); })) .on_action(cx.listener(|this, _: &ActivateEditingPage, _, cx| { this.set_page(SelectedPage::Editing, Some("action"), cx); })) .on_action(cx.listener(|this, _: &ActivateAISetupPage, _, cx| { this.set_page(SelectedPage::AiSetup, Some("action"), cx); })) .on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| { window.focus_next(); cx.notify(); })) .on_action(cx.listener(|_, _: &menu::SelectPrevious, window, cx| { window.focus_prev(); cx.notify(); })) .child( h_flex() .max_w(rems_from_px(1100.)) .max_h(rems_from_px(850.)) .size_full() .m_auto() .py_20() .px_12() .items_start() .gap_12() .child(self.render_nav(window, cx)) .child( v_flex() .id("page-content") .size_full() .max_w_full() .min_w_0() .pl_12() .border_l_1() .border_color(cx.theme().colors().border_variant.opacity(0.5)) .overflow_y_scroll() .child(self.render_page(window, cx)), ), ) } } impl EventEmitter 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 clone_on_split( &self, _workspace_id: Option, _: &mut Window, cx: &mut Context, ) -> Option> { Some(cx.new(|cx| Onboarding { workspace: self.workspace.clone(), user_store: self.user_store.clone(), selected_page: self.selected_page, focus_handle: cx.focus_handle(), _settings_subscription: cx.observe_global::(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::()?; 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::()?; 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, source: VsCodeSettingsSource, skip_prompt: bool, fs: Arc, 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::() .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(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, _window: &mut Window, cx: &mut App, ) -> gpui::Task> { workspace::delete_unloaded_items( alive_items, workspace_id, "onboarding_pages", &persistence::ONBOARDING_PAGES, cx, ) } fn deserialize( _project: Entity, workspace: WeakEntity, workspace_id: workspace::WorkspaceId, item_id: workspace::ItemId, window: &mut Window, cx: &mut App, ) -> gpui::Task>> { window.spawn(cx, async move |cx| { if let Some(page_number) = persistence::ONBOARDING_PAGES.get_onboarding_page(item_id, workspace_id)? { let page = match page_number { 0 => Some(SelectedPage::Basics), 1 => Some(SelectedPage::Editing), 2 => Some(SelectedPage::AiSetup), _ => None, }; workspace.update(cx, |workspace, cx| { let onboarding_page = Onboarding::new(workspace, cx); if let Some(page) = page { zlog::info!("Onboarding page {page:?} loaded"); onboarding_page.update(cx, |onboarding_page, cx| { onboarding_page.set_page(page, None, cx); }) } onboarding_page }) } 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, ) -> Option>> { let workspace_id = workspace.database_id()?; let page_number = self.selected_page as u16; Some(cx.background_spawn(async move { persistence::ONBOARDING_PAGES .save_onboarding_page(item_id, workspace_id, page_number) .await })) } fn should_serialize(&self, event: &Self::Event) -> bool { event == &ItemEvent::UpdateTab } } mod persistence { use db::{define_connection, query, sqlez_macros::sql}; use workspace::WorkspaceDb; define_connection! { pub static ref ONBOARDING_PAGES: OnboardingPagesDb = &[ 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; ), ]; } impl OnboardingPagesDb { query! { pub async fn save_onboarding_page( item_id: workspace::ItemId, workspace_id: workspace::WorkspaceId, page_number: u16 ) -> Result<()> { INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id, page_number) VALUES (?, ?, ?) } } query! { pub fn get_onboarding_page( item_id: workspace::ItemId, workspace_id: workspace::WorkspaceId ) -> Result> { SELECT page_number FROM onboarding_pages WHERE item_id = ? AND workspace_id = ? } } } }