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}