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}