onboarding_modal.rs

  1use std::{sync::Arc, time::Duration};
  2
  3use crate::{ZED_PREDICT_DATA_COLLECTION_CHOICE, onboarding_event};
  4use anyhow::Context as _;
  5use client::{Client, UserStore, zed_urls};
  6use db::kvp::KEY_VALUE_STORE;
  7use fs::Fs;
  8use gpui::{
  9    Animation, AnimationExt as _, ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle,
 10    Focusable, MouseDownEvent, Render, ease_in_out, svg,
 11};
 12use language::language_settings::{AllLanguageSettings, EditPredictionProvider};
 13use settings::{Settings, update_settings_file};
 14use ui::{Checkbox, TintColor, prelude::*};
 15use util::ResultExt;
 16use workspace::{ModalView, Workspace, notifications::NotifyTaskExt};
 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-prediction");
 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#disabled-globs");
 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        let fs = self.fs.clone();
 87
 88        cx.spawn(async move |this, cx| {
 89            task.await?;
 90
 91            let mut data_collection_opted_in = false;
 92            this.update(cx, |this, _cx| {
 93                data_collection_opted_in = this.data_collection_opted_in;
 94            })
 95            .ok();
 96
 97            KEY_VALUE_STORE
 98                .write_kvp(
 99                    ZED_PREDICT_DATA_COLLECTION_CHOICE.into(),
100                    data_collection_opted_in.to_string(),
101                )
102                .await
103                .log_err();
104
105            // Make sure edit prediction provider setting is using the new key
106            let settings_path = paths::settings_file().as_path();
107            let settings_path = fs.canonicalize(settings_path).await.with_context(|| {
108                format!("Failed to canonicalize settings path {:?}", settings_path)
109            })?;
110
111            if let Some(settings) = fs.load(&settings_path).await.log_err() {
112                if let Some(new_settings) =
113                    migrator::migrate_edit_prediction_provider_settings(&settings)?
114                {
115                    fs.atomic_write(settings_path, new_settings).await?;
116                }
117            }
118
119            this.update(cx, |this, cx| {
120                update_settings_file::<AllLanguageSettings>(this.fs.clone(), cx, move |file, _| {
121                    file.features
122                        .get_or_insert(Default::default())
123                        .edit_prediction_provider = Some(EditPredictionProvider::Zed);
124                });
125
126                cx.emit(DismissEvent);
127            })
128        })
129        .detach_and_notify_err(window, cx);
130
131        onboarding_event!(
132            "Enable Clicked",
133            data_collection_opted_in = self.data_collection_opted_in,
134        );
135    }
136
137    fn sign_in(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
138        let client = self.client.clone();
139        self.sign_in_status = SignInStatus::Waiting;
140
141        cx.spawn(async move |this, cx| {
142            let result = client.authenticate_and_connect(true, &cx).await;
143
144            let status = match result {
145                Ok(_) => SignInStatus::SignedIn,
146                Err(_) => SignInStatus::Idle,
147            };
148
149            this.update(cx, |this, cx| {
150                this.sign_in_status = status;
151                onboarding_event!("Signed In");
152                cx.notify()
153            })?;
154
155            result
156        })
157        .detach_and_notify_err(window, cx);
158
159        onboarding_event!("Sign In Clicked");
160    }
161
162    fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
163        cx.emit(DismissEvent);
164    }
165}
166
167impl EventEmitter<DismissEvent> for ZedPredictModal {}
168
169impl Focusable for ZedPredictModal {
170    fn focus_handle(&self, _cx: &App) -> FocusHandle {
171        self.focus_handle.clone()
172    }
173}
174
175impl ModalView for ZedPredictModal {}
176
177impl ZedPredictModal {
178    fn render_data_collection_explanation(&self, cx: &Context<Self>) -> impl IntoElement {
179        fn label_item(label_text: impl Into<SharedString>) -> impl Element {
180            Label::new(label_text).color(Color::Muted).into_element()
181        }
182
183        fn info_item(label_text: impl Into<SharedString>) -> impl Element {
184            h_flex()
185                .items_start()
186                .gap_2()
187                .child(
188                    div()
189                        .mt_1p5()
190                        .child(Icon::new(IconName::Check).size(IconSize::XSmall)),
191                )
192                .child(div().w_full().child(label_item(label_text)))
193        }
194
195        fn multiline_info_item<E1: Into<SharedString>, E2: IntoElement>(
196            first_line: E1,
197            second_line: E2,
198        ) -> impl Element {
199            v_flex()
200                .child(info_item(first_line))
201                .child(div().pl_5().child(second_line))
202        }
203
204        v_flex()
205            .mt_2()
206            .p_2()
207            .rounded_sm()
208            .bg(cx.theme().colors().editor_background.opacity(0.5))
209            .border_1()
210            .border_color(cx.theme().colors().border_variant)
211            .child(
212                div().child(
213                    Label::new("To improve edit predictions, please consider contributing to our open dataset based on your interactions within open source repositories.")
214                        .mb_1()
215                )
216            )
217            .child(info_item(
218                "We collect data exclusively from open source projects.",
219            ))
220            .child(info_item(
221                "Zed automatically detects if your project is open source.",
222            ))
223            .child(info_item("Toggle participation at any time via the status bar menu."))
224            .child(multiline_info_item(
225                "If turned on, this setting applies for all open source repositories",
226                label_item("you open in Zed.")
227            ))
228            .child(multiline_info_item(
229                "Files with sensitive data, like `.env`, are excluded by default",
230                h_flex()
231                    .w_full()
232                    .flex_wrap()
233                    .child(label_item("via the"))
234                    .child(
235                        Button::new("doc-link", "disabled_globs").on_click(
236                            cx.listener(Self::inline_completions_doc),
237                        ),
238                    )
239                    .child(label_item("setting.")),
240            ))
241    }
242}
243
244impl Render for ZedPredictModal {
245    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
246        let window_height = window.viewport_size().height;
247        let max_height = window_height - px(200.);
248
249        let has_subscription_period = self.user_store.read(cx).subscription_period().is_some();
250        let plan = self.user_store.read(cx).current_plan().filter(|_| {
251            // Since the user might be on the legacy free plan we filter based on whether we have a subscription period.
252            has_subscription_period
253        });
254
255        let base = v_flex()
256            .id("edit-prediction-onboarding")
257            .key_context("ZedPredictModal")
258            .relative()
259            .w(px(550.))
260            .h_full()
261            .max_h(max_height)
262            .p_4()
263            .gap_2()
264            .when(self.data_collection_expanded, |element| {
265                element.overflow_y_scroll()
266            })
267            .when(!self.data_collection_expanded, |element| {
268                element.overflow_hidden()
269            })
270            .elevation_3(cx)
271            .track_focus(&self.focus_handle(cx))
272            .on_action(cx.listener(Self::cancel))
273            .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
274                onboarding_event!("Cancelled", trigger = "Action");
275                cx.emit(DismissEvent);
276            }))
277            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
278                this.focus_handle.focus(window);
279            }))
280            .child(
281                div()
282                    .p_1p5()
283                    .absolute()
284                    .top_1()
285                    .left_1()
286                    .right_0()
287                    .h(px(200.))
288                    .child(
289                        svg()
290                            .path("icons/zed_predict_bg.svg")
291                            .text_color(cx.theme().colors().icon_disabled)
292                            .w(px(530.))
293                            .h(px(128.))
294                            .overflow_hidden(),
295                    ),
296            )
297            .child(
298                h_flex()
299                    .w_full()
300                    .mb_2()
301                    .justify_between()
302                    .child(
303                        v_flex()
304                            .gap_1()
305                            .child(
306                                Label::new("Introducing Zed AI's")
307                                    .size(LabelSize::Small)
308                                    .color(Color::Muted),
309                            )
310                            .child(Headline::new("Edit Prediction").size(HeadlineSize::Large)),
311                    )
312                    .child({
313                        let tab = |n: usize| {
314                            let text_color = cx.theme().colors().text;
315                            let border_color = cx.theme().colors().text_accent.opacity(0.4);
316
317                            h_flex().child(
318                                h_flex()
319                                    .px_4()
320                                    .py_0p5()
321                                    .bg(cx.theme().colors().editor_background)
322                                    .border_1()
323                                    .border_color(border_color)
324                                    .rounded_sm()
325                                    .font(theme::ThemeSettings::get_global(cx).buffer_font.clone())
326                                    .text_size(TextSize::XSmall.rems(cx))
327                                    .text_color(text_color)
328                                    .child("tab")
329                                    .with_animation(
330                                        n,
331                                        Animation::new(Duration::from_secs(2)).repeat(),
332                                        move |tab, delta| {
333                                            let delta = (delta - 0.15 * n as f32) / 0.7;
334                                            let delta = 1.0 - (0.5 - delta).abs() * 2.;
335                                            let delta = ease_in_out(delta.clamp(0., 1.));
336                                            let delta = 0.1 + 0.9 * delta;
337
338                                            tab.border_color(border_color.opacity(delta))
339                                                .text_color(text_color.opacity(delta))
340                                        },
341                                    ),
342                            )
343                        };
344
345                        v_flex()
346                            .gap_2()
347                            .items_center()
348                            .pr_2p5()
349                            .child(tab(0).ml_neg_20())
350                            .child(tab(1))
351                            .child(tab(2).ml_20())
352                    }),
353            )
354            .child(h_flex().absolute().top_2().right_2().child(
355                IconButton::new("cancel", IconName::X).on_click(cx.listener(
356                    |_, _: &ClickEvent, _window, cx| {
357                        onboarding_event!("Cancelled", trigger = "X click");
358                        cx.emit(DismissEvent);
359                    },
360                )),
361            ));
362
363        let blog_post_button = Button::new("view-blog", "Read the Blog Post")
364            .full_width()
365            .icon(IconName::ArrowUpRight)
366            .icon_size(IconSize::Indicator)
367            .icon_color(Color::Muted)
368            .on_click(cx.listener(Self::view_blog));
369
370        if self.user_store.read(cx).current_user().is_some() {
371            let copy = match self.sign_in_status {
372                SignInStatus::Idle => {
373                    "Zed can now predict your next edit on every keystroke. Powered by Zeta, our open-source, open-dataset language model."
374                }
375                SignInStatus::SignedIn => "Almost there! Ensure you:",
376                SignInStatus::Waiting => unreachable!(),
377            };
378
379            let accordion_icons = if self.data_collection_expanded {
380                (IconName::ChevronUp, IconName::ChevronDown)
381            } else {
382                (IconName::ChevronDown, IconName::ChevronUp)
383            };
384
385            base.child(Label::new(copy).color(Color::Muted))
386                .child(h_flex().map(|parent| {
387                    if let Some(plan) = plan {
388                        parent.child(
389                            Checkbox::new("plan", ToggleState::Selected)
390                                .fill()
391                                .disabled(true)
392                                .label(format!(
393                                    "You get {} edit predictions through your {}.",
394                                    if plan == proto::Plan::Free {
395                                        "2,000"
396                                    } else {
397                                        "unlimited"
398                                    },
399                                    match plan {
400                                        proto::Plan::Free => "Zed Free plan",
401                                        proto::Plan::ZedPro => "Zed Pro plan",
402                                        proto::Plan::ZedProTrial => "Zed Pro trial",
403                                    }
404                                )),
405                        )
406                    } else {
407                        parent
408                            .child(
409                                Checkbox::new("plan-required", ToggleState::Unselected)
410                                    .fill()
411                                    .disabled(true)
412                                    .label("To get started with edit prediction"),
413                            )
414                            .child(
415                                Button::new("subscribe", "choose a plan")
416                                    .icon(IconName::ArrowUpRight)
417                                    .icon_size(IconSize::Indicator)
418                                    .icon_color(Color::Muted)
419                                    .on_click(|_event, _window, cx| {
420                                        cx.open_url(&zed_urls::account_url(cx));
421                                    }),
422                            )
423                    }
424                }))
425                .child(
426                    h_flex()
427                        .child(
428                            Checkbox::new("tos-checkbox", self.terms_of_service.into())
429                                .fill()
430                                .label("I have read and accept the")
431                                .on_click(cx.listener(move |this, state, _window, cx| {
432                                    this.terms_of_service = *state == ToggleState::Selected;
433                                    cx.notify();
434                                })),
435                        )
436                        .child(
437                            Button::new("view-tos", "Terms of Service")
438                                .icon(IconName::ArrowUpRight)
439                                .icon_size(IconSize::Indicator)
440                                .icon_color(Color::Muted)
441                                .on_click(cx.listener(Self::view_terms)),
442                        ),
443                )
444                .child(
445                    v_flex()
446                        .child(
447                            h_flex()
448                                .flex_wrap()
449                                .child(
450                                    Checkbox::new(
451                                        "training-data-checkbox",
452                                        self.data_collection_opted_in.into(),
453                                    )
454                                    .label(
455                                        "Contribute to the open dataset when editing open source.",
456                                    )
457                                    .fill()
458                                    .on_click(cx.listener(
459                                        move |this, state, _window, cx| {
460                                            this.data_collection_opted_in =
461                                                *state == ToggleState::Selected;
462                                            cx.notify()
463                                        },
464                                    )),
465                                )
466                                .child(
467                                    Button::new("learn-more", "Learn More")
468                                        .icon(accordion_icons.0)
469                                        .icon_size(IconSize::Indicator)
470                                        .icon_color(Color::Muted)
471                                        .on_click(cx.listener(|this, _, _, cx| {
472                                            this.data_collection_expanded =
473                                                !this.data_collection_expanded;
474                                            cx.notify();
475
476                                            if this.data_collection_expanded {
477                                                onboarding_event!(
478                                                    "Data Collection Learn More Clicked"
479                                                );
480                                            }
481                                        })),
482                                ),
483                        )
484                        .when(self.data_collection_expanded, |element| {
485                            element.child(self.render_data_collection_explanation(cx))
486                        }),
487                )
488                .child(
489                    v_flex()
490                        .mt_2()
491                        .gap_2()
492                        .w_full()
493                        .child(
494                            Button::new("accept-tos", "Enable Edit Prediction")
495                                .disabled(plan.is_none() || !self.terms_of_service)
496                                .style(ButtonStyle::Tinted(TintColor::Accent))
497                                .full_width()
498                                .on_click(cx.listener(Self::accept_and_enable)),
499                        )
500                        .child(blog_post_button),
501                )
502        } else {
503            base.child(
504                Label::new("To set Zed as your edit prediction provider, please sign in.")
505                    .color(Color::Muted),
506            )
507            .child(
508                v_flex()
509                    .mt_2()
510                    .gap_2()
511                    .w_full()
512                    .child(
513                        Button::new("accept-tos", "Sign in with GitHub")
514                            .disabled(self.sign_in_status == SignInStatus::Waiting)
515                            .style(ButtonStyle::Tinted(TintColor::Accent))
516                            .full_width()
517                            .on_click(cx.listener(Self::sign_in)),
518                    )
519                    .child(blog_post_button),
520            )
521        }
522    }
523}