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