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/"); // 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                        .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(440.))
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(418.))
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 = if cx.is_staff() {
276            Some(
277                Button::new("view-blog", "Read the Blog Post")
278                    .full_width()
279                    .icon(IconName::ArrowUpRight)
280                    .icon_size(IconSize::Indicator)
281                    .icon_color(Color::Muted)
282                    .on_click(cx.listener(Self::view_blog)),
283            )
284        } else {
285            // TODO: put back when blog post is published
286            None
287        };
288
289        if self.user_store.read(cx).current_user().is_some() {
290            let copy = match self.sign_in_status {
291                SignInStatus::Idle => "Get accurate and instant edit predictions at every keystroke. Before setting Zed as your edit prediction provider:",
292                SignInStatus::SignedIn => "Almost there! Ensure you:",
293                SignInStatus::Waiting => unreachable!(),
294            };
295
296            let accordion_icons = if self.data_collection_expanded {
297                (IconName::ChevronUp, IconName::ChevronDown)
298            } else {
299                (IconName::ChevronDown, IconName::ChevronUp)
300            };
301
302            fn label_item(label_text: impl Into<SharedString>) -> impl Element {
303                Label::new(label_text).color(Color::Muted).into_element()
304            }
305
306            fn info_item(label_text: impl Into<SharedString>) -> impl Element {
307                h_flex()
308                    .items_start()
309                    .gap_2()
310                    .child(
311                        div()
312                            .mt_1p5()
313                            .child(Icon::new(IconName::Check).size(IconSize::XSmall)),
314                    )
315                    .child(div().w_full().child(label_item(label_text)))
316            }
317
318            fn multiline_info_item<E1: Into<SharedString>, E2: IntoElement>(
319                first_line: E1,
320                second_line: E2,
321            ) -> impl Element {
322                v_flex()
323                    .child(info_item(first_line))
324                    .child(div().pl_5().child(second_line))
325            }
326
327            base.child(Label::new(copy).color(Color::Muted))
328                .child(
329                    h_flex()
330                        .child(
331                            Checkbox::new("tos-checkbox", self.terms_of_service.into())
332                                .fill()
333                                .label("Read and accept the")
334                                .on_click(cx.listener(move |this, state, _window, cx| {
335                                    this.terms_of_service = *state == ToggleState::Selected;
336                                    cx.notify();
337                                })),
338                        )
339                        .child(
340                            Button::new("view-tos", "Terms of Service")
341                                .icon(IconName::ArrowUpRight)
342                                .icon_size(IconSize::Indicator)
343                                .icon_color(Color::Muted)
344                                .on_click(cx.listener(Self::view_terms)),
345                        ),
346                )
347                .child(
348                    v_flex()
349                        .child(
350                            h_flex()
351                                .flex_wrap()
352                                .child(
353                                    Checkbox::new(
354                                        "training-data-checkbox",
355                                        self.data_collection_opted_in.into(),
356                                    )
357                                    .label("Optionally share training data (OSS-only).")
358                                    .fill()
359                                    .on_click(cx.listener(
360                                        move |this, state, _window, cx| {
361                                            this.data_collection_opted_in =
362                                                *state == ToggleState::Selected;
363                                            cx.notify()
364                                        },
365                                    )),
366                                )
367                                .child(
368                                    Button::new("learn-more", "Learn More")
369                                        .icon(accordion_icons.0)
370                                        .icon_size(IconSize::Indicator)
371                                        .icon_color(Color::Muted)
372                                        .on_click(cx.listener(|this, _, _, cx| {
373                                            this.data_collection_expanded =
374                                                !this.data_collection_expanded;
375                                            cx.notify();
376
377                                            if this.data_collection_expanded {
378                                                onboarding_event!("Data Collection Learn More Clicked");
379                                            }
380                                        })),
381                                ),
382                        )
383                        .when(self.data_collection_expanded, |element| {
384                            element.child(
385                                v_flex()
386                                    .mt_2()
387                                    .p_2()
388                                    .rounded_md()
389                                    .bg(cx.theme().colors().editor_background.opacity(0.5))
390                                    .border_1()
391                                    .border_color(cx.theme().colors().border_variant)
392                                    .child(
393                                        div().child(
394                                            Label::new("To improve edit predictions, help fine-tune Zed's model by sharing data from the open-source projects you work on.")
395                                                .mb_1()
396                                        )
397                                    )
398                                    .child(info_item(
399                                        "We ask this exclusively for open-source projects.",
400                                    ))
401                                    .child(info_item(
402                                        "Zed automatically detects if your project is open-source.",
403                                    ))
404                                    .child(info_item(
405                                        "This setting is valid for all OSS projects you open in Zed.",
406                                    ))
407                                    .child(info_item("Toggle it anytime via the status bar menu."))
408                                    .child(multiline_info_item(
409                                        "Files with sensitive data, like `.env`, are excluded",
410                                        h_flex()
411                                            .w_full()
412                                            .flex_wrap()
413                                            .child(label_item("by default via the"))
414                                            .child(
415                                                Button::new("doc-link", "disabled_globs").on_click(
416                                                    cx.listener(Self::inline_completions_doc),
417                                                ),
418                                            )
419                                            .child(label_item("setting.")),
420                                    )),
421                            )
422                        }),
423                )
424                .child(
425                    v_flex()
426                        .mt_2()
427                        .gap_2()
428                        .w_full()
429                        .child(
430                            Button::new("accept-tos", "Enable Edit Predictions")
431                                .disabled(!self.terms_of_service)
432                                .style(ButtonStyle::Tinted(TintColor::Accent))
433                                .full_width()
434                                .on_click(cx.listener(Self::accept_and_enable)),
435                        )
436                        .children(blog_post_button),
437                )
438        } else {
439            base.child(
440                Label::new("To set Zed as your edit prediction provider, please sign in.")
441                    .color(Color::Muted),
442            )
443            .child(
444                v_flex()
445                    .mt_2()
446                    .gap_2()
447                    .w_full()
448                    .child(
449                        Button::new("accept-tos", "Sign in with GitHub")
450                            .disabled(self.sign_in_status == SignInStatus::Waiting)
451                            .style(ButtonStyle::Tinted(TintColor::Accent))
452                            .full_width()
453                            .on_click(cx.listener(Self::sign_in)),
454                    )
455                    .children(blog_post_button),
456            )
457        }
458    }
459}