1use crate::multibuffer_hint::MultibufferHint;
2use client::{Client, UserStore, zed_urls};
3use db::kvp::KeyValueStore;
4use fs::Fs;
5use gpui::{
6 Action, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, EventEmitter,
7 FocusHandle, Focusable, Global, IntoElement, KeyContext, Render, ScrollHandle, SharedString,
8 Subscription, Task, WeakEntity, Window, actions,
9};
10use notifications::status_toast::{StatusToast, ToastIcon};
11use schemars::JsonSchema;
12use serde::Deserialize;
13use settings::{SettingsStore, VsCodeSettingsSource};
14use std::sync::Arc;
15use ui::{
16 Divider, KeyBinding, ParentElement as _, StatefulInteractiveElement, Vector, VectorName,
17 WithScrollbar as _, prelude::*, rems_from_px,
18};
19pub use workspace::welcome::ShowWelcome;
20use workspace::welcome::WelcomePage;
21use workspace::{
22 AppState, Workspace, WorkspaceId,
23 dock::DockPosition,
24 item::{Item, ItemEvent},
25 notifications::NotifyResultExt as _,
26 open_new, register_serializable_item, with_active_or_new_workspace,
27};
28use zed_actions::OpenOnboarding;
29
30mod base_keymap_picker;
31mod basics_page;
32pub mod multibuffer_hint;
33mod theme_preview;
34
35/// Imports settings from Visual Studio Code.
36#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
37#[action(namespace = zed)]
38#[serde(deny_unknown_fields)]
39pub struct ImportVsCodeSettings {
40 #[serde(default)]
41 pub skip_prompt: bool,
42}
43
44/// Imports settings from Cursor editor.
45#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
46#[action(namespace = zed)]
47#[serde(deny_unknown_fields)]
48pub struct ImportCursorSettings {
49 #[serde(default)]
50 pub skip_prompt: bool,
51}
52
53pub const FIRST_OPEN: &str = "first_open";
54pub const DOCS_URL: &str = "https://zed.dev/docs/";
55
56actions!(
57 onboarding,
58 [
59 /// Finish the onboarding process.
60 Finish,
61 /// Sign in while in the onboarding flow.
62 SignIn,
63 /// Open the user account in zed.dev while in the onboarding flow.
64 OpenAccount,
65 /// Resets the welcome screen hints to their initial state.
66 ResetHints
67 ]
68);
69
70pub fn init(cx: &mut App) {
71 cx.observe_new(|workspace: &mut Workspace, _, _cx| {
72 workspace
73 .register_action(|_workspace, _: &ResetHints, _, cx| MultibufferHint::set_count(0, cx));
74 })
75 .detach();
76
77 cx.on_action(|_: &OpenOnboarding, cx| {
78 with_active_or_new_workspace(cx, |workspace, window, cx| {
79 workspace
80 .with_local_workspace(window, cx, |workspace, window, cx| {
81 let existing = workspace
82 .active_pane()
83 .read(cx)
84 .items()
85 .find_map(|item| item.downcast::<Onboarding>());
86
87 if let Some(existing) = existing {
88 workspace.activate_item(&existing, true, true, window, cx);
89 } else {
90 let settings_page = Onboarding::new(workspace, cx);
91 workspace.add_item_to_active_pane(
92 Box::new(settings_page),
93 None,
94 true,
95 window,
96 cx,
97 )
98 }
99 })
100 .detach();
101 });
102 });
103
104 cx.on_action(|_: &ShowWelcome, cx| {
105 with_active_or_new_workspace(cx, |workspace, window, cx| {
106 workspace
107 .with_local_workspace(window, cx, |workspace, window, cx| {
108 let existing = workspace
109 .active_pane()
110 .read(cx)
111 .items()
112 .find_map(|item| item.downcast::<WelcomePage>());
113
114 if let Some(existing) = existing {
115 workspace.activate_item(&existing, true, true, window, cx);
116 } else {
117 let settings_page = cx
118 .new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx));
119 workspace.add_item_to_active_pane(
120 Box::new(settings_page),
121 None,
122 true,
123 window,
124 cx,
125 )
126 }
127 })
128 .detach();
129 });
130 });
131
132 cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
133 workspace.register_action(|_workspace, action: &ImportVsCodeSettings, window, cx| {
134 let fs = <dyn Fs>::global(cx);
135 let action = *action;
136
137 let workspace = cx.weak_entity();
138
139 window
140 .spawn(cx, async move |cx: &mut AsyncWindowContext| {
141 handle_import_vscode_settings(
142 workspace,
143 VsCodeSettingsSource::VsCode,
144 action.skip_prompt,
145 fs,
146 cx,
147 )
148 .await
149 })
150 .detach();
151 });
152
153 workspace.register_action(|_workspace, action: &ImportCursorSettings, window, cx| {
154 let fs = <dyn Fs>::global(cx);
155 let action = *action;
156
157 let workspace = cx.weak_entity();
158
159 window
160 .spawn(cx, async move |cx: &mut AsyncWindowContext| {
161 handle_import_vscode_settings(
162 workspace,
163 VsCodeSettingsSource::Cursor,
164 action.skip_prompt,
165 fs,
166 cx,
167 )
168 .await
169 })
170 .detach();
171 });
172 })
173 .detach();
174
175 base_keymap_picker::init(cx);
176
177 register_serializable_item::<Onboarding>(cx);
178 register_serializable_item::<WelcomePage>(cx);
179}
180
181pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyhow::Result<()>> {
182 telemetry::event!("Onboarding Page Opened");
183 open_new(
184 Default::default(),
185 app_state,
186 cx,
187 |workspace, window, cx| {
188 {
189 workspace.toggle_dock(DockPosition::Left, window, cx);
190 let onboarding_page = Onboarding::new(workspace, cx);
191 workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
192
193 window.focus(&onboarding_page.focus_handle(cx), cx);
194
195 cx.notify();
196 };
197 let kvp = KeyValueStore::global(cx);
198 db::write_and_log(cx, move || async move {
199 kvp.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
200 .await
201 });
202 },
203 )
204}
205
206struct Onboarding {
207 workspace: WeakEntity<Workspace>,
208 focus_handle: FocusHandle,
209 user_store: Entity<UserStore>,
210 scroll_handle: ScrollHandle,
211 _settings_subscription: Subscription,
212}
213
214impl Onboarding {
215 fn new(workspace: &Workspace, cx: &mut App) -> Entity<Self> {
216 let font_family_cache = theme::FontFamilyCache::global(cx);
217
218 cx.new(|cx| {
219 cx.spawn(async move |this, cx| {
220 font_family_cache.prefetch(cx).await;
221 this.update(cx, |_, cx| {
222 cx.notify();
223 })
224 })
225 .detach();
226
227 Self {
228 workspace: workspace.weak_handle(),
229 focus_handle: cx.focus_handle(),
230 scroll_handle: ScrollHandle::new(),
231 user_store: workspace.user_store().clone(),
232 _settings_subscription: cx
233 .observe_global::<SettingsStore>(move |_, cx| cx.notify()),
234 }
235 })
236 }
237
238 fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) {
239 telemetry::event!("Finish Setup");
240 go_to_welcome_page(cx);
241 }
242
243 fn handle_sign_in(&mut self, _: &SignIn, window: &mut Window, cx: &mut Context<Self>) {
244 let client = Client::global(cx);
245 let workspace = self.workspace.clone();
246
247 window
248 .spawn(cx, async move |mut cx| {
249 client
250 .sign_in_with_optional_connect(true, &cx)
251 .await
252 .notify_workspace_async_err(workspace, &mut cx);
253 })
254 .detach();
255 }
256
257 fn handle_open_account(_: &OpenAccount, _: &mut Window, cx: &mut App) {
258 cx.open_url(&zed_urls::account_url(cx))
259 }
260
261 fn render_page(&mut self, cx: &mut Context<Self>) -> AnyElement {
262 crate::basics_page::render_basics_page(cx).into_any_element()
263 }
264}
265
266impl Render for Onboarding {
267 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
268 div()
269 .image_cache(gpui::retain_all("onboarding-page"))
270 .key_context({
271 let mut ctx = KeyContext::new_with_defaults();
272 ctx.add("Onboarding");
273 ctx.add("menu");
274 ctx
275 })
276 .track_focus(&self.focus_handle)
277 .size_full()
278 .bg(cx.theme().colors().editor_background)
279 .on_action(Self::on_finish)
280 .on_action(cx.listener(Self::handle_sign_in))
281 .on_action(Self::handle_open_account)
282 .on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| {
283 window.focus_next(cx);
284 cx.notify();
285 }))
286 .on_action(cx.listener(|_, _: &menu::SelectPrevious, window, cx| {
287 window.focus_prev(cx);
288 cx.notify();
289 }))
290 .child(
291 div()
292 .max_w(Rems(48.0))
293 .size_full()
294 .mx_auto()
295 .child(
296 v_flex()
297 .id("page-content")
298 .m_auto()
299 .p_12()
300 .size_full()
301 .max_w_full()
302 .min_w_0()
303 .gap_6()
304 .overflow_y_scroll()
305 .child(
306 h_flex()
307 .w_full()
308 .gap_4()
309 .justify_between()
310 .child(
311 h_flex()
312 .gap_4()
313 .child(Vector::square(VectorName::ZedLogo, rems(2.5)))
314 .child(
315 v_flex()
316 .child(
317 Headline::new("Welcome to Zed")
318 .size(HeadlineSize::Small),
319 )
320 .child(
321 Label::new("The editor for what's next")
322 .color(Color::Muted)
323 .size(LabelSize::Small)
324 .italic(),
325 ),
326 ),
327 )
328 .child({
329 Button::new("finish_setup", "Finish Setup")
330 .style(ButtonStyle::Filled)
331 .size(ButtonSize::Medium)
332 .width(Rems(12.0))
333 .key_binding(
334 KeyBinding::for_action_in(
335 &Finish,
336 &self.focus_handle,
337 cx,
338 )
339 .size(rems_from_px(12.)),
340 )
341 .on_click(|_, window, cx| {
342 window.dispatch_action(Finish.boxed_clone(), cx);
343 })
344 }),
345 )
346 .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
347 .child(self.render_page(cx))
348 .track_scroll(&self.scroll_handle),
349 )
350 .vertical_scrollbar_for(&self.scroll_handle, window, cx),
351 )
352 }
353}
354
355impl EventEmitter<ItemEvent> for Onboarding {}
356
357impl Focusable for Onboarding {
358 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
359 self.focus_handle.clone()
360 }
361}
362
363impl Item for Onboarding {
364 type Event = ItemEvent;
365
366 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
367 "Onboarding".into()
368 }
369
370 fn telemetry_event_text(&self) -> Option<&'static str> {
371 Some("Onboarding Page Opened")
372 }
373
374 fn show_toolbar(&self) -> bool {
375 false
376 }
377
378 fn can_split(&self) -> bool {
379 true
380 }
381
382 fn clone_on_split(
383 &self,
384 _workspace_id: Option<WorkspaceId>,
385 _: &mut Window,
386 cx: &mut Context<Self>,
387 ) -> Task<Option<Entity<Self>>> {
388 Task::ready(Some(cx.new(|cx| Onboarding {
389 workspace: self.workspace.clone(),
390 user_store: self.user_store.clone(),
391 scroll_handle: ScrollHandle::new(),
392 focus_handle: cx.focus_handle(),
393 _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
394 })))
395 }
396
397 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
398 f(*event)
399 }
400}
401
402fn go_to_welcome_page(cx: &mut App) {
403 with_active_or_new_workspace(cx, |workspace, window, cx| {
404 let Some((onboarding_id, onboarding_idx)) = workspace
405 .active_pane()
406 .read(cx)
407 .items()
408 .enumerate()
409 .find_map(|(idx, item)| {
410 let _ = item.downcast::<Onboarding>()?;
411 Some((item.item_id(), idx))
412 })
413 else {
414 return;
415 };
416
417 workspace.active_pane().update(cx, |pane, cx| {
418 // Get the index here to get around the borrow checker
419 let idx = pane.items().enumerate().find_map(|(idx, item)| {
420 let _ = item.downcast::<WelcomePage>()?;
421 Some(idx)
422 });
423
424 if let Some(idx) = idx {
425 pane.activate_item(idx, true, true, window, cx);
426 } else {
427 let item = Box::new(
428 cx.new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx)),
429 );
430 pane.add_item(item, true, true, Some(onboarding_idx), window, cx);
431 }
432
433 pane.remove_item(onboarding_id, false, false, window, cx);
434 });
435 });
436}
437
438pub async fn handle_import_vscode_settings(
439 workspace: WeakEntity<Workspace>,
440 source: VsCodeSettingsSource,
441 skip_prompt: bool,
442 fs: Arc<dyn Fs>,
443 cx: &mut AsyncWindowContext,
444) {
445 use util::truncate_and_remove_front;
446
447 let vscode_settings =
448 match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
449 Ok(vscode_settings) => vscode_settings,
450 Err(err) => {
451 zlog::error!("{err:?}");
452 let _ = cx.prompt(
453 gpui::PromptLevel::Info,
454 &format!("Could not find or load a {source} settings file"),
455 None,
456 &["Ok"],
457 );
458 return;
459 }
460 };
461
462 if !skip_prompt {
463 let prompt = cx.prompt(
464 gpui::PromptLevel::Warning,
465 &format!(
466 "Importing {} settings may overwrite your existing settings. \
467 Will import settings from {}",
468 vscode_settings.source,
469 truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
470 ),
471 None,
472 &["Ok", "Cancel"],
473 );
474 let result = cx.spawn(async move |_| prompt.await.ok()).await;
475 if result != Some(0) {
476 return;
477 }
478 };
479
480 let Ok(result_channel) = cx.update(|_, cx| {
481 let source = vscode_settings.source;
482 let path = vscode_settings.path.clone();
483 let result_channel = cx
484 .global::<SettingsStore>()
485 .import_vscode_settings(fs, vscode_settings);
486 zlog::info!("Imported {source} settings from {}", path.display());
487 result_channel
488 }) else {
489 return;
490 };
491
492 let result = result_channel.await;
493 workspace
494 .update_in(cx, |workspace, _, cx| match result {
495 Ok(_) => {
496 let confirmation_toast = StatusToast::new(
497 format!("Your {} settings were successfully imported.", source),
498 cx,
499 |this, _| {
500 this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
501 .dismiss_button(true)
502 },
503 );
504 SettingsImportState::update(cx, |state, _| match source {
505 VsCodeSettingsSource::VsCode => {
506 state.vscode = true;
507 }
508 VsCodeSettingsSource::Cursor => {
509 state.cursor = true;
510 }
511 });
512 workspace.toggle_status_toast(confirmation_toast, cx);
513 }
514 Err(_) => {
515 let error_toast = StatusToast::new(
516 "Failed to import settings. See log for details",
517 cx,
518 |this, _| {
519 this.icon(ToastIcon::new(IconName::Close).color(Color::Error))
520 .action("Open Log", |window, cx| {
521 window.dispatch_action(workspace::OpenLog.boxed_clone(), cx)
522 })
523 .dismiss_button(true)
524 },
525 );
526 workspace.toggle_status_toast(error_toast, cx);
527 }
528 })
529 .ok();
530}
531
532#[derive(Default, Copy, Clone)]
533pub struct SettingsImportState {
534 pub cursor: bool,
535 pub vscode: bool,
536}
537
538impl Global for SettingsImportState {}
539
540impl SettingsImportState {
541 pub fn global(cx: &App) -> Self {
542 cx.try_global().cloned().unwrap_or_default()
543 }
544 pub fn update<R>(cx: &mut App, f: impl FnOnce(&mut Self, &mut App) -> R) -> R {
545 cx.update_default_global(f)
546 }
547}
548
549impl workspace::SerializableItem for Onboarding {
550 fn serialized_item_kind() -> &'static str {
551 "OnboardingPage"
552 }
553
554 fn cleanup(
555 workspace_id: workspace::WorkspaceId,
556 alive_items: Vec<workspace::ItemId>,
557 _window: &mut Window,
558 cx: &mut App,
559 ) -> gpui::Task<gpui::Result<()>> {
560 workspace::delete_unloaded_items(
561 alive_items,
562 workspace_id,
563 "onboarding_pages",
564 &persistence::OnboardingPagesDb::global(cx),
565 cx,
566 )
567 }
568
569 fn deserialize(
570 _project: Entity<project::Project>,
571 workspace: WeakEntity<Workspace>,
572 workspace_id: workspace::WorkspaceId,
573 item_id: workspace::ItemId,
574 window: &mut Window,
575 cx: &mut App,
576 ) -> gpui::Task<gpui::Result<Entity<Self>>> {
577 let db = persistence::OnboardingPagesDb::global(cx);
578 window.spawn(cx, async move |cx| {
579 if let Some(_) = db.get_onboarding_page(item_id, workspace_id)? {
580 workspace.update(cx, |workspace, cx| Onboarding::new(workspace, cx))
581 } else {
582 Err(anyhow::anyhow!("No onboarding page to deserialize"))
583 }
584 })
585 }
586
587 fn serialize(
588 &mut self,
589 workspace: &mut Workspace,
590 item_id: workspace::ItemId,
591 _closing: bool,
592 _window: &mut Window,
593 cx: &mut ui::Context<Self>,
594 ) -> Option<gpui::Task<gpui::Result<()>>> {
595 let workspace_id = workspace.database_id()?;
596
597 let db = persistence::OnboardingPagesDb::global(cx);
598 Some(
599 cx.background_spawn(
600 async move { db.save_onboarding_page(item_id, workspace_id).await },
601 ),
602 )
603 }
604
605 fn should_serialize(&self, event: &Self::Event) -> bool {
606 event == &ItemEvent::UpdateTab
607 }
608}
609
610mod persistence {
611 use db::{
612 query,
613 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
614 sqlez_macros::sql,
615 };
616 use workspace::WorkspaceDb;
617
618 pub struct OnboardingPagesDb(ThreadSafeConnection);
619
620 impl Domain for OnboardingPagesDb {
621 const NAME: &str = stringify!(OnboardingPagesDb);
622
623 const MIGRATIONS: &[&str] = &[
624 sql!(
625 CREATE TABLE onboarding_pages (
626 workspace_id INTEGER,
627 item_id INTEGER UNIQUE,
628 page_number INTEGER,
629
630 PRIMARY KEY(workspace_id, item_id),
631 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
632 ON DELETE CASCADE
633 ) STRICT;
634 ),
635 sql!(
636 CREATE TABLE onboarding_pages_2 (
637 workspace_id INTEGER,
638 item_id INTEGER UNIQUE,
639
640 PRIMARY KEY(workspace_id, item_id),
641 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
642 ON DELETE CASCADE
643 ) STRICT;
644 INSERT INTO onboarding_pages_2 SELECT workspace_id, item_id FROM onboarding_pages;
645 DROP TABLE onboarding_pages;
646 ALTER TABLE onboarding_pages_2 RENAME TO onboarding_pages;
647 ),
648 ];
649 }
650
651 db::static_connection!(OnboardingPagesDb, [WorkspaceDb]);
652
653 impl OnboardingPagesDb {
654 query! {
655 pub async fn save_onboarding_page(
656 item_id: workspace::ItemId,
657 workspace_id: workspace::WorkspaceId
658 ) -> Result<()> {
659 INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id)
660 VALUES (?, ?)
661 }
662 }
663
664 query! {
665 pub fn get_onboarding_page(
666 item_id: workspace::ItemId,
667 workspace_id: workspace::WorkspaceId
668 ) -> Result<Option<workspace::ItemId>> {
669 SELECT item_id
670 FROM onboarding_pages
671 WHERE item_id = ? AND workspace_id = ?
672 }
673 }
674 }
675}