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