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 render_nav_buttons(
258 &mut self,
259 window: &mut Window,
260 cx: &mut Context<Self>,
261 ) -> [impl IntoElement; 3] {
262 let pages = [
263 SelectedPage::Basics,
264 SelectedPage::Editing,
265 SelectedPage::AiSetup,
266 ];
267
268 let text = ["Basics", "Editing", "AI Setup"];
269
270 let actions: [&dyn Action; 3] = [
271 &ActivateBasicsPage,
272 &ActivateEditingPage,
273 &ActivateAISetupPage,
274 ];
275
276 let mut binding = actions.map(|action| {
277 KeyBinding::for_action_in(action, &self.focus_handle, window, cx)
278 .map(|kb| kb.size(rems_from_px(12.)))
279 });
280
281 pages.map(|page| {
282 let i = page as usize;
283 let selected = self.selected_page == page;
284 h_flex()
285 .id(text[i])
286 .relative()
287 .w_full()
288 .gap_2()
289 .px_2()
290 .py_0p5()
291 .justify_between()
292 .rounded_sm()
293 .when(selected, |this| {
294 this.child(
295 div()
296 .h_4()
297 .w_px()
298 .bg(cx.theme().colors().text_accent)
299 .absolute()
300 .left_0(),
301 )
302 })
303 .hover(|style| style.bg(cx.theme().colors().element_hover))
304 .child(Label::new(text[i]).map(|this| {
305 if selected {
306 this.color(Color::Default)
307 } else {
308 this.color(Color::Muted)
309 }
310 }))
311 .child(binding[i].take().map_or(
312 gpui::Empty.into_any_element(),
313 IntoElement::into_any_element,
314 ))
315 .on_click(cx.listener(move |this, _, _, cx| {
316 this.set_page(page, cx);
317 }))
318 })
319 }
320
321 fn render_nav(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
322 v_flex()
323 .h_full()
324 .w(rems_from_px(220.))
325 .flex_shrink_0()
326 .gap_4()
327 .justify_between()
328 .child(
329 v_flex()
330 .gap_6()
331 .child(
332 h_flex()
333 .px_2()
334 .gap_4()
335 .child(Vector::square(VectorName::ZedLogo, rems(2.5)))
336 .child(
337 v_flex()
338 .child(
339 Headline::new("Welcome to Zed").size(HeadlineSize::Small),
340 )
341 .child(
342 Label::new("The editor for what's next")
343 .color(Color::Muted)
344 .size(LabelSize::Small)
345 .italic(),
346 ),
347 ),
348 )
349 .child(
350 v_flex()
351 .gap_4()
352 .child(
353 v_flex()
354 .py_4()
355 .border_y_1()
356 .border_color(cx.theme().colors().border_variant.opacity(0.5))
357 .gap_1()
358 .children(self.render_nav_buttons(window, cx)),
359 )
360 .child(
361 ButtonLike::new("skip_all")
362 .child(Label::new("Skip All").ml_1())
363 .on_click(|_, _, cx| {
364 with_active_or_new_workspace(
365 cx,
366 |workspace, window, cx| {
367 let Some((onboarding_id, onboarding_idx)) =
368 workspace
369 .active_pane()
370 .read(cx)
371 .items()
372 .enumerate()
373 .find_map(|(idx, item)| {
374 let _ =
375 item.downcast::<Onboarding>()?;
376 Some((item.item_id(), idx))
377 })
378 else {
379 return;
380 };
381
382 workspace.active_pane().update(cx, |pane, cx| {
383 // Get the index here to get around the borrow checker
384 let idx = pane.items().enumerate().find_map(
385 |(idx, item)| {
386 let _ =
387 item.downcast::<WelcomePage>()?;
388 Some(idx)
389 },
390 );
391
392 if let Some(idx) = idx {
393 pane.activate_item(
394 idx, true, true, window, cx,
395 );
396 } else {
397 let item =
398 Box::new(WelcomePage::new(window, cx));
399 pane.add_item(
400 item,
401 true,
402 true,
403 Some(onboarding_idx),
404 window,
405 cx,
406 );
407 }
408
409 pane.remove_item(
410 onboarding_id,
411 false,
412 false,
413 window,
414 cx,
415 );
416 });
417 },
418 );
419 }),
420 ),
421 ),
422 )
423 .child(
424 if let Some(user) = self.user_store.read(cx).current_user() {
425 h_flex()
426 .pl_1p5()
427 .gap_2()
428 .child(Avatar::new(user.avatar_uri.clone()))
429 .child(Label::new(user.github_login.clone()))
430 .into_any_element()
431 } else {
432 Button::new("sign_in", "Sign In")
433 .style(ButtonStyle::Outlined)
434 .full_width()
435 .on_click(|_, window, cx| {
436 let client = Client::global(cx);
437 window
438 .spawn(cx, async move |cx| {
439 client
440 .sign_in_with_optional_connect(true, &cx)
441 .await
442 .notify_async_err(cx);
443 })
444 .detach();
445 })
446 .into_any_element()
447 },
448 )
449 }
450
451 fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
452 match self.selected_page {
453 SelectedPage::Basics => {
454 crate::basics_page::render_basics_page(window, cx).into_any_element()
455 }
456 SelectedPage::Editing => {
457 crate::editing_page::render_editing_page(window, cx).into_any_element()
458 }
459 SelectedPage::AiSetup => {
460 crate::ai_setup_page::render_ai_setup_page(&self, window, cx).into_any_element()
461 }
462 }
463 }
464}
465
466impl Render for Onboarding {
467 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
468 h_flex()
469 .image_cache(gpui::retain_all("onboarding-page"))
470 .key_context({
471 let mut ctx = KeyContext::new_with_defaults();
472 ctx.add("Onboarding");
473 ctx
474 })
475 .track_focus(&self.focus_handle)
476 .size_full()
477 .bg(cx.theme().colors().editor_background)
478 .on_action(cx.listener(|this, _: &ActivateBasicsPage, _, cx| {
479 this.set_page(SelectedPage::Basics, cx);
480 }))
481 .on_action(cx.listener(|this, _: &ActivateEditingPage, _, cx| {
482 this.set_page(SelectedPage::Editing, cx);
483 }))
484 .on_action(cx.listener(|this, _: &ActivateAISetupPage, _, cx| {
485 this.set_page(SelectedPage::AiSetup, cx);
486 }))
487 .child(
488 h_flex()
489 .max_w(rems_from_px(1100.))
490 .size_full()
491 .m_auto()
492 .py_20()
493 .px_12()
494 .items_start()
495 .gap_12()
496 .child(self.render_nav(window, cx))
497 .child(
498 v_flex()
499 .max_w_full()
500 .min_w_0()
501 .pl_12()
502 .border_l_1()
503 .border_color(cx.theme().colors().border_variant.opacity(0.5))
504 .size_full()
505 .child(self.render_page(window, cx)),
506 ),
507 )
508 }
509}
510
511impl EventEmitter<ItemEvent> for Onboarding {}
512
513impl Focusable for Onboarding {
514 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
515 self.focus_handle.clone()
516 }
517}
518
519impl Item for Onboarding {
520 type Event = ItemEvent;
521
522 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
523 "Onboarding".into()
524 }
525
526 fn telemetry_event_text(&self) -> Option<&'static str> {
527 Some("Onboarding Page Opened")
528 }
529
530 fn show_toolbar(&self) -> bool {
531 false
532 }
533
534 fn clone_on_split(
535 &self,
536 _workspace_id: Option<WorkspaceId>,
537 _: &mut Window,
538 cx: &mut Context<Self>,
539 ) -> Option<Entity<Self>> {
540 self.workspace
541 .update(cx, |workspace, cx| Onboarding::new(workspace, cx))
542 .ok()
543 }
544
545 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
546 f(*event)
547 }
548}
549
550pub async fn handle_import_vscode_settings(
551 source: VsCodeSettingsSource,
552 skip_prompt: bool,
553 fs: Arc<dyn Fs>,
554 cx: &mut AsyncWindowContext,
555) {
556 use util::truncate_and_remove_front;
557
558 let vscode_settings =
559 match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
560 Ok(vscode_settings) => vscode_settings,
561 Err(err) => {
562 zlog::error!("{err}");
563 let _ = cx.prompt(
564 gpui::PromptLevel::Info,
565 &format!("Could not find or load a {source} settings file"),
566 None,
567 &["Ok"],
568 );
569 return;
570 }
571 };
572
573 if !skip_prompt {
574 let prompt = cx.prompt(
575 gpui::PromptLevel::Warning,
576 &format!(
577 "Importing {} settings may overwrite your existing settings. \
578 Will import settings from {}",
579 vscode_settings.source,
580 truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
581 ),
582 None,
583 &["Ok", "Cancel"],
584 );
585 let result = cx.spawn(async move |_| prompt.await.ok()).await;
586 if result != Some(0) {
587 return;
588 }
589 };
590
591 cx.update(|_, cx| {
592 let source = vscode_settings.source;
593 let path = vscode_settings.path.clone();
594 cx.global::<SettingsStore>()
595 .import_vscode_settings(fs, vscode_settings);
596 zlog::info!("Imported {source} settings from {}", path.display());
597 })
598 .ok();
599}
600
601impl workspace::SerializableItem for Onboarding {
602 fn serialized_item_kind() -> &'static str {
603 "OnboardingPage"
604 }
605
606 fn cleanup(
607 workspace_id: workspace::WorkspaceId,
608 alive_items: Vec<workspace::ItemId>,
609 _window: &mut Window,
610 cx: &mut App,
611 ) -> gpui::Task<gpui::Result<()>> {
612 workspace::delete_unloaded_items(
613 alive_items,
614 workspace_id,
615 "onboarding_pages",
616 &persistence::ONBOARDING_PAGES,
617 cx,
618 )
619 }
620
621 fn deserialize(
622 _project: Entity<project::Project>,
623 workspace: WeakEntity<Workspace>,
624 workspace_id: workspace::WorkspaceId,
625 item_id: workspace::ItemId,
626 window: &mut Window,
627 cx: &mut App,
628 ) -> gpui::Task<gpui::Result<Entity<Self>>> {
629 window.spawn(cx, async move |cx| {
630 if let Some(page_number) =
631 persistence::ONBOARDING_PAGES.get_onboarding_page(item_id, workspace_id)?
632 {
633 let page = match page_number {
634 0 => Some(SelectedPage::Basics),
635 1 => Some(SelectedPage::Editing),
636 2 => Some(SelectedPage::AiSetup),
637 _ => None,
638 };
639 workspace.update(cx, |workspace, cx| {
640 let onboarding_page = Onboarding::new(workspace, cx);
641 if let Some(page) = page {
642 zlog::info!("Onboarding page {page:?} loaded");
643 onboarding_page.update(cx, |onboarding_page, cx| {
644 onboarding_page.set_page(page, cx);
645 })
646 }
647 onboarding_page
648 })
649 } else {
650 Err(anyhow::anyhow!("No onboarding page to deserialize"))
651 }
652 })
653 }
654
655 fn serialize(
656 &mut self,
657 workspace: &mut Workspace,
658 item_id: workspace::ItemId,
659 _closing: bool,
660 _window: &mut Window,
661 cx: &mut ui::Context<Self>,
662 ) -> Option<gpui::Task<gpui::Result<()>>> {
663 let workspace_id = workspace.database_id()?;
664 let page_number = self.selected_page as u16;
665 Some(cx.background_spawn(async move {
666 persistence::ONBOARDING_PAGES
667 .save_onboarding_page(item_id, workspace_id, page_number)
668 .await
669 }))
670 }
671
672 fn should_serialize(&self, event: &Self::Event) -> bool {
673 event == &ItemEvent::UpdateTab
674 }
675}
676
677mod persistence {
678 use db::{define_connection, query, sqlez_macros::sql};
679 use workspace::WorkspaceDb;
680
681 define_connection! {
682 pub static ref ONBOARDING_PAGES: OnboardingPagesDb<WorkspaceDb> =
683 &[
684 sql!(
685 CREATE TABLE onboarding_pages (
686 workspace_id INTEGER,
687 item_id INTEGER UNIQUE,
688 page_number INTEGER,
689
690 PRIMARY KEY(workspace_id, item_id),
691 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
692 ON DELETE CASCADE
693 ) STRICT;
694 ),
695 ];
696 }
697
698 impl OnboardingPagesDb {
699 query! {
700 pub async fn save_onboarding_page(
701 item_id: workspace::ItemId,
702 workspace_id: workspace::WorkspaceId,
703 page_number: u16
704 ) -> Result<()> {
705 INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id, page_number)
706 VALUES (?, ?, ?)
707 }
708 }
709
710 query! {
711 pub fn get_onboarding_page(
712 item_id: workspace::ItemId,
713 workspace_id: workspace::WorkspaceId
714 ) -> Result<Option<u16>> {
715 SELECT page_number
716 FROM onboarding_pages
717 WHERE item_id = ? AND workspace_id = ?
718 }
719 }
720 }
721}