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