onboarding_ui.rs

  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}