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