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