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