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 theme::{Theme, ThemeRegistry};
17use ui::{Avatar, FluentBuilder, KeyBinding, Vector, VectorName, prelude::*, rems_from_px};
18use workspace::{
19 AppState, Workspace, WorkspaceId,
20 dock::DockPosition,
21 item::{Item, ItemEvent},
22 notifications::NotifyResultExt as _,
23 open_new, with_active_or_new_workspace,
24};
25
26mod basics_page;
27mod editing_page;
28mod welcome;
29
30pub struct OnBoardingFeatureFlag {}
31
32impl FeatureFlag for OnBoardingFeatureFlag {
33 const NAME: &'static str = "onboarding";
34}
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";
55
56actions!(
57 zed,
58 [
59 /// Opens the onboarding view.
60 OpenOnboarding
61 ]
62);
63
64pub fn init(cx: &mut App) {
65 cx.on_action(|_: &OpenOnboarding, cx| {
66 with_active_or_new_workspace(cx, |workspace, window, cx| {
67 workspace
68 .with_local_workspace(window, cx, |workspace, window, cx| {
69 let existing = workspace
70 .active_pane()
71 .read(cx)
72 .items()
73 .find_map(|item| item.downcast::<Onboarding>());
74
75 if let Some(existing) = existing {
76 workspace.activate_item(&existing, true, true, window, cx);
77 } else {
78 let settings_page = Onboarding::new(
79 workspace.weak_handle(),
80 workspace.user_store().clone(),
81 cx,
82 );
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 =
199 Onboarding::new(workspace.weak_handle(), workspace.user_store().clone(), cx);
200 workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
201
202 window.focus(&onboarding_page.focus_handle(cx));
203
204 cx.notify();
205 };
206 db::write_and_log(cx, || {
207 KEY_VALUE_STORE.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
208 });
209 },
210 )
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214enum SelectedPage {
215 Basics,
216 Editing,
217 AiSetup,
218}
219
220struct Onboarding {
221 workspace: WeakEntity<Workspace>,
222 light_themes: [Arc<Theme>; 3],
223 dark_themes: [Arc<Theme>; 3],
224 focus_handle: FocusHandle,
225 selected_page: SelectedPage,
226 fs: Arc<dyn Fs>,
227 user_store: Entity<UserStore>,
228 _settings_subscription: Subscription,
229}
230
231impl Onboarding {
232 fn new(
233 workspace: WeakEntity<Workspace>,
234 user_store: Entity<UserStore>,
235 cx: &mut App,
236 ) -> Entity<Self> {
237 let theme_registry = ThemeRegistry::global(cx);
238
239 let one_dark = theme_registry
240 .get("One Dark")
241 .expect("Default themes are always present");
242 let ayu_dark = theme_registry
243 .get("Ayu Dark")
244 .expect("Default themes are always present");
245 let gruvbox_dark = theme_registry
246 .get("Gruvbox Dark")
247 .expect("Default themes are always present");
248
249 let one_light = theme_registry
250 .get("One Light")
251 .expect("Default themes are always present");
252 let ayu_light = theme_registry
253 .get("Ayu Light")
254 .expect("Default themes are always present");
255 let gruvbox_light = theme_registry
256 .get("Gruvbox Light")
257 .expect("Default themes are always present");
258
259 cx.new(|cx| Self {
260 workspace,
261 user_store,
262 focus_handle: cx.focus_handle(),
263 light_themes: [one_light, ayu_light, gruvbox_light],
264 dark_themes: [one_dark, ayu_dark, gruvbox_dark],
265 selected_page: SelectedPage::Basics,
266 fs: <dyn Fs>::global(cx),
267 _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
268 })
269 }
270
271 fn render_nav_button(
272 &mut self,
273 page: SelectedPage,
274 _: &mut Window,
275 cx: &mut Context<Self>,
276 ) -> impl IntoElement {
277 let text = match page {
278 SelectedPage::Basics => "Basics",
279 SelectedPage::Editing => "Editing",
280 SelectedPage::AiSetup => "AI Setup",
281 };
282
283 let binding = match page {
284 SelectedPage::Basics => {
285 KeyBinding::new(vec![gpui::Keystroke::parse("cmd-1").unwrap()], cx)
286 .map(|kb| kb.size(rems_from_px(12.)))
287 }
288 SelectedPage::Editing => {
289 KeyBinding::new(vec![gpui::Keystroke::parse("cmd-2").unwrap()], cx)
290 .map(|kb| kb.size(rems_from_px(12.)))
291 }
292 SelectedPage::AiSetup => {
293 KeyBinding::new(vec![gpui::Keystroke::parse("cmd-3").unwrap()], cx)
294 .map(|kb| kb.size(rems_from_px(12.)))
295 }
296 };
297
298 let selected = self.selected_page == page;
299
300 h_flex()
301 .id(text)
302 .relative()
303 .w_full()
304 .gap_2()
305 .px_2()
306 .py_0p5()
307 .justify_between()
308 .rounded_sm()
309 .when(selected, |this| {
310 this.child(
311 div()
312 .h_4()
313 .w_px()
314 .bg(cx.theme().colors().text_accent)
315 .absolute()
316 .left_0(),
317 )
318 })
319 .hover(|style| style.bg(cx.theme().colors().element_hover))
320 .child(Label::new(text).map(|this| {
321 if selected {
322 this.color(Color::Default)
323 } else {
324 this.color(Color::Muted)
325 }
326 }))
327 .child(binding)
328 .on_click(cx.listener(move |this, _, _, cx| {
329 this.selected_page = page;
330 cx.notify();
331 }))
332 }
333
334 fn render_nav(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
335 v_flex()
336 .h_full()
337 .w(rems_from_px(220.))
338 .flex_shrink_0()
339 .gap_4()
340 .justify_between()
341 .child(
342 v_flex()
343 .gap_6()
344 .child(
345 h_flex()
346 .px_2()
347 .gap_4()
348 .child(Vector::square(VectorName::ZedLogo, rems(2.5)))
349 .child(
350 v_flex()
351 .child(
352 Headline::new("Welcome to Zed").size(HeadlineSize::Small),
353 )
354 .child(
355 Label::new("The editor for what's next")
356 .color(Color::Muted)
357 .size(LabelSize::Small)
358 .italic(),
359 ),
360 ),
361 )
362 .child(
363 v_flex()
364 .gap_4()
365 .child(
366 v_flex()
367 .py_4()
368 .border_y_1()
369 .border_color(cx.theme().colors().border_variant.opacity(0.5))
370 .gap_1()
371 .children([
372 self.render_nav_button(SelectedPage::Basics, window, cx)
373 .into_element(),
374 self.render_nav_button(SelectedPage::Editing, window, cx)
375 .into_element(),
376 self.render_nav_button(SelectedPage::AiSetup, window, cx)
377 .into_element(),
378 ]),
379 )
380 .child(Button::new("skip_all", "Skip All")),
381 ),
382 )
383 .child(
384 if let Some(user) = self.user_store.read(cx).current_user() {
385 h_flex()
386 .gap_2()
387 .child(Avatar::new(user.avatar_uri.clone()))
388 .child(Label::new(user.github_login.clone()))
389 .into_any_element()
390 } else {
391 Button::new("sign_in", "Sign In")
392 .style(ButtonStyle::Outlined)
393 .full_width()
394 .on_click(|_, window, cx| {
395 let client = Client::global(cx);
396 window
397 .spawn(cx, async move |cx| {
398 client
399 .authenticate_and_connect(true, &cx)
400 .await
401 .into_response()
402 .notify_async_err(cx);
403 })
404 .detach();
405 })
406 .into_any_element()
407 },
408 )
409 }
410
411 fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
412 match self.selected_page {
413 SelectedPage::Basics => {
414 crate::basics_page::render_basics_page(&self, cx).into_any_element()
415 }
416 SelectedPage::Editing => {
417 crate::editing_page::render_editing_page(window, cx).into_any_element()
418 }
419 SelectedPage::AiSetup => self.render_ai_setup_page(window, cx).into_any_element(),
420 }
421 }
422
423 fn render_ai_setup_page(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
424 div().child("ai setup page")
425 }
426}
427
428impl Render for Onboarding {
429 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
430 h_flex()
431 .image_cache(gpui::retain_all("onboarding-page"))
432 .key_context("onboarding-page")
433 .size_full()
434 .bg(cx.theme().colors().editor_background)
435 .child(
436 h_flex()
437 .max_w(rems_from_px(1100.))
438 .size_full()
439 .m_auto()
440 .py_20()
441 .px_12()
442 .items_start()
443 .gap_12()
444 .child(self.render_nav(window, cx))
445 .child(
446 div()
447 .pl_12()
448 .border_l_1()
449 .border_color(cx.theme().colors().border_variant.opacity(0.5))
450 .size_full()
451 .child(self.render_page(window, cx)),
452 ),
453 )
454 }
455}
456
457impl EventEmitter<ItemEvent> for Onboarding {}
458
459impl Focusable for Onboarding {
460 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
461 self.focus_handle.clone()
462 }
463}
464
465impl Item for Onboarding {
466 type Event = ItemEvent;
467
468 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
469 "Onboarding".into()
470 }
471
472 fn telemetry_event_text(&self) -> Option<&'static str> {
473 Some("Onboarding Page Opened")
474 }
475
476 fn show_toolbar(&self) -> bool {
477 false
478 }
479
480 fn clone_on_split(
481 &self,
482 _workspace_id: Option<WorkspaceId>,
483 _: &mut Window,
484 cx: &mut Context<Self>,
485 ) -> Option<Entity<Self>> {
486 Some(Onboarding::new(
487 self.workspace.clone(),
488 self.user_store.clone(),
489 cx,
490 ))
491 }
492
493 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
494 f(*event)
495 }
496}
497
498pub async fn handle_import_vscode_settings(
499 source: VsCodeSettingsSource,
500 skip_prompt: bool,
501 fs: Arc<dyn Fs>,
502 cx: &mut AsyncWindowContext,
503) {
504 use util::truncate_and_remove_front;
505
506 let vscode_settings =
507 match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
508 Ok(vscode_settings) => vscode_settings,
509 Err(err) => {
510 zlog::error!("{err}");
511 let _ = cx.prompt(
512 gpui::PromptLevel::Info,
513 &format!("Could not find or load a {source} settings file"),
514 None,
515 &["Ok"],
516 );
517 return;
518 }
519 };
520
521 if !skip_prompt {
522 let prompt = cx.prompt(
523 gpui::PromptLevel::Warning,
524 &format!(
525 "Importing {} settings may overwrite your existing settings. \
526 Will import settings from {}",
527 vscode_settings.source,
528 truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
529 ),
530 None,
531 &["Ok", "Cancel"],
532 );
533 let result = cx.spawn(async move |_| prompt.await.ok()).await;
534 if result != Some(0) {
535 return;
536 }
537 };
538
539 cx.update(|_, cx| {
540 let source = vscode_settings.source;
541 let path = vscode_settings.path.clone();
542 cx.global::<SettingsStore>()
543 .import_vscode_settings(fs, vscode_settings);
544 zlog::info!("Imported {source} settings from {}", path.display());
545 })
546 .ok();
547}