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, EditPredictionProvider};
 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/edit-predictions");
 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                        .edit_prediction_provider = Some(EditPredictionProvider::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 window_height = window.viewport_size().height;
165        let max_height = window_height - px(200.);
166
167        let base = v_flex()
168            .id("edit-prediction-onboarding")
169            .key_context("ZedPredictModal")
170            .relative()
171            .w(px(480.))
172            .h_full()
173            .max_h(max_height)
174            .p_4()
175            .gap_2()
176            .when(self.data_collection_expanded, |element| {
177                element.overflow_y_scroll()
178            })
179            .when(!self.data_collection_expanded, |element| {
180                element.overflow_hidden()
181            })
182            .elevation_3(cx)
183            .track_focus(&self.focus_handle(cx))
184            .on_action(cx.listener(Self::cancel))
185            .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
186                onboarding_event!("Cancelled", trigger = "Action");
187                cx.emit(DismissEvent);
188            }))
189            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
190                this.focus_handle.focus(window);
191            }))
192            .child(
193                div()
194                    .p_1p5()
195                    .absolute()
196                    .top_1()
197                    .left_1()
198                    .right_0()
199                    .h(px(200.))
200                    .child(
201                        svg()
202                            .path("icons/zed_predict_bg.svg")
203                            .text_color(cx.theme().colors().icon_disabled)
204                            .w(px(460.))
205                            .h(px(128.))
206                            .overflow_hidden(),
207                    ),
208            )
209            .child(
210                h_flex()
211                    .w_full()
212                    .mb_2()
213                    .justify_between()
214                    .child(
215                        v_flex()
216                            .gap_1()
217                            .child(
218                                Label::new("Introducing Zed AI's")
219                                    .size(LabelSize::Small)
220                                    .color(Color::Muted),
221                            )
222                            .child(Headline::new("Edit Prediction").size(HeadlineSize::Large)),
223                    )
224                    .child({
225                        let tab = |n: usize| {
226                            let text_color = cx.theme().colors().text;
227                            let border_color = cx.theme().colors().text_accent.opacity(0.4);
228
229                            h_flex().child(
230                                h_flex()
231                                    .px_4()
232                                    .py_0p5()
233                                    .bg(cx.theme().colors().editor_background)
234                                    .border_1()
235                                    .border_color(border_color)
236                                    .rounded_md()
237                                    .font(theme::ThemeSettings::get_global(cx).buffer_font.clone())
238                                    .text_size(TextSize::XSmall.rems(cx))
239                                    .text_color(text_color)
240                                    .child("tab")
241                                    .with_animation(
242                                        ElementId::Integer(n),
243                                        Animation::new(Duration::from_secs(2)).repeat(),
244                                        move |tab, delta| {
245                                            let delta = (delta - 0.15 * n as f32) / 0.7;
246                                            let delta = 1.0 - (0.5 - delta).abs() * 2.;
247                                            let delta = ease_in_out(delta.clamp(0., 1.));
248                                            let delta = 0.1 + 0.9 * delta;
249
250                                            tab.border_color(border_color.opacity(delta))
251                                                .text_color(text_color.opacity(delta))
252                                        },
253                                    ),
254                            )
255                        };
256
257                        v_flex()
258                            .gap_2()
259                            .items_center()
260                            .pr_2p5()
261                            .child(tab(0).ml_neg_20())
262                            .child(tab(1))
263                            .child(tab(2).ml_20())
264                    }),
265            )
266            .child(h_flex().absolute().top_2().right_2().child(
267                IconButton::new("cancel", IconName::X).on_click(cx.listener(
268                    |_, _: &ClickEvent, _window, cx| {
269                        onboarding_event!("Cancelled", trigger = "X click");
270                        cx.emit(DismissEvent);
271                    },
272                )),
273            ));
274
275        let blog_post_button = cx
276            .has_flag::<feature_flags::PredictEditsLaunchFeatureFlag>()
277            .then(|| {
278                Button::new("view-blog", "Read the Blog Post")
279                    .full_width()
280                    .icon(IconName::ArrowUpRight)
281                    .icon_size(IconSize::Indicator)
282                    .icon_color(Color::Muted)
283                    .on_click(cx.listener(Self::view_blog))
284            });
285
286        if self.user_store.read(cx).current_user().is_some() {
287            let copy = match self.sign_in_status {
288                SignInStatus::Idle => "Get accurate and instant edit predictions at every keystroke. Before setting Zed as your edit prediction provider:",
289                SignInStatus::SignedIn => "Almost there! Ensure you:",
290                SignInStatus::Waiting => unreachable!(),
291            };
292
293            let accordion_icons = if self.data_collection_expanded {
294                (IconName::ChevronUp, IconName::ChevronDown)
295            } else {
296                (IconName::ChevronDown, IconName::ChevronUp)
297            };
298
299            fn label_item(label_text: impl Into<SharedString>) -> impl Element {
300                Label::new(label_text).color(Color::Muted).into_element()
301            }
302
303            fn info_item(label_text: impl Into<SharedString>) -> impl Element {
304                h_flex()
305                    .items_start()
306                    .gap_2()
307                    .child(
308                        div()
309                            .mt_1p5()
310                            .child(Icon::new(IconName::Check).size(IconSize::XSmall)),
311                    )
312                    .child(div().w_full().child(label_item(label_text)))
313            }
314
315            fn multiline_info_item<E1: Into<SharedString>, E2: IntoElement>(
316                first_line: E1,
317                second_line: E2,
318            ) -> impl Element {
319                v_flex()
320                    .child(info_item(first_line))
321                    .child(div().pl_5().child(second_line))
322            }
323
324            base.child(Label::new(copy).color(Color::Muted))
325                .child(
326                    h_flex()
327                        .child(
328                            Checkbox::new("tos-checkbox", self.terms_of_service.into())
329                                .fill()
330                                .label("Read and accept the")
331                                .on_click(cx.listener(move |this, state, _window, cx| {
332                                    this.terms_of_service = *state == ToggleState::Selected;
333                                    cx.notify();
334                                })),
335                        )
336                        .child(
337                            Button::new("view-tos", "Terms of Service")
338                                .icon(IconName::ArrowUpRight)
339                                .icon_size(IconSize::Indicator)
340                                .icon_color(Color::Muted)
341                                .on_click(cx.listener(Self::view_terms)),
342                        ),
343                )
344                .child(
345                    v_flex()
346                        .child(
347                            h_flex()
348                                .flex_wrap()
349                                .child(
350                                    Checkbox::new(
351                                        "training-data-checkbox",
352                                        self.data_collection_opted_in.into(),
353                                    )
354                                    .label("Open source repos: optionally share training data.")
355                                    .fill()
356                                    .on_click(cx.listener(
357                                        move |this, state, _window, cx| {
358                                            this.data_collection_opted_in =
359                                                *state == ToggleState::Selected;
360                                            cx.notify()
361                                        },
362                                    )),
363                                )
364                                .child(
365                                    Button::new("learn-more", "Learn More")
366                                        .icon(accordion_icons.0)
367                                        .icon_size(IconSize::Indicator)
368                                        .icon_color(Color::Muted)
369                                        .on_click(cx.listener(|this, _, _, cx| {
370                                            this.data_collection_expanded =
371                                                !this.data_collection_expanded;
372                                            cx.notify();
373
374                                            if this.data_collection_expanded {
375                                                onboarding_event!("Data Collection Learn More Clicked");
376                                            }
377                                        })),
378                                ),
379                        )
380                        .when(self.data_collection_expanded, |element| {
381                            element.child(
382                                v_flex()
383                                    .mt_2()
384                                    .p_2()
385                                    .rounded_md()
386                                    .bg(cx.theme().colors().editor_background.opacity(0.5))
387                                    .border_1()
388                                    .border_color(cx.theme().colors().border_variant)
389                                    .child(
390                                        div().child(
391                                            Label::new("To improve edit predictions, please consider contributing to our open dataset based on your interactions within open source repositories.")
392                                                .mb_1()
393                                        )
394                                    )
395                                    .child(info_item(
396                                        "We ask this exclusively for open source projects.",
397                                    ))
398                                    .child(info_item(
399                                        "Zed automatically detects if your project is open source.",
400                                    ))
401                                    .child(info_item("Toggle it anytime via the status bar menu."))
402                                    .child(multiline_info_item(
403                                        "If turned on, this setting is valid for all open source projects",
404                                        label_item("you open in Zed.")
405                                    ))
406                                    .child(multiline_info_item(
407                                        "Files with sensitive data, like `.env`, are excluded by default",
408                                        h_flex()
409                                            .w_full()
410                                            .flex_wrap()
411                                            .child(label_item("via the"))
412                                            .child(
413                                                Button::new("doc-link", "disabled_globs").on_click(
414                                                    cx.listener(Self::inline_completions_doc),
415                                                ),
416                                            )
417                                            .child(label_item("setting.")),
418                                    )),
419                            )
420                        }),
421                )
422                .child(
423                    v_flex()
424                        .mt_2()
425                        .gap_2()
426                        .w_full()
427                        .child(
428                            Button::new("accept-tos", "Enable Edit Predictions")
429                                .disabled(!self.terms_of_service)
430                                .style(ButtonStyle::Tinted(TintColor::Accent))
431                                .full_width()
432                                .on_click(cx.listener(Self::accept_and_enable)),
433                        )
434                        .children(blog_post_button),
435                )
436        } else {
437            base.child(
438                Label::new("To set Zed as your edit prediction provider, please sign in.")
439                    .color(Color::Muted),
440            )
441            .child(
442                v_flex()
443                    .mt_2()
444                    .gap_2()
445                    .w_full()
446                    .child(
447                        Button::new("accept-tos", "Sign in with GitHub")
448                            .disabled(self.sign_in_status == SignInStatus::Waiting)
449                            .style(ButtonStyle::Tinted(TintColor::Accent))
450                            .full_width()
451                            .on_click(cx.listener(Self::sign_in)),
452                    )
453                    .children(blog_post_button),
454            )
455        }
456    }
457}