1#![allow(unused, dead_code)]
2mod persistence;
3
4use client::Client;
5use command_palette_hooks::CommandPaletteFilter;
6use feature_flags::FeatureFlagAppExt as _;
7use gpui::{
8 Entity, EventEmitter, FocusHandle, Focusable, KeyBinding, Task, WeakEntity, actions, prelude::*,
9};
10use persistence::ONBOARDING_DB;
11
12use project::Project;
13use settings_ui::SettingsUiFeatureFlag;
14use std::sync::Arc;
15use ui::{ListItem, Vector, VectorName, prelude::*};
16use util::ResultExt;
17use workspace::{
18 Workspace, WorkspaceId,
19 item::{Item, ItemEvent, SerializableItem},
20 notifications::NotifyResultExt,
21};
22
23actions!(
24 onboarding,
25 [
26 ShowOnboarding,
27 JumpToBasics,
28 JumpToEditing,
29 JumpToAiSetup,
30 JumpToWelcome,
31 NextPage,
32 PreviousPage,
33 ToggleFocus,
34 ResetOnboarding,
35 ]
36);
37
38pub fn init(cx: &mut App) {
39 cx.observe_new(|workspace: &mut Workspace, _, _cx| {
40 workspace.register_action(|workspace, _: &ShowOnboarding, window, cx| {
41 let client = workspace.client().clone();
42 let onboarding = cx.new(|cx| OnboardingUI::new(workspace, client, cx));
43 workspace.add_item_to_active_pane(Box::new(onboarding), None, true, window, cx);
44 });
45 })
46 .detach();
47
48 workspace::register_serializable_item::<OnboardingUI>(cx);
49
50 feature_gate_onboarding_ui_actions(cx);
51}
52
53fn feature_gate_onboarding_ui_actions(cx: &mut App) {
54 const ONBOARDING_ACTION_NAMESPACE: &str = "onboarding";
55
56 CommandPaletteFilter::update_global(cx, |filter, _cx| {
57 filter.hide_namespace(ONBOARDING_ACTION_NAMESPACE);
58 });
59
60 cx.observe_flag::<SettingsUiFeatureFlag, _>({
61 move |is_enabled, cx| {
62 CommandPaletteFilter::update_global(cx, |filter, _cx| {
63 if is_enabled {
64 filter.show_namespace(ONBOARDING_ACTION_NAMESPACE);
65 } else {
66 filter.hide_namespace(ONBOARDING_ACTION_NAMESPACE);
67 }
68 });
69 }
70 })
71 .detach();
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum OnboardingPage {
76 Basics,
77 Editing,
78 AiSetup,
79 Welcome,
80}
81
82impl OnboardingPage {
83 fn next(&self) -> Option<Self> {
84 match self {
85 Self::Basics => Some(Self::Editing),
86 Self::Editing => Some(Self::AiSetup),
87 Self::AiSetup => Some(Self::Welcome),
88 Self::Welcome => None,
89 }
90 }
91
92 fn previous(&self) -> Option<Self> {
93 match self {
94 Self::Basics => None,
95 Self::Editing => Some(Self::Basics),
96 Self::AiSetup => Some(Self::Editing),
97 Self::Welcome => Some(Self::AiSetup),
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum OnboardingFocus {
104 Navigation,
105 Page,
106}
107
108pub struct OnboardingUI {
109 focus_handle: FocusHandle,
110 current_page: OnboardingPage,
111 current_focus: OnboardingFocus,
112 completed_pages: [bool; 4],
113
114 // Workspace reference for Item trait
115 workspace: WeakEntity<Workspace>,
116 workspace_id: Option<WorkspaceId>,
117 client: Arc<Client>,
118}
119
120impl EventEmitter<ItemEvent> for OnboardingUI {}
121
122impl Focusable for OnboardingUI {
123 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
124 self.focus_handle.clone()
125 }
126}
127
128#[derive(Clone)]
129pub enum OnboardingEvent {
130 PageCompleted(OnboardingPage),
131}
132
133impl Render for OnboardingUI {
134 fn render(
135 &mut self,
136 window: &mut gpui::Window,
137 cx: &mut Context<Self>,
138 ) -> impl gpui::IntoElement {
139 div()
140 .bg(cx.theme().colors().editor_background)
141 .size_full()
142 .flex()
143 .items_center()
144 .justify_center()
145 .overflow_hidden()
146 .child(
147 h_flex()
148 .id("onboarding-ui")
149 .key_context("Onboarding")
150 .track_focus(&self.focus_handle)
151 .on_action(cx.listener(Self::handle_jump_to_basics))
152 .on_action(cx.listener(Self::handle_jump_to_editing))
153 .on_action(cx.listener(Self::handle_jump_to_ai_setup))
154 .on_action(cx.listener(Self::handle_jump_to_welcome))
155 .on_action(cx.listener(Self::handle_next_page))
156 .on_action(cx.listener(Self::handle_previous_page))
157 .w(px(904.))
158 .h(px(500.))
159 .gap(px(48.))
160 .child(self.render_navigation(window, cx))
161 .child(
162 v_flex()
163 .h_full()
164 .flex_1()
165 .child(div().flex_1().child(self.render_active_page(window, cx))),
166 ),
167 )
168 }
169}
170
171impl OnboardingUI {
172 pub fn new(workspace: &Workspace, client: Arc<Client>, cx: &mut Context<Self>) -> Self {
173 Self {
174 focus_handle: cx.focus_handle(),
175 current_page: OnboardingPage::Basics,
176 current_focus: OnboardingFocus::Page,
177 completed_pages: [false; 4],
178 workspace: workspace.weak_handle(),
179 workspace_id: workspace.database_id(),
180 client,
181 }
182 }
183
184 fn completed_pages_to_string(&self) -> String {
185 self.completed_pages
186 .iter()
187 .map(|&completed| if completed { '1' } else { '0' })
188 .collect()
189 }
190
191 fn completed_pages_from_string(s: &str) -> [bool; 4] {
192 let mut result = [false; 4];
193 for (i, ch) in s.chars().take(4).enumerate() {
194 result[i] = ch == '1';
195 }
196 result
197 }
198
199 fn jump_to_page(
200 &mut self,
201 page: OnboardingPage,
202 _window: &mut gpui::Window,
203 cx: &mut Context<Self>,
204 ) {
205 self.current_page = page;
206 cx.emit(ItemEvent::UpdateTab);
207 cx.notify();
208 }
209
210 fn next_page(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) {
211 if let Some(next) = self.current_page.next() {
212 self.current_page = next;
213 cx.notify();
214 }
215 }
216
217 fn previous_page(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) {
218 if let Some(prev) = self.current_page.previous() {
219 self.current_page = prev;
220 cx.notify();
221 }
222 }
223
224 fn toggle_focus(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) {
225 self.current_focus = match self.current_focus {
226 OnboardingFocus::Navigation => OnboardingFocus::Page,
227 OnboardingFocus::Page => OnboardingFocus::Navigation,
228 };
229 cx.notify();
230 }
231
232 fn reset(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) {
233 self.current_page = OnboardingPage::Basics;
234 self.current_focus = OnboardingFocus::Page;
235 self.completed_pages = [false; 4];
236 cx.notify();
237 }
238
239 fn mark_page_completed(
240 &mut self,
241 page: OnboardingPage,
242 _window: &mut gpui::Window,
243 cx: &mut Context<Self>,
244 ) {
245 let index = match page {
246 OnboardingPage::Basics => 0,
247 OnboardingPage::Editing => 1,
248 OnboardingPage::AiSetup => 2,
249 OnboardingPage::Welcome => 3,
250 };
251 self.completed_pages[index] = true;
252 cx.notify();
253 }
254
255 fn handle_jump_to_basics(
256 &mut self,
257 _: &JumpToBasics,
258 window: &mut Window,
259 cx: &mut Context<Self>,
260 ) {
261 self.jump_to_page(OnboardingPage::Basics, window, cx);
262 }
263
264 fn handle_jump_to_editing(
265 &mut self,
266 _: &JumpToEditing,
267 window: &mut Window,
268 cx: &mut Context<Self>,
269 ) {
270 self.jump_to_page(OnboardingPage::Editing, window, cx);
271 }
272
273 fn handle_jump_to_ai_setup(
274 &mut self,
275 _: &JumpToAiSetup,
276 window: &mut Window,
277 cx: &mut Context<Self>,
278 ) {
279 self.jump_to_page(OnboardingPage::AiSetup, window, cx);
280 }
281
282 fn handle_jump_to_welcome(
283 &mut self,
284 _: &JumpToWelcome,
285 window: &mut Window,
286 cx: &mut Context<Self>,
287 ) {
288 self.jump_to_page(OnboardingPage::Welcome, window, cx);
289 }
290
291 fn handle_next_page(&mut self, _: &NextPage, window: &mut Window, cx: &mut Context<Self>) {
292 self.next_page(window, cx);
293 }
294
295 fn handle_previous_page(
296 &mut self,
297 _: &PreviousPage,
298 window: &mut Window,
299 cx: &mut Context<Self>,
300 ) {
301 self.previous_page(window, cx);
302 }
303
304 fn render_navigation(
305 &mut self,
306 window: &mut Window,
307 cx: &mut Context<Self>,
308 ) -> impl gpui::IntoElement {
309 let client = self.client.clone();
310
311 v_flex()
312 .h_full()
313 .w(px(256.))
314 .gap_2()
315 .justify_between()
316 .child(
317 v_flex()
318 .w_full()
319 .gap_px()
320 .child(
321 h_flex()
322 .w_full()
323 .justify_between()
324 .py(px(24.))
325 .pl(px(24.))
326 .pr(px(12.))
327 .child(Vector::new(VectorName::ZedLogo, rems(2.), rems(2.)))
328 .child(
329 Button::new("sign_in", "Sign in")
330 .label_size(LabelSize::Small)
331 .on_click(cx.listener(move |_, _, window, cx| {
332 let client = client.clone();
333 window
334 .spawn(cx, async move |cx| {
335 client
336 .authenticate_and_connect(true, &cx)
337 .await
338 .into_response()
339 .notify_async_err(cx);
340 })
341 .detach();
342 })),
343 ),
344 )
345 .child(
346 v_flex()
347 .gap_px()
348 .py(px(16.))
349 .child(self.render_nav_item(
350 OnboardingPage::Basics,
351 "The Basics",
352 "1",
353 cx,
354 ))
355 .child(self.render_nav_item(
356 OnboardingPage::Editing,
357 "Editing Experience",
358 "2",
359 cx,
360 ))
361 .child(self.render_nav_item(
362 OnboardingPage::AiSetup,
363 "AI Setup",
364 "3",
365 cx,
366 ))
367 .child(self.render_nav_item(
368 OnboardingPage::Welcome,
369 "Welcome",
370 "4",
371 cx,
372 )),
373 ),
374 )
375 .child(self.render_bottom_controls(window, cx))
376 }
377
378 fn render_nav_item(
379 &mut self,
380 page: OnboardingPage,
381 label: impl Into<SharedString>,
382 shortcut: impl Into<SharedString>,
383 cx: &mut Context<Self>,
384 ) -> impl gpui::IntoElement {
385 let selected = self.current_page == page;
386 let label = label.into();
387 let shortcut = shortcut.into();
388 let id = ElementId::Name(label.clone());
389
390 h_flex()
391 .id(id)
392 .h(rems(1.5))
393 .w_full()
394 .child(
395 div()
396 .w(px(3.))
397 .h_full()
398 .when(selected, |this| this.bg(cx.theme().status().info)),
399 )
400 .child(
401 h_flex()
402 .pl(px(23.))
403 .flex_1()
404 .justify_between()
405 .items_center()
406 .child(Label::new(label))
407 .child(Label::new(format!("⌘{}", shortcut.clone())).color(Color::Muted)),
408 )
409 .on_click(cx.listener(move |this, _, window, cx| {
410 this.jump_to_page(page, window, cx);
411 }))
412 }
413
414 fn render_bottom_controls(
415 &mut self,
416 window: &mut gpui::Window,
417 cx: &mut Context<Self>,
418 ) -> impl gpui::IntoElement {
419 h_flex().w_full().p_4().child(
420 Button::new(
421 "next",
422 if self.current_page == OnboardingPage::Welcome {
423 "Get Started"
424 } else {
425 "Next"
426 },
427 )
428 .style(ButtonStyle::Filled)
429 .key_binding(ui::KeyBinding::for_action_in(
430 &NextPage,
431 &self.focus_handle,
432 window,
433 cx,
434 ))
435 .on_click(cx.listener(|this, _, window, cx| {
436 this.next_page(window, cx);
437 })),
438 )
439 }
440
441 fn render_active_page(
442 &mut self,
443 _window: &mut gpui::Window,
444 _cx: &mut Context<Self>,
445 ) -> AnyElement {
446 match self.current_page {
447 OnboardingPage::Basics => self.render_basics_page(),
448 OnboardingPage::Editing => self.render_editing_page(),
449 OnboardingPage::AiSetup => self.render_ai_setup_page(),
450 OnboardingPage::Welcome => self.render_welcome_page(),
451 }
452 }
453
454 fn render_basics_page(&self) -> AnyElement {
455 v_flex()
456 .h_full()
457 .w_full()
458 .child("Basics Page")
459 .into_any_element()
460 }
461
462 fn render_editing_page(&self) -> AnyElement {
463 v_flex()
464 .h_full()
465 .w_full()
466 .child("Editing Page")
467 .into_any_element()
468 }
469
470 fn render_ai_setup_page(&self) -> AnyElement {
471 v_flex()
472 .h_full()
473 .w_full()
474 .child("AI Setup Page")
475 .into_any_element()
476 }
477
478 fn render_welcome_page(&self) -> AnyElement {
479 v_flex()
480 .h_full()
481 .w_full()
482 .child("Welcome Page")
483 .into_any_element()
484 }
485}
486
487impl Item for OnboardingUI {
488 type Event = ItemEvent;
489
490 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
491 "Onboarding".into()
492 }
493
494 fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
495 f(event.clone())
496 }
497
498 fn added_to_workspace(
499 &mut self,
500 workspace: &mut Workspace,
501 _window: &mut Window,
502 _cx: &mut Context<Self>,
503 ) {
504 self.workspace_id = workspace.database_id();
505 }
506
507 fn show_toolbar(&self) -> bool {
508 false
509 }
510
511 fn clone_on_split(
512 &self,
513 _workspace_id: Option<WorkspaceId>,
514 window: &mut Window,
515 cx: &mut Context<Self>,
516 ) -> Option<Entity<Self>> {
517 let weak_workspace = self.workspace.clone();
518 let client = self.client.clone();
519 if let Some(workspace) = weak_workspace.upgrade() {
520 workspace.update(cx, |workspace, cx| {
521 Some(cx.new(|cx| OnboardingUI::new(workspace, client, cx)))
522 })
523 } else {
524 None
525 }
526 }
527}
528
529impl SerializableItem for OnboardingUI {
530 fn serialized_item_kind() -> &'static str {
531 "OnboardingUI"
532 }
533
534 fn deserialize(
535 _project: Entity<Project>,
536 workspace: WeakEntity<Workspace>,
537 workspace_id: WorkspaceId,
538 item_id: u64,
539 window: &mut Window,
540 cx: &mut App,
541 ) -> Task<anyhow::Result<Entity<Self>>> {
542 window.spawn(cx, async move |cx| {
543 let (current_page, completed_pages) = if let Some((page_str, completed_str)) =
544 ONBOARDING_DB.get_state(item_id, workspace_id)?
545 {
546 let page = match page_str.as_str() {
547 "basics" => OnboardingPage::Basics,
548 "editing" => OnboardingPage::Editing,
549 "ai_setup" => OnboardingPage::AiSetup,
550 "welcome" => OnboardingPage::Welcome,
551 _ => OnboardingPage::Basics,
552 };
553 let completed = OnboardingUI::completed_pages_from_string(&completed_str);
554 (page, completed)
555 } else {
556 (OnboardingPage::Basics, [false; 4])
557 };
558
559 cx.update(|window, cx| {
560 let workspace = workspace
561 .upgrade()
562 .ok_or_else(|| anyhow::anyhow!("workspace dropped"))?;
563
564 workspace.update(cx, |workspace, cx| {
565 let client = workspace.client().clone();
566 Ok(cx.new(|cx| {
567 let mut onboarding = OnboardingUI::new(workspace, client, cx);
568 onboarding.current_page = current_page;
569 onboarding.completed_pages = completed_pages;
570 onboarding
571 }))
572 })
573 })?
574 })
575 }
576
577 fn serialize(
578 &mut self,
579 _workspace: &mut Workspace,
580 item_id: u64,
581 _closing: bool,
582 _window: &mut Window,
583 cx: &mut Context<Self>,
584 ) -> Option<Task<anyhow::Result<()>>> {
585 let workspace_id = self.workspace_id?;
586 let current_page = match self.current_page {
587 OnboardingPage::Basics => "basics",
588 OnboardingPage::Editing => "editing",
589 OnboardingPage::AiSetup => "ai_setup",
590 OnboardingPage::Welcome => "welcome",
591 }
592 .to_string();
593 let completed_pages = self.completed_pages_to_string();
594
595 Some(cx.background_spawn(async move {
596 ONBOARDING_DB
597 .save_state(item_id, workspace_id, current_page, completed_pages)
598 .await
599 }))
600 }
601
602 fn cleanup(
603 _workspace_id: WorkspaceId,
604 _item_ids: Vec<u64>,
605 _window: &mut Window,
606 _cx: &mut App,
607 ) -> Task<anyhow::Result<()>> {
608 Task::ready(Ok(()))
609 }
610
611 fn should_serialize(&self, _event: &ItemEvent) -> bool {
612 true
613 }
614}