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, Render, SharedString, Subscription, Task, WeakEntity,
10 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, 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
68pub fn init(cx: &mut App) {
69 cx.on_action(|_: &OpenOnboarding, cx| {
70 with_active_or_new_workspace(cx, |workspace, window, cx| {
71 workspace
72 .with_local_workspace(window, cx, |workspace, window, cx| {
73 let existing = workspace
74 .active_pane()
75 .read(cx)
76 .items()
77 .find_map(|item| item.downcast::<Onboarding>());
78
79 if let Some(existing) = existing {
80 workspace.activate_item(&existing, true, true, window, cx);
81 } else {
82 let settings_page = Onboarding::new(workspace, cx);
83 workspace.add_item_to_active_pane(
84 Box::new(settings_page),
85 None,
86 true,
87 window,
88 cx,
89 )
90 }
91 })
92 .detach();
93 });
94 });
95
96 cx.on_action(|_: &ShowWelcome, cx| {
97 with_active_or_new_workspace(cx, |workspace, window, cx| {
98 workspace
99 .with_local_workspace(window, cx, |workspace, window, cx| {
100 let existing = workspace
101 .active_pane()
102 .read(cx)
103 .items()
104 .find_map(|item| item.downcast::<WelcomePage>());
105
106 if let Some(existing) = existing {
107 workspace.activate_item(&existing, true, true, window, cx);
108 } else {
109 let settings_page = WelcomePage::new(window, cx);
110 workspace.add_item_to_active_pane(
111 Box::new(settings_page),
112 None,
113 true,
114 window,
115 cx,
116 )
117 }
118 })
119 .detach();
120 });
121 });
122
123 cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
124 workspace.register_action(|_workspace, action: &ImportVsCodeSettings, window, cx| {
125 let fs = <dyn Fs>::global(cx);
126 let action = *action;
127
128 window
129 .spawn(cx, async move |cx: &mut AsyncWindowContext| {
130 handle_import_vscode_settings(
131 VsCodeSettingsSource::VsCode,
132 action.skip_prompt,
133 fs,
134 cx,
135 )
136 .await
137 })
138 .detach();
139 });
140
141 workspace.register_action(|_workspace, action: &ImportCursorSettings, window, cx| {
142 let fs = <dyn Fs>::global(cx);
143 let action = *action;
144
145 window
146 .spawn(cx, async move |cx: &mut AsyncWindowContext| {
147 handle_import_vscode_settings(
148 VsCodeSettingsSource::Cursor,
149 action.skip_prompt,
150 fs,
151 cx,
152 )
153 .await
154 })
155 .detach();
156 });
157 })
158 .detach();
159
160 cx.observe_new::<Workspace>(|_, window, cx| {
161 let Some(window) = window else {
162 return;
163 };
164
165 let onboarding_actions = [
166 std::any::TypeId::of::<OpenOnboarding>(),
167 std::any::TypeId::of::<ShowWelcome>(),
168 ];
169
170 CommandPaletteFilter::update_global(cx, |filter, _cx| {
171 filter.hide_action_types(&onboarding_actions);
172 });
173
174 cx.observe_flag::<OnBoardingFeatureFlag, _>(window, move |is_enabled, _, _, cx| {
175 if is_enabled {
176 CommandPaletteFilter::update_global(cx, |filter, _cx| {
177 filter.show_action_types(onboarding_actions.iter());
178 });
179 } else {
180 CommandPaletteFilter::update_global(cx, |filter, _cx| {
181 filter.hide_action_types(&onboarding_actions);
182 });
183 }
184 })
185 .detach();
186 })
187 .detach();
188}
189
190pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyhow::Result<()>> {
191 open_new(
192 Default::default(),
193 app_state,
194 cx,
195 |workspace, window, cx| {
196 {
197 workspace.toggle_dock(DockPosition::Left, window, cx);
198 let onboarding_page = Onboarding::new(workspace, cx);
199 workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
200
201 window.focus(&onboarding_page.focus_handle(cx));
202
203 cx.notify();
204 };
205 db::write_and_log(cx, || {
206 KEY_VALUE_STORE.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
207 });
208 },
209 )
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213enum SelectedPage {
214 Basics,
215 Editing,
216 AiSetup,
217}
218
219struct Onboarding {
220 workspace: WeakEntity<Workspace>,
221 focus_handle: FocusHandle,
222 selected_page: SelectedPage,
223 user_store: Entity<UserStore>,
224 _settings_subscription: Subscription,
225}
226
227impl Onboarding {
228 fn new(workspace: &Workspace, cx: &mut App) -> Entity<Self> {
229 cx.new(|cx| Self {
230 workspace: workspace.weak_handle(),
231 focus_handle: cx.focus_handle(),
232 selected_page: SelectedPage::Basics,
233 user_store: workspace.user_store().clone(),
234 _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
235 })
236 }
237
238 fn render_nav_button(
239 &mut self,
240 page: SelectedPage,
241 _: &mut Window,
242 cx: &mut Context<Self>,
243 ) -> impl IntoElement {
244 let text = match page {
245 SelectedPage::Basics => "Basics",
246 SelectedPage::Editing => "Editing",
247 SelectedPage::AiSetup => "AI Setup",
248 };
249
250 let binding = match page {
251 SelectedPage::Basics => {
252 KeyBinding::new(vec![gpui::Keystroke::parse("cmd-1").unwrap()], cx)
253 .map(|kb| kb.size(rems_from_px(12.)))
254 }
255 SelectedPage::Editing => {
256 KeyBinding::new(vec![gpui::Keystroke::parse("cmd-2").unwrap()], cx)
257 .map(|kb| kb.size(rems_from_px(12.)))
258 }
259 SelectedPage::AiSetup => {
260 KeyBinding::new(vec![gpui::Keystroke::parse("cmd-3").unwrap()], cx)
261 .map(|kb| kb.size(rems_from_px(12.)))
262 }
263 };
264
265 let selected = self.selected_page == page;
266
267 h_flex()
268 .id(text)
269 .relative()
270 .w_full()
271 .gap_2()
272 .px_2()
273 .py_0p5()
274 .justify_between()
275 .rounded_sm()
276 .when(selected, |this| {
277 this.child(
278 div()
279 .h_4()
280 .w_px()
281 .bg(cx.theme().colors().text_accent)
282 .absolute()
283 .left_0(),
284 )
285 })
286 .hover(|style| style.bg(cx.theme().colors().element_hover))
287 .child(Label::new(text).map(|this| {
288 if selected {
289 this.color(Color::Default)
290 } else {
291 this.color(Color::Muted)
292 }
293 }))
294 .child(binding)
295 .on_click(cx.listener(move |this, _, _, cx| {
296 this.selected_page = page;
297 cx.notify();
298 }))
299 }
300
301 fn render_nav(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
302 v_flex()
303 .h_full()
304 .w(rems_from_px(220.))
305 .flex_shrink_0()
306 .gap_4()
307 .justify_between()
308 .child(
309 v_flex()
310 .gap_6()
311 .child(
312 h_flex()
313 .px_2()
314 .gap_4()
315 .child(Vector::square(VectorName::ZedLogo, rems(2.5)))
316 .child(
317 v_flex()
318 .child(
319 Headline::new("Welcome to Zed").size(HeadlineSize::Small),
320 )
321 .child(
322 Label::new("The editor for what's next")
323 .color(Color::Muted)
324 .size(LabelSize::Small)
325 .italic(),
326 ),
327 ),
328 )
329 .child(
330 v_flex()
331 .gap_4()
332 .child(
333 v_flex()
334 .py_4()
335 .border_y_1()
336 .border_color(cx.theme().colors().border_variant.opacity(0.5))
337 .gap_1()
338 .children([
339 self.render_nav_button(SelectedPage::Basics, window, cx)
340 .into_element(),
341 self.render_nav_button(SelectedPage::Editing, window, cx)
342 .into_element(),
343 self.render_nav_button(SelectedPage::AiSetup, window, cx)
344 .into_element(),
345 ]),
346 )
347 .child(
348 ButtonLike::new("skip_all")
349 .child(Label::new("Skip All").ml_1())
350 .on_click(|_, _, cx| {
351 with_active_or_new_workspace(
352 cx,
353 |workspace, window, cx| {
354 let Some((onboarding_id, onboarding_idx)) =
355 workspace
356 .active_pane()
357 .read(cx)
358 .items()
359 .enumerate()
360 .find_map(|(idx, item)| {
361 let _ =
362 item.downcast::<Onboarding>()?;
363 Some((item.item_id(), idx))
364 })
365 else {
366 return;
367 };
368
369 workspace.active_pane().update(cx, |pane, cx| {
370 // Get the index here to get around the borrow checker
371 let idx = pane.items().enumerate().find_map(
372 |(idx, item)| {
373 let _ =
374 item.downcast::<WelcomePage>()?;
375 Some(idx)
376 },
377 );
378
379 if let Some(idx) = idx {
380 pane.activate_item(
381 idx, true, true, window, cx,
382 );
383 } else {
384 let item =
385 Box::new(WelcomePage::new(window, cx));
386 pane.add_item(
387 item,
388 true,
389 true,
390 Some(onboarding_idx),
391 window,
392 cx,
393 );
394 }
395
396 pane.remove_item(
397 onboarding_id,
398 false,
399 false,
400 window,
401 cx,
402 );
403 });
404 },
405 );
406 }),
407 ),
408 ),
409 )
410 .child(
411 if let Some(user) = self.user_store.read(cx).current_user() {
412 h_flex()
413 .pl_1p5()
414 .gap_2()
415 .child(Avatar::new(user.avatar_uri.clone()))
416 .child(Label::new(user.github_login.clone()))
417 .into_any_element()
418 } else {
419 Button::new("sign_in", "Sign In")
420 .style(ButtonStyle::Outlined)
421 .full_width()
422 .on_click(|_, window, cx| {
423 let client = Client::global(cx);
424 window
425 .spawn(cx, async move |cx| {
426 client
427 .sign_in_with_optional_connect(true, &cx)
428 .await
429 .notify_async_err(cx);
430 })
431 .detach();
432 })
433 .into_any_element()
434 },
435 )
436 }
437
438 fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
439 match self.selected_page {
440 SelectedPage::Basics => {
441 crate::basics_page::render_basics_page(window, cx).into_any_element()
442 }
443 SelectedPage::Editing => {
444 crate::editing_page::render_editing_page(window, cx).into_any_element()
445 }
446 SelectedPage::AiSetup => {
447 crate::ai_setup_page::render_ai_setup_page(&self, window, cx).into_any_element()
448 }
449 }
450 }
451}
452
453impl Render for Onboarding {
454 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
455 h_flex()
456 .image_cache(gpui::retain_all("onboarding-page"))
457 .key_context("onboarding-page")
458 .size_full()
459 .bg(cx.theme().colors().editor_background)
460 .child(
461 h_flex()
462 .max_w(rems_from_px(1100.))
463 .size_full()
464 .m_auto()
465 .py_20()
466 .px_12()
467 .items_start()
468 .gap_12()
469 .child(self.render_nav(window, cx))
470 .child(
471 v_flex()
472 .max_w_full()
473 .min_w_0()
474 .pl_12()
475 .border_l_1()
476 .border_color(cx.theme().colors().border_variant.opacity(0.5))
477 .size_full()
478 .child(self.render_page(window, cx)),
479 ),
480 )
481 }
482}
483
484impl EventEmitter<ItemEvent> for Onboarding {}
485
486impl Focusable for Onboarding {
487 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
488 self.focus_handle.clone()
489 }
490}
491
492impl Item for Onboarding {
493 type Event = ItemEvent;
494
495 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
496 "Onboarding".into()
497 }
498
499 fn telemetry_event_text(&self) -> Option<&'static str> {
500 Some("Onboarding Page Opened")
501 }
502
503 fn show_toolbar(&self) -> bool {
504 false
505 }
506
507 fn clone_on_split(
508 &self,
509 _workspace_id: Option<WorkspaceId>,
510 _: &mut Window,
511 cx: &mut Context<Self>,
512 ) -> Option<Entity<Self>> {
513 self.workspace
514 .update(cx, |workspace, cx| Onboarding::new(workspace, cx))
515 .ok()
516 }
517
518 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
519 f(*event)
520 }
521}
522
523pub async fn handle_import_vscode_settings(
524 source: VsCodeSettingsSource,
525 skip_prompt: bool,
526 fs: Arc<dyn Fs>,
527 cx: &mut AsyncWindowContext,
528) {
529 use util::truncate_and_remove_front;
530
531 let vscode_settings =
532 match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
533 Ok(vscode_settings) => vscode_settings,
534 Err(err) => {
535 zlog::error!("{err}");
536 let _ = cx.prompt(
537 gpui::PromptLevel::Info,
538 &format!("Could not find or load a {source} settings file"),
539 None,
540 &["Ok"],
541 );
542 return;
543 }
544 };
545
546 if !skip_prompt {
547 let prompt = cx.prompt(
548 gpui::PromptLevel::Warning,
549 &format!(
550 "Importing {} settings may overwrite your existing settings. \
551 Will import settings from {}",
552 vscode_settings.source,
553 truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
554 ),
555 None,
556 &["Ok", "Cancel"],
557 );
558 let result = cx.spawn(async move |_| prompt.await.ok()).await;
559 if result != Some(0) {
560 return;
561 }
562 };
563
564 cx.update(|_, cx| {
565 let source = vscode_settings.source;
566 let path = vscode_settings.path.clone();
567 cx.global::<SettingsStore>()
568 .import_vscode_settings(fs, vscode_settings);
569 zlog::info!("Imported {source} settings from {}", path.display());
570 })
571 .ok();
572}