feedback_editor.rs

  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}