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