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