onboarding_modal.rs

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