1use crate::welcome::{ShowWelcome, WelcomePage};
2use client::{Client, UserStore};
3use command_palette_hooks::CommandPaletteFilter;
4use db::kvp::KEY_VALUE_STORE;
5use feature_flags::{FeatureFlag, FeatureFlagViewExt as _};
6use fs::Fs;
7use gpui::{
8 Action, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, EventEmitter,
9 FocusHandle, Focusable, IntoElement, KeyContext, Render, SharedString, Subscription, Task,
10 WeakEntity, Window, actions,
11};
12use schemars::JsonSchema;
13use serde::Deserialize;
14use settings::{SettingsStore, VsCodeSettingsSource};
15use std::sync::Arc;
16use ui::{
17 Avatar, ButtonLike, FluentBuilder, Headline, KeyBinding, ParentElement as _,
18 StatefulInteractiveElement, Vector, VectorName, 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 ai_setup_page;
29mod basics_page;
30mod editing_page;
31mod theme_preview;
32mod welcome;
33
34pub struct OnBoardingFeatureFlag {}
35
36impl FeatureFlag for OnBoardingFeatureFlag {
37 const NAME: &'static str = "onboarding";
38}
39
40/// Imports settings from Visual Studio Code.
41#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
42#[action(namespace = zed)]
43#[serde(deny_unknown_fields)]
44pub struct ImportVsCodeSettings {
45 #[serde(default)]
46 pub skip_prompt: bool,
47}
48
49/// Imports settings from Cursor editor.
50#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
51#[action(namespace = zed)]
52#[serde(deny_unknown_fields)]
53pub struct ImportCursorSettings {
54 #[serde(default)]
55 pub skip_prompt: bool,
56}
57
58pub const FIRST_OPEN: &str = "first_open";
59
60actions!(
61 zed,
62 [
63 /// Opens the onboarding view.
64 OpenOnboarding
65 ]
66);
67
68actions!(
69 onboarding,
70 [
71 /// Activates the Basics page.
72 ActivateBasicsPage,
73 /// Activates the Editing page.
74 ActivateEditingPage,
75 /// Activates the AI Setup page.
76 ActivateAISetupPage,
77 ]
78);
79
80pub fn init(cx: &mut App) {
81 cx.on_action(|_: &OpenOnboarding, cx| {
82 with_active_or_new_workspace(cx, |workspace, window, cx| {
83 workspace
84 .with_local_workspace(window, cx, |workspace, window, cx| {
85 let existing = workspace
86 .active_pane()
87 .read(cx)
88 .items()
89 .find_map(|item| item.downcast::<Onboarding>());
90
91 if let Some(existing) = existing {
92 workspace.activate_item(&existing, true, true, window, cx);
93 } else {
94 let settings_page = Onboarding::new(workspace, cx);
95 workspace.add_item_to_active_pane(
96 Box::new(settings_page),
97 None,
98 true,
99 window,
100 cx,
101 )
102 }
103 })
104 .detach();
105 });
106 });
107
108 cx.on_action(|_: &ShowWelcome, cx| {
109 with_active_or_new_workspace(cx, |workspace, window, cx| {
110 workspace
111 .with_local_workspace(window, cx, |workspace, window, cx| {
112 let existing = workspace
113 .active_pane()
114 .read(cx)
115 .items()
116 .find_map(|item| item.downcast::<WelcomePage>());
117
118 if let Some(existing) = existing {
119 workspace.activate_item(&existing, true, true, window, cx);
120 } else {
121 let settings_page = WelcomePage::new(window, cx);
122 workspace.add_item_to_active_pane(
123 Box::new(settings_page),
124 None,
125 true,
126 window,
127 cx,
128 )
129 }
130 })
131 .detach();
132 });
133 });
134
135 cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
136 workspace.register_action(|_workspace, action: &ImportVsCodeSettings, window, cx| {
137 let fs = <dyn Fs>::global(cx);
138 let action = *action;
139
140 window
141 .spawn(cx, async move |cx: &mut AsyncWindowContext| {
142 handle_import_vscode_settings(
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 window
158 .spawn(cx, async move |cx: &mut AsyncWindowContext| {
159 handle_import_vscode_settings(
160 VsCodeSettingsSource::Cursor,
161 action.skip_prompt,
162 fs,
163 cx,
164 )
165 .await
166 })
167 .detach();
168 });
169 })
170 .detach();
171
172 cx.observe_new::<Workspace>(|_, window, cx| {
173 let Some(window) = window else {
174 return;
175 };
176
177 let onboarding_actions = [
178 std::any::TypeId::of::<OpenOnboarding>(),
179 std::any::TypeId::of::<ShowWelcome>(),
180 ];
181
182 CommandPaletteFilter::update_global(cx, |filter, _cx| {
183 filter.hide_action_types(&onboarding_actions);
184 });
185
186 cx.observe_flag::<OnBoardingFeatureFlag, _>(window, move |is_enabled, _, _, cx| {
187 if is_enabled {
188 CommandPaletteFilter::update_global(cx, |filter, _cx| {
189 filter.show_action_types(onboarding_actions.iter());
190 });
191 } else {
192 CommandPaletteFilter::update_global(cx, |filter, _cx| {
193 filter.hide_action_types(&onboarding_actions);
194 });
195 }
196 })
197 .detach();
198 })
199 .detach();
200 register_serializable_item::<Onboarding>(cx);
201}
202
203pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyhow::Result<()>> {
204 open_new(
205 Default::default(),
206 app_state,
207 cx,
208 |workspace, window, cx| {
209 {
210 workspace.toggle_dock(DockPosition::Left, window, cx);
211 let onboarding_page = Onboarding::new(workspace, cx);
212 workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
213
214 window.focus(&onboarding_page.focus_handle(cx));
215
216 cx.notify();
217 };
218 db::write_and_log(cx, || {
219 KEY_VALUE_STORE.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
220 });
221 },
222 )
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226enum SelectedPage {
227 Basics,
228 Editing,
229 AiSetup,
230}
231
232struct Onboarding {
233 workspace: WeakEntity<Workspace>,
234 focus_handle: FocusHandle,
235 selected_page: SelectedPage,
236 user_store: Entity<UserStore>,
237 _settings_subscription: Subscription,
238}
239
240impl Onboarding {
241 fn new(workspace: &Workspace, cx: &mut App) -> Entity<Self> {
242 cx.new(|cx| Self {
243 workspace: workspace.weak_handle(),
244 focus_handle: cx.focus_handle(),
245 selected_page: SelectedPage::Basics,
246 user_store: workspace.user_store().clone(),
247 _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
248 })
249 }
250
251 fn set_page(&mut self, page: SelectedPage, cx: &mut Context<Self>) {
252 self.selected_page = page;
253 cx.notify();
254 cx.emit(ItemEvent::UpdateTab);
255 }
256
257 fn go_to_welcome_page(&self, cx: &mut App) {
258 with_active_or_new_workspace(cx, |workspace, window, cx| {
259 let Some((onboarding_id, onboarding_idx)) = workspace
260 .active_pane()
261 .read(cx)
262 .items()
263 .enumerate()
264 .find_map(|(idx, item)| {
265 let _ = item.downcast::<Onboarding>()?;
266 Some((item.item_id(), idx))
267 })
268 else {
269 return;
270 };
271
272 workspace.active_pane().update(cx, |pane, cx| {
273 // Get the index here to get around the borrow checker
274 let idx = pane.items().enumerate().find_map(|(idx, item)| {
275 let _ = item.downcast::<WelcomePage>()?;
276 Some(idx)
277 });
278
279 if let Some(idx) = idx {
280 pane.activate_item(idx, true, true, window, cx);
281 } else {
282 let item = Box::new(WelcomePage::new(window, cx));
283 pane.add_item(item, true, true, Some(onboarding_idx), window, cx);
284 }
285
286 pane.remove_item(onboarding_id, false, false, window, cx);
287 });
288 });
289 }
290
291 fn render_nav_buttons(
292 &mut self,
293 window: &mut Window,
294 cx: &mut Context<Self>,
295 ) -> [impl IntoElement; 3] {
296 let pages = [
297 SelectedPage::Basics,
298 SelectedPage::Editing,
299 SelectedPage::AiSetup,
300 ];
301
302 let text = ["Basics", "Editing", "AI Setup"];
303
304 let actions: [&dyn Action; 3] = [
305 &ActivateBasicsPage,
306 &ActivateEditingPage,
307 &ActivateAISetupPage,
308 ];
309
310 let mut binding = actions.map(|action| {
311 KeyBinding::for_action_in(action, &self.focus_handle, window, cx)
312 .map(|kb| kb.size(rems_from_px(12.)))
313 });
314
315 pages.map(|page| {
316 let i = page as usize;
317 let selected = self.selected_page == page;
318 h_flex()
319 .id(text[i])
320 .relative()
321 .w_full()
322 .gap_2()
323 .px_2()
324 .py_0p5()
325 .justify_between()
326 .rounded_sm()
327 .when(selected, |this| {
328 this.child(
329 div()
330 .h_4()
331 .w_px()
332 .bg(cx.theme().colors().text_accent)
333 .absolute()
334 .left_0(),
335 )
336 })
337 .hover(|style| style.bg(cx.theme().colors().element_hover))
338 .child(Label::new(text[i]).map(|this| {
339 if selected {
340 this.color(Color::Default)
341 } else {
342 this.color(Color::Muted)
343 }
344 }))
345 .child(binding[i].take().map_or(
346 gpui::Empty.into_any_element(),
347 IntoElement::into_any_element,
348 ))
349 .on_click(cx.listener(move |this, _, _, cx| {
350 this.set_page(page, cx);
351 }))
352 })
353 }
354
355 fn render_nav(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
356 let ai_setup_page = matches!(self.selected_page, SelectedPage::AiSetup);
357
358 v_flex()
359 .h_full()
360 .w(rems_from_px(220.))
361 .flex_shrink_0()
362 .gap_4()
363 .justify_between()
364 .child(
365 v_flex()
366 .gap_6()
367 .child(
368 h_flex()
369 .px_2()
370 .gap_4()
371 .child(Vector::square(VectorName::ZedLogo, rems(2.5)))
372 .child(
373 v_flex()
374 .child(
375 Headline::new("Welcome to Zed").size(HeadlineSize::Small),
376 )
377 .child(
378 Label::new("The editor for what's next")
379 .color(Color::Muted)
380 .size(LabelSize::Small)
381 .italic(),
382 ),
383 ),
384 )
385 .child(
386 v_flex()
387 .gap_4()
388 .child(
389 v_flex()
390 .py_4()
391 .border_y_1()
392 .border_color(cx.theme().colors().border_variant.opacity(0.5))
393 .gap_1()
394 .children(self.render_nav_buttons(window, cx)),
395 )
396 .map(|this| {
397 if ai_setup_page {
398 this.child(
399 ButtonLike::new("start_building")
400 .style(ButtonStyle::Outlined)
401 .size(ButtonSize::Medium)
402 .child(
403 h_flex()
404 .ml_1()
405 .w_full()
406 .justify_between()
407 .child(Label::new("Start Building"))
408 .child(
409 Icon::new(IconName::Check)
410 .size(IconSize::Small),
411 ),
412 )
413 .on_click(cx.listener(|this, _, _, cx| {
414 this.go_to_welcome_page(cx);
415 })),
416 )
417 } else {
418 this.child(
419 ButtonLike::new("skip_all")
420 .size(ButtonSize::Medium)
421 .child(Label::new("Skip All").ml_1())
422 .on_click(cx.listener(|this, _, _, cx| {
423 this.go_to_welcome_page(cx);
424 })),
425 )
426 }
427 }),
428 ),
429 )
430 .child(
431 if let Some(user) = self.user_store.read(cx).current_user() {
432 h_flex()
433 .pl_1p5()
434 .gap_2()
435 .child(Avatar::new(user.avatar_uri.clone()))
436 .child(Label::new(user.github_login.clone()))
437 .into_any_element()
438 } else {
439 Button::new("sign_in", "Sign In")
440 .full_width()
441 .style(ButtonStyle::Outlined)
442 .on_click(|_, window, cx| {
443 let client = Client::global(cx);
444 window
445 .spawn(cx, async move |cx| {
446 client
447 .sign_in_with_optional_connect(true, &cx)
448 .await
449 .notify_async_err(cx);
450 })
451 .detach();
452 })
453 .into_any_element()
454 },
455 )
456 }
457
458 fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
459 match self.selected_page {
460 SelectedPage::Basics => {
461 crate::basics_page::render_basics_page(window, cx).into_any_element()
462 }
463 SelectedPage::Editing => {
464 crate::editing_page::render_editing_page(window, cx).into_any_element()
465 }
466 SelectedPage::AiSetup => {
467 crate::ai_setup_page::render_ai_setup_page(&self, window, cx).into_any_element()
468 }
469 }
470 }
471}
472
473impl Render for Onboarding {
474 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
475 h_flex()
476 .image_cache(gpui::retain_all("onboarding-page"))
477 .key_context({
478 let mut ctx = KeyContext::new_with_defaults();
479 ctx.add("Onboarding");
480 ctx
481 })
482 .track_focus(&self.focus_handle)
483 .size_full()
484 .bg(cx.theme().colors().editor_background)
485 .on_action(cx.listener(|this, _: &ActivateBasicsPage, _, cx| {
486 this.set_page(SelectedPage::Basics, cx);
487 }))
488 .on_action(cx.listener(|this, _: &ActivateEditingPage, _, cx| {
489 this.set_page(SelectedPage::Editing, cx);
490 }))
491 .on_action(cx.listener(|this, _: &ActivateAISetupPage, _, cx| {
492 this.set_page(SelectedPage::AiSetup, cx);
493 }))
494 .child(
495 h_flex()
496 .max_w(rems_from_px(1100.))
497 .size_full()
498 .m_auto()
499 .py_20()
500 .px_12()
501 .items_start()
502 .gap_12()
503 .child(self.render_nav(window, cx))
504 .child(
505 v_flex()
506 .max_w_full()
507 .min_w_0()
508 .pl_12()
509 .border_l_1()
510 .border_color(cx.theme().colors().border_variant.opacity(0.5))
511 .size_full()
512 .child(self.render_page(window, cx)),
513 ),
514 )
515 }
516}
517
518impl EventEmitter<ItemEvent> for Onboarding {}
519
520impl Focusable for Onboarding {
521 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
522 self.focus_handle.clone()
523 }
524}
525
526impl Item for Onboarding {
527 type Event = ItemEvent;
528
529 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
530 "Onboarding".into()
531 }
532
533 fn telemetry_event_text(&self) -> Option<&'static str> {
534 Some("Onboarding Page Opened")
535 }
536
537 fn show_toolbar(&self) -> bool {
538 false
539 }
540
541 fn clone_on_split(
542 &self,
543 _workspace_id: Option<WorkspaceId>,
544 _: &mut Window,
545 cx: &mut Context<Self>,
546 ) -> Option<Entity<Self>> {
547 self.workspace
548 .update(cx, |workspace, cx| Onboarding::new(workspace, cx))
549 .ok()
550 }
551
552 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
553 f(*event)
554 }
555}
556
557pub async fn handle_import_vscode_settings(
558 source: VsCodeSettingsSource,
559 skip_prompt: bool,
560 fs: Arc<dyn Fs>,
561 cx: &mut AsyncWindowContext,
562) {
563 use util::truncate_and_remove_front;
564
565 let vscode_settings =
566 match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
567 Ok(vscode_settings) => vscode_settings,
568 Err(err) => {
569 zlog::error!("{err}");
570 let _ = cx.prompt(
571 gpui::PromptLevel::Info,
572 &format!("Could not find or load a {source} settings file"),
573 None,
574 &["Ok"],
575 );
576 return;
577 }
578 };
579
580 if !skip_prompt {
581 let prompt = cx.prompt(
582 gpui::PromptLevel::Warning,
583 &format!(
584 "Importing {} settings may overwrite your existing settings. \
585 Will import settings from {}",
586 vscode_settings.source,
587 truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
588 ),
589 None,
590 &["Ok", "Cancel"],
591 );
592 let result = cx.spawn(async move |_| prompt.await.ok()).await;
593 if result != Some(0) {
594 return;
595 }
596 };
597
598 cx.update(|_, cx| {
599 let source = vscode_settings.source;
600 let path = vscode_settings.path.clone();
601 cx.global::<SettingsStore>()
602 .import_vscode_settings(fs, vscode_settings);
603 zlog::info!("Imported {source} settings from {}", path.display());
604 })
605 .ok();
606}
607
608impl workspace::SerializableItem for Onboarding {
609 fn serialized_item_kind() -> &'static str {
610 "OnboardingPage"
611 }
612
613 fn cleanup(
614 workspace_id: workspace::WorkspaceId,
615 alive_items: Vec<workspace::ItemId>,
616 _window: &mut Window,
617 cx: &mut App,
618 ) -> gpui::Task<gpui::Result<()>> {
619 workspace::delete_unloaded_items(
620 alive_items,
621 workspace_id,
622 "onboarding_pages",
623 &persistence::ONBOARDING_PAGES,
624 cx,
625 )
626 }
627
628 fn deserialize(
629 _project: Entity<project::Project>,
630 workspace: WeakEntity<Workspace>,
631 workspace_id: workspace::WorkspaceId,
632 item_id: workspace::ItemId,
633 window: &mut Window,
634 cx: &mut App,
635 ) -> gpui::Task<gpui::Result<Entity<Self>>> {
636 window.spawn(cx, async move |cx| {
637 if let Some(page_number) =
638 persistence::ONBOARDING_PAGES.get_onboarding_page(item_id, workspace_id)?
639 {
640 let page = match page_number {
641 0 => Some(SelectedPage::Basics),
642 1 => Some(SelectedPage::Editing),
643 2 => Some(SelectedPage::AiSetup),
644 _ => None,
645 };
646 workspace.update(cx, |workspace, cx| {
647 let onboarding_page = Onboarding::new(workspace, cx);
648 if let Some(page) = page {
649 zlog::info!("Onboarding page {page:?} loaded");
650 onboarding_page.update(cx, |onboarding_page, cx| {
651 onboarding_page.set_page(page, cx);
652 })
653 }
654 onboarding_page
655 })
656 } else {
657 Err(anyhow::anyhow!("No onboarding page to deserialize"))
658 }
659 })
660 }
661
662 fn serialize(
663 &mut self,
664 workspace: &mut Workspace,
665 item_id: workspace::ItemId,
666 _closing: bool,
667 _window: &mut Window,
668 cx: &mut ui::Context<Self>,
669 ) -> Option<gpui::Task<gpui::Result<()>>> {
670 let workspace_id = workspace.database_id()?;
671 let page_number = self.selected_page as u16;
672 Some(cx.background_spawn(async move {
673 persistence::ONBOARDING_PAGES
674 .save_onboarding_page(item_id, workspace_id, page_number)
675 .await
676 }))
677 }
678
679 fn should_serialize(&self, event: &Self::Event) -> bool {
680 event == &ItemEvent::UpdateTab
681 }
682}
683
684mod persistence {
685 use db::{define_connection, query, sqlez_macros::sql};
686 use workspace::WorkspaceDb;
687
688 define_connection! {
689 pub static ref ONBOARDING_PAGES: OnboardingPagesDb<WorkspaceDb> =
690 &[
691 sql!(
692 CREATE TABLE onboarding_pages (
693 workspace_id INTEGER,
694 item_id INTEGER UNIQUE,
695 page_number INTEGER,
696
697 PRIMARY KEY(workspace_id, item_id),
698 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
699 ON DELETE CASCADE
700 ) STRICT;
701 ),
702 ];
703 }
704
705 impl OnboardingPagesDb {
706 query! {
707 pub async fn save_onboarding_page(
708 item_id: workspace::ItemId,
709 workspace_id: workspace::WorkspaceId,
710 page_number: u16
711 ) -> Result<()> {
712 INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id, page_number)
713 VALUES (?, ?, ?)
714 }
715 }
716
717 query! {
718 pub fn get_onboarding_page(
719 item_id: workspace::ItemId,
720 workspace_id: workspace::WorkspaceId
721 ) -> Result<Option<u16>> {
722 SELECT page_number
723 FROM onboarding_pages
724 WHERE item_id = ? AND workspace_id = ?
725 }
726 }
727 }
728}