onboarding_modal.rs

  1use std::{sync::Arc, time::Duration};
  2
  3use crate::{onboarding_event, ZED_PREDICT_DATA_COLLECTION_CHOICE};
  4use client::{Client, UserStore};
  5use db::kvp::KEY_VALUE_STORE;
  6use feature_flags::FeatureFlagAppExt as _;
  7use fs::Fs;
  8use gpui::{
  9    ease_in_out, svg, Animation, AnimationExt as _, ClickEvent, DismissEvent, Entity, EventEmitter,
 10    FocusHandle, Focusable, MouseDownEvent, Render,
 11};
 12use language::language_settings::{AllLanguageSettings, InlineCompletionProvider};
 13use settings::{update_settings_file, Settings};
 14use ui::{prelude::*, Checkbox, TintColor};
 15use util::ResultExt;
 16use workspace::{notifications::NotifyTaskExt, ModalView, Workspace};
 17
 18/// Introduces user to Zed's Edit Prediction feature and terms of service
 19pub struct ZedPredictModal {
 20    user_store: Entity<UserStore>,
 21    client: Arc<Client>,
 22    fs: Arc<dyn Fs>,
 23    focus_handle: FocusHandle,
 24    sign_in_status: SignInStatus,
 25    terms_of_service: bool,
 26    data_collection_expanded: bool,
 27    data_collection_opted_in: bool,
 28}
 29
 30#[derive(PartialEq, Eq)]
 31enum SignInStatus {
 32    /// Signed out or signed in but not from this modal
 33    Idle,
 34    /// Authentication triggered from this modal
 35    Waiting,
 36    /// Signed in after authentication from this modal
 37    SignedIn,
 38}
 39
 40impl ZedPredictModal {
 41    pub fn toggle(
 42        workspace: &mut Workspace,
 43        user_store: Entity<UserStore>,
 44        client: Arc<Client>,
 45        fs: Arc<dyn Fs>,
 46        window: &mut Window,
 47        cx: &mut Context<Workspace>,
 48    ) {
 49        workspace.toggle_modal(window, cx, |_window, cx| Self {
 50            user_store,
 51            client,
 52            fs,
 53            focus_handle: cx.focus_handle(),
 54            sign_in_status: SignInStatus::Idle,
 55            terms_of_service: false,
 56            data_collection_expanded: false,
 57            data_collection_opted_in: false,
 58        });
 59    }
 60
 61    fn view_terms(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
 62        cx.open_url("https://zed.dev/terms-of-service");
 63        cx.notify();
 64
 65        onboarding_event!("ToS Link Clicked");
 66    }
 67
 68    fn view_blog(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
 69        cx.open_url("https://zed.dev/blog/"); // TODO Add the link when live
 70        cx.notify();
 71
 72        onboarding_event!("Blog Link clicked");
 73    }
 74
 75    fn inline_completions_doc(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
 76        cx.open_url("https://zed.dev/docs/configuring-zed#inline-completions");
 77        cx.notify();
 78
 79        onboarding_event!("Docs Link Clicked");
 80    }
 81
 82    fn accept_and_enable(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
 83        let task = self
 84            .user_store
 85            .update(cx, |this, cx| this.accept_terms_of_service(cx));
 86
 87        cx.spawn(|this, mut cx| async move {
 88            task.await?;
 89
 90            let mut data_collection_opted_in = false;
 91            this.update(&mut cx, |this, _cx| {
 92                data_collection_opted_in = this.data_collection_opted_in;
 93            })
 94            .ok();
 95
 96            KEY_VALUE_STORE
 97                .write_kvp(
 98                    ZED_PREDICT_DATA_COLLECTION_CHOICE.into(),
 99                    data_collection_opted_in.to_string(),
100                )
101                .await
102                .log_err();
103
104            this.update(&mut cx, |this, cx| {
105                update_settings_file::<AllLanguageSettings>(this.fs.clone(), cx, move |file, _| {
106                    file.features
107                        .get_or_insert(Default::default())
108                        .inline_completion_provider = Some(InlineCompletionProvider::Zed);
109                });
110
111                cx.emit(DismissEvent);
112            })
113        })
114        .detach_and_notify_err(window, cx);
115
116        onboarding_event!(
117            "Enable Clicked",
118            data_collection_opted_in = self.data_collection_opted_in,
119        );
120    }
121
122    fn sign_in(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
123        let client = self.client.clone();
124        self.sign_in_status = SignInStatus::Waiting;
125
126        cx.spawn(move |this, mut cx| async move {
127            let result = client.authenticate_and_connect(true, &cx).await;
128
129            let status = match result {
130                Ok(_) => SignInStatus::SignedIn,
131                Err(_) => SignInStatus::Idle,
132            };
133
134            this.update(&mut cx, |this, cx| {
135                this.sign_in_status = status;
136                onboarding_event!("Signed In");
137                cx.notify()
138            })?;
139
140            result
141        })
142        .detach_and_notify_err(window, cx);
143
144        onboarding_event!("Sign In Clicked");
145    }
146
147    fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
148        cx.emit(DismissEvent);
149    }
150}
151
152impl EventEmitter<DismissEvent> for ZedPredictModal {}
153
154impl Focusable for ZedPredictModal {
155    fn focus_handle(&self, _cx: &App) -> FocusHandle {
156        self.focus_handle.clone()
157    }
158}
159
160impl ModalView for ZedPredictModal {}
161
162impl Render for ZedPredictModal {
163    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
164        let base = v_flex()
165            .id("zed predict tos")
166            .key_context("ZedPredictModal")
167            .w(px(440.))
168            .p_4()
169            .relative()
170            .gap_2()
171            .overflow_hidden()
172            .elevation_3(cx)
173            .track_focus(&self.focus_handle(cx))
174            .on_action(cx.listener(Self::cancel))
175            .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
176                onboarding_event!("Cancelled", trigger = "Action");
177                cx.emit(DismissEvent);
178            }))
179            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
180                this.focus_handle.focus(window);
181            }))
182            .child(
183                div()
184                    .p_1p5()
185                    .absolute()
186                    .top_1()
187                    .left_1p5()
188                    .right_0()
189                    .h(px(200.))
190                    .child(
191                        svg()
192                            .path("icons/zed_predict_bg.svg")
193                            .text_color(cx.theme().colors().icon_disabled)
194                            .w(px(418.))
195                            .h(px(128.))
196                            .overflow_hidden(),
197                    ),
198            )
199            .child(
200                h_flex()
201                    .w_full()
202                    .mb_2()
203                    .justify_between()
204                    .child(
205                        v_flex()
206                            .gap_1()
207                            .child(
208                                Label::new("Introducing Zed AI's")
209                                    .size(LabelSize::Small)
210                                    .color(Color::Muted),
211                            )
212                            .child(Headline::new("Edit Prediction").size(HeadlineSize::Large)),
213                    )
214                    .child({
215                        let tab = |n: usize| {
216                            let text_color = cx.theme().colors().text;
217                            let border_color = cx.theme().colors().text_accent.opacity(0.4);
218
219                            h_flex().child(
220                                h_flex()
221                                    .px_4()
222                                    .py_0p5()
223                                    .bg(cx.theme().colors().editor_background)
224                                    .border_1()
225                                    .border_color(border_color)
226                                    .rounded_md()
227                                    .font(theme::ThemeSettings::get_global(cx).buffer_font.clone())
228                                    .text_size(TextSize::XSmall.rems(cx))
229                                    .text_color(text_color)
230                                    .child("tab")
231                                    .with_animation(
232                                        ElementId::Integer(n),
233                                        Animation::new(Duration::from_secs(2)).repeat(),
234                                        move |tab, delta| {
235                                            let delta = (delta - 0.15 * n as f32) / 0.7;
236                                            let delta = 1.0 - (0.5 - delta).abs() * 2.;
237                                            let delta = ease_in_out(delta.clamp(0., 1.));
238                                            let delta = 0.1 + 0.9 * delta;
239
240                                            tab.border_color(border_color.opacity(delta))
241                                                .text_color(text_color.opacity(delta))
242                                        },
243                                    ),
244                            )
245                        };
246
247                        v_flex()
248                            .gap_2()
249                            .items_center()
250                            .pr_4()
251                            .child(tab(0).ml_neg_20())
252                            .child(tab(1))
253                            .child(tab(2).ml_20())
254                    }),
255            )
256            .child(h_flex().absolute().top_2().right_2().child(
257                IconButton::new("cancel", IconName::X).on_click(cx.listener(
258                    |_, _: &ClickEvent, _window, cx| {
259                        onboarding_event!("Cancelled", trigger = "X click");
260                        cx.emit(DismissEvent);
261                    },
262                )),
263            ));
264
265        let blog_post_button = if cx.is_staff() {
266            Some(
267                Button::new("view-blog", "Read the Blog Post")
268                    .full_width()
269                    .icon(IconName::ArrowUpRight)
270                    .icon_size(IconSize::Indicator)
271                    .icon_color(Color::Muted)
272                    .on_click(cx.listener(Self::view_blog)),
273            )
274        } else {
275            // TODO: put back when blog post is published
276            None
277        };
278
279        if self.user_store.read(cx).current_user().is_some() {
280            let copy = match self.sign_in_status {
281                SignInStatus::Idle => "Get accurate and instant edit predictions at every keystroke. Before setting Zed as your edit prediction provider:",
282                SignInStatus::SignedIn => "Almost there! Ensure you:",
283                SignInStatus::Waiting => unreachable!(),
284            };
285
286            let accordion_icons = if self.data_collection_expanded {
287                (IconName::ChevronUp, IconName::ChevronDown)
288            } else {
289                (IconName::ChevronDown, IconName::ChevronUp)
290            };
291
292            fn label_item(label_text: impl Into<SharedString>) -> impl Element {
293                Label::new(label_text).color(Color::Muted).into_element()
294            }
295
296            fn info_item(label_text: impl Into<SharedString>) -> impl Element {
297                h_flex()
298                    .gap_2()
299                    .child(Icon::new(IconName::Check).size(IconSize::XSmall))
300                    .child(label_item(label_text))
301            }
302
303            fn multiline_info_item<E1: Into<SharedString>, E2: IntoElement>(
304                first_line: E1,
305                second_line: E2,
306            ) -> impl Element {
307                v_flex()
308                    .child(info_item(first_line))
309                    .child(div().pl_5().child(second_line))
310            }
311
312            base.child(Label::new(copy).color(Color::Muted))
313                .child(
314                    h_flex()
315                        .child(
316                            Checkbox::new("tos-checkbox", self.terms_of_service.into())
317                                .fill()
318                                .label("Read and accept the")
319                                .on_click(cx.listener(move |this, state, _window, cx| {
320                                    this.terms_of_service = *state == ToggleState::Selected;
321                                    cx.notify();
322                                })),
323                        )
324                        .child(
325                            Button::new("view-tos", "Terms of Service")
326                                .icon(IconName::ArrowUpRight)
327                                .icon_size(IconSize::Indicator)
328                                .icon_color(Color::Muted)
329                                .on_click(cx.listener(Self::view_terms)),
330                        ),
331                )
332                .child(
333                    v_flex()
334                        .child(
335                            h_flex()
336                                .child(
337                                    Checkbox::new(
338                                        "training-data-checkbox",
339                                        self.data_collection_opted_in.into(),
340                                    )
341                                    .label("Optionally share training data (OSS-only).")
342                                    .fill()
343                                    .on_click(cx.listener(
344                                        move |this, state, _window, cx| {
345                                            this.data_collection_opted_in =
346                                                *state == ToggleState::Selected;
347                                            cx.notify()
348                                        },
349                                    )),
350                                )
351                                .child(
352                                    Button::new("learn-more", "Learn More")
353                                        .icon(accordion_icons.0)
354                                        .icon_size(IconSize::Indicator)
355                                        .icon_color(Color::Muted)
356                                        .on_click(cx.listener(|this, _, _, cx| {
357                                            this.data_collection_expanded =
358                                                !this.data_collection_expanded;
359                                            cx.notify();
360
361                                            if this.data_collection_expanded {
362                                                onboarding_event!("Data Collection Learn More Clicked");
363                                            }
364                                        })),
365                                ),
366                        )
367                        .when(self.data_collection_expanded, |element| {
368                            element.child(
369                                v_flex()
370                                    .mt_2()
371                                    .p_2()
372                                    .rounded_md()
373                                    .bg(cx.theme().colors().editor_background.opacity(0.5))
374                                    .border_1()
375                                    .border_color(cx.theme().colors().border_variant)
376                                    .child(
377                                        div().child(
378                                            Label::new("To improve edit predictions, help fine-tune Zed's model by sharing data from the open-source projects you work on.")
379                                                .mb_1()
380                                        )
381                                    )
382                                    .child(info_item(
383                                        "We ask this exclusively for open-source projects.",
384                                    ))
385                                    .child(info_item(
386                                        "Zed automatically detects if your project is open-source.",
387                                    ))
388                                    .child(info_item(
389                                        "This setting is valid for all OSS projects you open in Zed.",
390                                    ))
391                                    .child(info_item("Toggle it anytime via the status bar menu."))
392                                    .child(multiline_info_item(
393                                        "Files that can contain sensitive data, like `.env`, are",
394                                        h_flex()
395                                            .child(label_item("excluded by default via the"))
396                                            .child(
397                                                Button::new("doc-link", "disabled_globs").on_click(
398                                                    cx.listener(Self::inline_completions_doc),
399                                                ),
400                                            )
401                                            .child(label_item("setting.")),
402                                    )),
403                            )
404                        }),
405                )
406                .child(
407                    v_flex()
408                        .mt_2()
409                        .gap_2()
410                        .w_full()
411                        .child(
412                            Button::new("accept-tos", "Enable Edit Predictions")
413                                .disabled(!self.terms_of_service)
414                                .style(ButtonStyle::Tinted(TintColor::Accent))
415                                .full_width()
416                                .on_click(cx.listener(Self::accept_and_enable)),
417                        )
418                        .children(blog_post_button),
419                )
420        } else {
421            base.child(
422                Label::new("To set Zed as your edit prediction provider, please sign in.")
423                    .color(Color::Muted),
424            )
425            .child(
426                v_flex()
427                    .mt_2()
428                    .gap_2()
429                    .w_full()
430                    .child(
431                        Button::new("accept-tos", "Sign in with GitHub")
432                            .disabled(self.sign_in_status == SignInStatus::Waiting)
433                            .style(ButtonStyle::Tinted(TintColor::Accent))
434                            .full_width()
435                            .on_click(cx.listener(Self::sign_in)),
436                    )
437                    .children(blog_post_button),
438            )
439        }
440    }
441}