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