feedback_editor.rs

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