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}