1use crate::system_specs::SystemSpecs;
2use anyhow::bail;
3use client::{Client, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL};
4use editor::{Anchor, Editor};
5use futures::AsyncReadExt;
6use gpui::{
7 actions,
8 elements::{ChildView, Flex, Label, ParentElement, Svg},
9 platform::PromptLevel,
10 serde_json, AnyElement, AnyViewHandle, AppContext, Element, Entity, ModelHandle, Task, View,
11 ViewContext, ViewHandle,
12};
13use isahc::Request;
14use language::Buffer;
15use postage::prelude::Stream;
16use project::{search::SearchQuery, Project};
17use regex::Regex;
18use serde::Serialize;
19use smallvec::SmallVec;
20use std::{
21 any::TypeId,
22 borrow::Cow,
23 ops::{Range, RangeInclusive},
24 sync::Arc,
25};
26use util::ResultExt;
27use workspace::{
28 item::{Item, ItemEvent, ItemHandle},
29 searchable::{SearchableItem, SearchableItemHandle},
30 Workspace,
31};
32
33const FEEDBACK_CHAR_LIMIT: RangeInclusive<usize> = 10..=5000;
34const FEEDBACK_SUBMISSION_ERROR_TEXT: &str =
35 "Feedback failed to submit, see error log for details.";
36
37actions!(feedback, [GiveFeedback, SubmitFeedback]);
38
39pub fn init(cx: &mut AppContext) {
40 cx.add_action({
41 move |workspace: &mut Workspace, _: &GiveFeedback, cx: &mut ViewContext<Workspace>| {
42 FeedbackEditor::deploy(workspace, cx);
43 }
44 });
45}
46
47#[derive(Serialize)]
48struct FeedbackRequestBody<'a> {
49 feedback_text: &'a str,
50 email: Option<String>,
51 metrics_id: Option<Arc<str>>,
52 installation_id: Option<Arc<str>>,
53 system_specs: SystemSpecs,
54 is_staff: bool,
55 token: &'a str,
56}
57
58#[derive(Clone)]
59pub(crate) struct FeedbackEditor {
60 system_specs: SystemSpecs,
61 editor: ViewHandle<Editor>,
62 project: ModelHandle<Project>,
63 pub allow_submission: bool,
64}
65
66impl FeedbackEditor {
67 fn new(
68 system_specs: SystemSpecs,
69 project: ModelHandle<Project>,
70 buffer: ModelHandle<Buffer>,
71 cx: &mut ViewContext<Self>,
72 ) -> Self {
73 let editor = cx.add_view(|cx| {
74 let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx);
75 editor.set_vertical_scroll_margin(5, cx);
76 editor
77 });
78
79 cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone()))
80 .detach();
81
82 Self {
83 system_specs: system_specs.clone(),
84 editor,
85 project,
86 allow_submission: true,
87 }
88 }
89
90 pub fn submit(&mut self, cx: &mut ViewContext<Self>) -> Task<anyhow::Result<()>> {
91 if !self.allow_submission {
92 return Task::ready(Ok(()));
93 }
94
95 let feedback_text = self.editor.read(cx).text(cx);
96 let feedback_char_count = feedback_text.chars().count();
97 let feedback_text = feedback_text.trim().to_string();
98
99 let error = if feedback_char_count < *FEEDBACK_CHAR_LIMIT.start() {
100 Some(format!(
101 "Feedback can't be shorter than {} characters.",
102 FEEDBACK_CHAR_LIMIT.start()
103 ))
104 } else if feedback_char_count > *FEEDBACK_CHAR_LIMIT.end() {
105 Some(format!(
106 "Feedback can't be longer than {} characters.",
107 FEEDBACK_CHAR_LIMIT.end()
108 ))
109 } else {
110 None
111 };
112
113 if let Some(error) = error {
114 cx.prompt(PromptLevel::Critical, &error, &["OK"]);
115 return Task::ready(Ok(()));
116 }
117
118 let mut answer = cx.prompt(
119 PromptLevel::Info,
120 "Ready to submit your feedback?",
121 &["Yes, Submit!", "No"],
122 );
123
124 let client = cx.global::<Arc<Client>>().clone();
125 let specs = self.system_specs.clone();
126
127 cx.spawn(|this, mut cx| async move {
128 let answer = answer.recv().await;
129
130 if answer == Some(0) {
131 this.update(&mut cx, |feedback_editor, cx| {
132 feedback_editor.set_allow_submission(false, cx);
133 })
134 .log_err();
135
136 match FeedbackEditor::submit_feedback(&feedback_text, client, specs).await {
137 Ok(_) => {
138 this.update(&mut cx, |_, cx| cx.emit(editor::Event::Closed))
139 .log_err();
140 }
141
142 Err(error) => {
143 log::error!("{}", error);
144 this.update(&mut cx, |feedback_editor, cx| {
145 cx.prompt(
146 PromptLevel::Critical,
147 FEEDBACK_SUBMISSION_ERROR_TEXT,
148 &["OK"],
149 );
150 feedback_editor.set_allow_submission(true, cx);
151 })
152 .log_err();
153 }
154 }
155 }
156 })
157 .detach();
158
159 Task::ready(Ok(()))
160 }
161
162 fn set_allow_submission(&mut self, allow_submission: bool, cx: &mut ViewContext<Self>) {
163 self.allow_submission = allow_submission;
164 cx.notify();
165 }
166
167 async fn submit_feedback(
168 feedback_text: &str,
169 zed_client: Arc<Client>,
170 system_specs: SystemSpecs,
171 ) -> anyhow::Result<()> {
172 let feedback_endpoint = format!("{}/api/feedback", *ZED_SERVER_URL);
173
174 let telemetry = zed_client.telemetry();
175 let metrics_id = telemetry.metrics_id();
176 let installation_id = telemetry.installation_id();
177 let is_staff = telemetry.is_staff();
178 let http_client = zed_client.http_client();
179
180 let re = Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b").unwrap();
181
182 let emails: Vec<&str> = re
183 .captures_iter(feedback_text)
184 .map(|capture| capture.get(0).unwrap().as_str())
185 .collect();
186
187 let email = emails.first().map(|e| e.to_string());
188
189 let request = FeedbackRequestBody {
190 feedback_text: &feedback_text,
191 email,
192 metrics_id,
193 installation_id,
194 system_specs,
195 is_staff: is_staff.unwrap_or(false),
196 token: ZED_SECRET_CLIENT_TOKEN,
197 };
198
199 let json_bytes = serde_json::to_vec(&request)?;
200
201 let request = Request::post(feedback_endpoint)
202 .header("content-type", "application/json")
203 .body(json_bytes.into())?;
204
205 let mut response = http_client.send(request).await?;
206 let mut body = String::new();
207 response.body_mut().read_to_string(&mut body).await?;
208
209 let response_status = response.status();
210
211 if !response_status.is_success() {
212 bail!("Feedback API failed with error: {}", response_status)
213 }
214
215 Ok(())
216 }
217}
218
219impl FeedbackEditor {
220 pub fn deploy(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
221 let markdown = workspace
222 .app_state()
223 .languages
224 .language_for_name("Markdown");
225 cx.spawn(|workspace, mut cx| async move {
226 let markdown = markdown.await.log_err();
227 workspace
228 .update(&mut cx, |workspace, cx| {
229 workspace.with_local_workspace(cx, |workspace, cx| {
230 let project = workspace.project().clone();
231 let buffer = project
232 .update(cx, |project, cx| project.create_buffer("", markdown, cx))
233 .expect("creating buffers on a local workspace always succeeds");
234 let system_specs = SystemSpecs::new(cx);
235 let feedback_editor = cx
236 .add_view(|cx| FeedbackEditor::new(system_specs, project, buffer, cx));
237 workspace.add_item(Box::new(feedback_editor), cx);
238 })
239 })?
240 .await
241 })
242 .detach_and_log_err(cx);
243 }
244}
245
246impl View for FeedbackEditor {
247 fn ui_name() -> &'static str {
248 "FeedbackEditor"
249 }
250
251 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
252 ChildView::new(&self.editor, cx).into_any()
253 }
254
255 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
256 if cx.is_self_focused() {
257 cx.focus(&self.editor);
258 }
259 }
260}
261
262impl Entity for FeedbackEditor {
263 type Event = editor::Event;
264}
265
266impl Item for FeedbackEditor {
267 fn tab_tooltip_text(&self, _: &AppContext) -> Option<Cow<str>> {
268 Some("Send Feedback".into())
269 }
270
271 fn tab_content<T: 'static>(
272 &self,
273 _: Option<usize>,
274 style: &theme::Tab,
275 _: &AppContext,
276 ) -> AnyElement<T> {
277 Flex::row()
278 .with_child(
279 Svg::new("icons/feedback.svg")
280 .with_color(style.label.text.color)
281 .constrained()
282 .with_width(style.type_icon_width)
283 .aligned()
284 .contained()
285 .with_margin_right(style.spacing),
286 )
287 .with_child(
288 Label::new("Send Feedback", style.label.clone())
289 .aligned()
290 .contained(),
291 )
292 .into_any()
293 }
294
295 fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
296 self.editor.for_each_project_item(cx, f)
297 }
298
299 fn is_singleton(&self, _: &AppContext) -> bool {
300 true
301 }
302
303 fn can_save(&self, _: &AppContext) -> bool {
304 true
305 }
306
307 fn save(
308 &mut self,
309 _: ModelHandle<Project>,
310 cx: &mut ViewContext<Self>,
311 ) -> Task<anyhow::Result<()>> {
312 self.submit(cx)
313 }
314
315 fn save_as(
316 &mut self,
317 _: ModelHandle<Project>,
318 _: std::path::PathBuf,
319 cx: &mut ViewContext<Self>,
320 ) -> Task<anyhow::Result<()>> {
321 self.submit(cx)
322 }
323
324 fn reload(
325 &mut self,
326 _: ModelHandle<Project>,
327 _: &mut ViewContext<Self>,
328 ) -> Task<anyhow::Result<()>> {
329 Task::Ready(Some(Ok(())))
330 }
331
332 fn clone_on_split(
333 &self,
334 _workspace_id: workspace::WorkspaceId,
335 cx: &mut ViewContext<Self>,
336 ) -> Option<Self>
337 where
338 Self: Sized,
339 {
340 let buffer = self
341 .editor
342 .read(cx)
343 .buffer()
344 .read(cx)
345 .as_singleton()
346 .expect("Feedback buffer is only ever singleton");
347
348 Some(Self::new(
349 self.system_specs.clone(),
350 self.project.clone(),
351 buffer.clone(),
352 cx,
353 ))
354 }
355
356 fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
357 Some(Box::new(handle.clone()))
358 }
359
360 fn act_as_type<'a>(
361 &'a self,
362 type_id: TypeId,
363 self_handle: &'a ViewHandle<Self>,
364 _: &'a AppContext,
365 ) -> Option<&'a AnyViewHandle> {
366 if type_id == TypeId::of::<Self>() {
367 Some(self_handle)
368 } else if type_id == TypeId::of::<Editor>() {
369 Some(&self.editor)
370 } else {
371 None
372 }
373 }
374
375 fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
376 Editor::to_item_events(event)
377 }
378}
379
380impl SearchableItem for FeedbackEditor {
381 type Match = Range<Anchor>;
382
383 fn to_search_event(
384 &mut self,
385 event: &Self::Event,
386 cx: &mut ViewContext<Self>,
387 ) -> Option<workspace::searchable::SearchEvent> {
388 self.editor
389 .update(cx, |editor, cx| editor.to_search_event(event, cx))
390 }
391
392 fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
393 self.editor
394 .update(cx, |editor, cx| editor.clear_matches(cx))
395 }
396
397 fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
398 self.editor
399 .update(cx, |editor, cx| editor.update_matches(matches, cx))
400 }
401
402 fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
403 self.editor
404 .update(cx, |editor, cx| editor.query_suggestion(cx))
405 }
406
407 fn activate_match(
408 &mut self,
409 index: usize,
410 matches: Vec<Self::Match>,
411 cx: &mut ViewContext<Self>,
412 ) {
413 self.editor
414 .update(cx, |editor, cx| editor.activate_match(index, matches, cx))
415 }
416
417 fn select_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
418 self.editor
419 .update(cx, |e, cx| e.select_matches(matches, cx))
420 }
421 fn replace(&mut self, matches: &Self::Match, query: &SearchQuery, cx: &mut ViewContext<Self>) {
422 self.editor
423 .update(cx, |e, cx| e.replace(matches, query, cx));
424 }
425 fn find_matches(
426 &mut self,
427 query: Arc<project::search::SearchQuery>,
428 cx: &mut ViewContext<Self>,
429 ) -> Task<Vec<Self::Match>> {
430 self.editor
431 .update(cx, |editor, cx| editor.find_matches(query, cx))
432 }
433
434 fn active_match_index(
435 &mut self,
436 matches: Vec<Self::Match>,
437 cx: &mut ViewContext<Self>,
438 ) -> Option<usize> {
439 self.editor
440 .update(cx, |editor, cx| editor.active_match_index(matches, cx))
441 }
442}