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 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}