onboarding_modal.rs

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