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 v_flex()
310 .h_full()
311 .w(px(256.))
312 .gap_2()
313 .justify_between()
314 .child(
315 v_flex()
316 .w_full()
317 .gap_px()
318 .child(
319 h_flex()
320 .w_full()
321 .justify_between()
322 .child(Vector::new(VectorName::ZedLogo, rems(2.), rems(2.)))
323 .child(self.render_sign_in_button(cx)),
324 )
325 .child(self.render_nav_item(OnboardingPage::Basics, "The Basics", "1", cx))
326 .child(self.render_nav_item(
327 OnboardingPage::Editing,
328 "Editing Experience",
329 "2",
330 cx,
331 ))
332 .child(self.render_nav_item(OnboardingPage::AiSetup, "AI Setup", "3", cx))
333 .child(self.render_nav_item(OnboardingPage::Welcome, "Welcome", "4", cx)),
334 )
335 .child(self.render_bottom_controls(window, cx))
336 }
337
338 fn render_nav_item(
339 &mut self,
340 page: OnboardingPage,
341 label: impl Into<SharedString>,
342 shortcut: impl Into<SharedString>,
343 cx: &mut Context<Self>,
344 ) -> impl gpui::IntoElement {
345 let selected = self.current_page == page;
346 let label = label.into();
347 let shortcut = shortcut.into();
348
349 ListItem::new(label.clone())
350 .child(
351 h_flex()
352 .w_full()
353 .justify_between()
354 .child(Label::new(label.clone()))
355 .child(Label::new(format!("⌘{}", shortcut.clone())).color(Color::Muted)),
356 )
357 .selectable(true)
358 .toggle_state(selected)
359 .on_click(cx.listener(move |this, _, window, cx| {
360 this.jump_to_page(page, window, cx);
361 }))
362 }
363
364 fn render_bottom_controls(
365 &mut self,
366 window: &mut gpui::Window,
367 cx: &mut Context<Self>,
368 ) -> impl gpui::IntoElement {
369 h_flex().w_full().p_4().child(
370 Button::new(
371 "next",
372 if self.current_page == OnboardingPage::Welcome {
373 "Get Started"
374 } else {
375 "Next"
376 },
377 )
378 .style(ButtonStyle::Filled)
379 .key_binding(ui::KeyBinding::for_action_in(
380 &NextPage,
381 &self.focus_handle,
382 window,
383 cx,
384 ))
385 .on_click(cx.listener(|this, _, window, cx| {
386 this.next_page(window, cx);
387 })),
388 )
389 }
390
391 fn render_active_page(
392 &mut self,
393 _window: &mut gpui::Window,
394 _cx: &mut Context<Self>,
395 ) -> AnyElement {
396 match self.current_page {
397 OnboardingPage::Basics => self.render_basics_page(),
398 OnboardingPage::Editing => self.render_editing_page(),
399 OnboardingPage::AiSetup => self.render_ai_setup_page(),
400 OnboardingPage::Welcome => self.render_welcome_page(),
401 }
402 }
403
404 fn render_basics_page(&self) -> AnyElement {
405 v_flex()
406 .h_full()
407 .w_full()
408 .child("Basics Page")
409 .into_any_element()
410 }
411
412 fn render_editing_page(&self) -> AnyElement {
413 v_flex()
414 .h_full()
415 .w_full()
416 .child("Editing Page")
417 .into_any_element()
418 }
419
420 fn render_ai_setup_page(&self) -> AnyElement {
421 v_flex()
422 .h_full()
423 .w_full()
424 .child("AI Setup Page")
425 .into_any_element()
426 }
427
428 fn render_welcome_page(&self) -> AnyElement {
429 v_flex()
430 .h_full()
431 .w_full()
432 .child("Welcome Page")
433 .into_any_element()
434 }
435
436 fn render_sign_in_button(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
437 let client = self.client.clone();
438 Button::new("sign_in", "Sign in")
439 .label_size(LabelSize::Small)
440 .on_click(cx.listener(move |_, _, window, cx| {
441 let client = client.clone();
442 window
443 .spawn(cx, async move |cx| {
444 client
445 .authenticate_and_connect(true, &cx)
446 .await
447 .into_response()
448 .notify_async_err(cx);
449 })
450 .detach();
451 }))
452 }
453}
454
455impl Item for OnboardingUI {
456 type Event = ItemEvent;
457
458 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
459 "Onboarding".into()
460 }
461
462 fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
463 f(event.clone())
464 }
465
466 fn added_to_workspace(
467 &mut self,
468 workspace: &mut Workspace,
469 _window: &mut Window,
470 _cx: &mut Context<Self>,
471 ) {
472 self.workspace_id = workspace.database_id();
473 }
474
475 fn show_toolbar(&self) -> bool {
476 false
477 }
478
479 fn clone_on_split(
480 &self,
481 _workspace_id: Option<WorkspaceId>,
482 window: &mut Window,
483 cx: &mut Context<Self>,
484 ) -> Option<Entity<Self>> {
485 let weak_workspace = self.workspace.clone();
486 let client = self.client.clone();
487 if let Some(workspace) = weak_workspace.upgrade() {
488 workspace.update(cx, |workspace, cx| {
489 Some(cx.new(|cx| OnboardingUI::new(workspace, client, cx)))
490 })
491 } else {
492 None
493 }
494 }
495}
496
497impl SerializableItem for OnboardingUI {
498 fn serialized_item_kind() -> &'static str {
499 "OnboardingUI"
500 }
501
502 fn deserialize(
503 _project: Entity<Project>,
504 workspace: WeakEntity<Workspace>,
505 workspace_id: WorkspaceId,
506 item_id: u64,
507 window: &mut Window,
508 cx: &mut App,
509 ) -> Task<anyhow::Result<Entity<Self>>> {
510 window.spawn(cx, async move |cx| {
511 let (current_page, completed_pages) = if let Some((page_str, completed_str)) =
512 ONBOARDING_DB.get_state(item_id, workspace_id)?
513 {
514 let page = match page_str.as_str() {
515 "basics" => OnboardingPage::Basics,
516 "editing" => OnboardingPage::Editing,
517 "ai_setup" => OnboardingPage::AiSetup,
518 "welcome" => OnboardingPage::Welcome,
519 _ => OnboardingPage::Basics,
520 };
521 let completed = OnboardingUI::completed_pages_from_string(&completed_str);
522 (page, completed)
523 } else {
524 (OnboardingPage::Basics, [false; 4])
525 };
526
527 cx.update(|window, cx| {
528 let workspace = workspace
529 .upgrade()
530 .ok_or_else(|| anyhow::anyhow!("workspace dropped"))?;
531
532 workspace.update(cx, |workspace, cx| {
533 let client = workspace.client().clone();
534 Ok(cx.new(|cx| {
535 let mut onboarding = OnboardingUI::new(workspace, client, cx);
536 onboarding.current_page = current_page;
537 onboarding.completed_pages = completed_pages;
538 onboarding
539 }))
540 })
541 })?
542 })
543 }
544
545 fn serialize(
546 &mut self,
547 _workspace: &mut Workspace,
548 item_id: u64,
549 _closing: bool,
550 _window: &mut Window,
551 cx: &mut Context<Self>,
552 ) -> Option<Task<anyhow::Result<()>>> {
553 let workspace_id = self.workspace_id?;
554 let current_page = match self.current_page {
555 OnboardingPage::Basics => "basics",
556 OnboardingPage::Editing => "editing",
557 OnboardingPage::AiSetup => "ai_setup",
558 OnboardingPage::Welcome => "welcome",
559 }
560 .to_string();
561 let completed_pages = self.completed_pages_to_string();
562
563 Some(cx.background_spawn(async move {
564 ONBOARDING_DB
565 .save_state(item_id, workspace_id, current_page, completed_pages)
566 .await
567 }))
568 }
569
570 fn cleanup(
571 _workspace_id: WorkspaceId,
572 _item_ids: Vec<u64>,
573 _window: &mut Window,
574 _cx: &mut App,
575 ) -> Task<anyhow::Result<()>> {
576 Task::ready(Ok(()))
577 }
578
579 fn should_serialize(&self, _event: &ItemEvent) -> bool {
580 true
581 }
582}