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