feedback_editor.rs

  1use std::{
  2    any::TypeId,
  3    ops::{Range, RangeInclusive},
  4    sync::Arc,
  5};
  6
  7use anyhow::bail;
  8use client::{Client, ZED_SECRET_CLIENT_TOKEN};
  9use editor::{Anchor, Editor};
 10use futures::AsyncReadExt;
 11use gpui::{
 12    actions,
 13    elements::{ChildView, Flex, Label, MouseEventHandler, ParentElement, Stack, Text},
 14    serde_json, AnyViewHandle, AppContext, CursorStyle, Element, ElementBox, Entity, ModelHandle,
 15    MouseButton, MutableAppContext, PromptLevel, RenderContext, Task, View, ViewContext,
 16    ViewHandle, WeakViewHandle,
 17};
 18use isahc::Request;
 19use language::Buffer;
 20use postage::prelude::Stream;
 21
 22use lazy_static::lazy_static;
 23use project::Project;
 24use serde::Serialize;
 25use settings::Settings;
 26use workspace::{
 27    item::{Item, ItemHandle},
 28    searchable::{SearchableItem, SearchableItemHandle},
 29    StatusItemView, Workspace,
 30};
 31
 32use crate::system_specs::SystemSpecs;
 33
 34lazy_static! {
 35    pub static ref ZED_SERVER_URL: String =
 36        std::env::var("ZED_SERVER_URL").unwrap_or_else(|_| "https://zed.dev".to_string());
 37}
 38
 39const FEEDBACK_CHAR_LIMIT: RangeInclusive<usize> = 10..=5000;
 40const FEEDBACK_PLACEHOLDER_TEXT: &str = "Thanks for spending time with Zed. Enter your feedback here as Markdown. Save the tab to submit your feedback.";
 41const FEEDBACK_SUBMISSION_ERROR_TEXT: &str =
 42    "Feedback failed to submit, see error log for details.";
 43
 44actions!(feedback, [SubmitFeedback, GiveFeedback, DeployFeedback]);
 45
 46pub fn init(cx: &mut MutableAppContext) {
 47    cx.add_action(FeedbackEditor::deploy);
 48}
 49
 50pub struct FeedbackButton;
 51
 52impl Entity for FeedbackButton {
 53    type Event = ();
 54}
 55
 56impl View for FeedbackButton {
 57    fn ui_name() -> &'static str {
 58        "FeedbackButton"
 59    }
 60
 61    fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
 62        Stack::new()
 63            .with_child(
 64                MouseEventHandler::<Self>::new(0, cx, |state, cx| {
 65                    let theme = &cx.global::<Settings>().theme;
 66                    let theme = &theme.workspace.status_bar.feedback;
 67
 68                    Text::new(
 69                        "Give Feedback".to_string(),
 70                        theme.style_for(state, true).clone(),
 71                    )
 72                    .boxed()
 73                })
 74                .with_cursor_style(CursorStyle::PointingHand)
 75                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(GiveFeedback))
 76                .boxed(),
 77            )
 78            .boxed()
 79    }
 80}
 81
 82impl StatusItemView for FeedbackButton {
 83    fn set_active_pane_item(&mut self, _: Option<&dyn ItemHandle>, _: &mut ViewContext<Self>) {}
 84}
 85
 86#[derive(Serialize)]
 87struct FeedbackRequestBody<'a> {
 88    feedback_text: &'a str,
 89    metrics_id: Option<Arc<str>>,
 90    system_specs: SystemSpecs,
 91    token: &'a str,
 92}
 93
 94#[derive(Clone)]
 95struct FeedbackEditor {
 96    editor: ViewHandle<Editor>,
 97    project: ModelHandle<Project>,
 98}
 99
100impl FeedbackEditor {
101    fn new_with_buffer(
102        project: ModelHandle<Project>,
103        buffer: ModelHandle<Buffer>,
104        cx: &mut ViewContext<Self>,
105    ) -> Self {
106        let editor = cx.add_view(|cx| {
107            let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx);
108            editor.set_vertical_scroll_margin(5, cx);
109            editor.set_placeholder_text(FEEDBACK_PLACEHOLDER_TEXT, cx);
110            editor
111        });
112
113        cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone()))
114            .detach();
115
116        Self { editor, project }
117    }
118
119    fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
120        let markdown_language = project.read(cx).languages().language_for_name("Markdown");
121
122        let buffer = project
123            .update(cx, |project, cx| {
124                project.create_buffer("", markdown_language, cx)
125            })
126            .expect("creating buffers on a local workspace always succeeds");
127
128        Self::new_with_buffer(project, buffer, cx)
129    }
130
131    fn handle_save(
132        &mut self,
133        _: ModelHandle<Project>,
134        cx: &mut ViewContext<Self>,
135    ) -> Task<anyhow::Result<()>> {
136        let feedback_char_count = self.editor.read(cx).text(cx).chars().count();
137
138        let error = if feedback_char_count < *FEEDBACK_CHAR_LIMIT.start() {
139            Some(format!(
140                "Feedback can't be shorter than {} characters.",
141                FEEDBACK_CHAR_LIMIT.start()
142            ))
143        } else if feedback_char_count > *FEEDBACK_CHAR_LIMIT.end() {
144            Some(format!(
145                "Feedback can't be longer than {} characters.",
146                FEEDBACK_CHAR_LIMIT.end()
147            ))
148        } else {
149            None
150        };
151
152        if let Some(error) = error {
153            cx.prompt(PromptLevel::Critical, &error, &["OK"]);
154            return Task::ready(Ok(()));
155        }
156
157        let mut answer = cx.prompt(
158            PromptLevel::Info,
159            "Ready to submit your feedback?",
160            &["Yes, Submit!", "No"],
161        );
162
163        let this = cx.handle();
164        let client = cx.global::<Arc<Client>>().clone();
165        let feedback_text = self.editor.read(cx).text(cx);
166        let specs = SystemSpecs::new(cx);
167
168        cx.spawn(|_, mut cx| async move {
169            let answer = answer.recv().await;
170
171            if answer == Some(0) {
172                match FeedbackEditor::submit_feedback(&feedback_text, client, specs).await {
173                    Ok(_) => {
174                        cx.update(|cx| {
175                            this.update(cx, |_, cx| {
176                                cx.dispatch_action(workspace::CloseActiveItem);
177                            })
178                        });
179                    }
180                    Err(error) => {
181                        log::error!("{}", error);
182
183                        cx.update(|cx| {
184                            this.update(cx, |_, cx| {
185                                cx.prompt(
186                                    PromptLevel::Critical,
187                                    FEEDBACK_SUBMISSION_ERROR_TEXT,
188                                    &["OK"],
189                                );
190                            })
191                        });
192                    }
193                }
194            }
195        })
196        .detach();
197
198        Task::ready(Ok(()))
199    }
200
201    async fn submit_feedback(
202        feedback_text: &str,
203        zed_client: Arc<Client>,
204        system_specs: SystemSpecs,
205    ) -> anyhow::Result<()> {
206        let feedback_endpoint = format!("{}/api/feedback", *ZED_SERVER_URL);
207
208        let metrics_id = zed_client.metrics_id();
209        let http_client = zed_client.http_client();
210
211        let request = FeedbackRequestBody {
212            feedback_text: &feedback_text,
213            metrics_id,
214            system_specs,
215            token: ZED_SECRET_CLIENT_TOKEN,
216        };
217
218        let json_bytes = serde_json::to_vec(&request)?;
219
220        let request = Request::post(feedback_endpoint)
221            .header("content-type", "application/json")
222            .body(json_bytes.into())?;
223
224        let mut response = http_client.send(request).await?;
225        let mut body = String::new();
226        response.body_mut().read_to_string(&mut body).await?;
227
228        let response_status = response.status();
229
230        if !response_status.is_success() {
231            bail!("Feedback API failed with error: {}", response_status)
232        }
233
234        Ok(())
235    }
236}
237
238impl FeedbackEditor {
239    pub fn deploy(workspace: &mut Workspace, _: &GiveFeedback, cx: &mut ViewContext<Workspace>) {
240        let feedback_editor =
241            cx.add_view(|cx| FeedbackEditor::new(workspace.project().clone(), cx));
242        workspace.add_item(Box::new(feedback_editor), 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 RenderContext<Self>) -> ElementBox {
252        ChildView::new(&self.editor, cx).boxed()
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_content(&self, _: Option<usize>, style: &theme::Tab, _: &AppContext) -> ElementBox {
268        Flex::row()
269            .with_child(
270                Label::new("Feedback".to_string(), style.label.clone())
271                    .aligned()
272                    .contained()
273                    .boxed(),
274            )
275            .boxed()
276    }
277
278    fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
279        self.editor.for_each_project_item(cx, f)
280    }
281
282    fn to_item_events(_: &Self::Event) -> Vec<workspace::item::ItemEvent> {
283        Vec::new()
284    }
285
286    fn is_singleton(&self, _: &AppContext) -> bool {
287        true
288    }
289
290    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
291
292    fn can_save(&self, _: &AppContext) -> bool {
293        true
294    }
295
296    fn save(
297        &mut self,
298        project: ModelHandle<Project>,
299        cx: &mut ViewContext<Self>,
300    ) -> Task<anyhow::Result<()>> {
301        self.handle_save(project, cx)
302    }
303
304    fn save_as(
305        &mut self,
306        project: ModelHandle<Project>,
307        _: std::path::PathBuf,
308        cx: &mut ViewContext<Self>,
309    ) -> Task<anyhow::Result<()>> {
310        self.handle_save(project, cx)
311    }
312
313    fn reload(
314        &mut self,
315        _: ModelHandle<Project>,
316        _: &mut ViewContext<Self>,
317    ) -> Task<anyhow::Result<()>> {
318        unreachable!("reload should not have been called")
319    }
320
321    fn clone_on_split(
322        &self,
323        _workspace_id: workspace::WorkspaceId,
324        cx: &mut ViewContext<Self>,
325    ) -> Option<Self>
326    where
327        Self: Sized,
328    {
329        let buffer = self
330            .editor
331            .read(cx)
332            .buffer()
333            .read(cx)
334            .as_singleton()
335            .expect("Feedback buffer is only ever singleton");
336
337        Some(Self::new_with_buffer(
338            self.project.clone(),
339            buffer.clone(),
340            cx,
341        ))
342    }
343
344    fn serialized_item_kind() -> Option<&'static str> {
345        None
346    }
347
348    fn deserialize(
349        _: ModelHandle<Project>,
350        _: WeakViewHandle<Workspace>,
351        _: workspace::WorkspaceId,
352        _: workspace::ItemId,
353        _: &mut ViewContext<workspace::Pane>,
354    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
355        unreachable!()
356    }
357
358    fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
359        Some(Box::new(handle.clone()))
360    }
361
362    fn act_as_type(
363        &self,
364        type_id: TypeId,
365        self_handle: &ViewHandle<Self>,
366        _: &AppContext,
367    ) -> Option<AnyViewHandle> {
368        if type_id == TypeId::of::<Self>() {
369            Some(self_handle.into())
370        } else if type_id == TypeId::of::<Editor>() {
371            Some((&self.editor).into())
372        } else {
373            None
374        }
375    }
376}
377
378impl SearchableItem for FeedbackEditor {
379    type Match = Range<Anchor>;
380
381    fn to_search_event(event: &Self::Event) -> Option<workspace::searchable::SearchEvent> {
382        Editor::to_search_event(event)
383    }
384
385    fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
386        self.editor
387            .update(cx, |editor, cx| editor.clear_matches(cx))
388    }
389
390    fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
391        self.editor
392            .update(cx, |editor, cx| editor.update_matches(matches, cx))
393    }
394
395    fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
396        self.editor
397            .update(cx, |editor, cx| editor.query_suggestion(cx))
398    }
399
400    fn activate_match(
401        &mut self,
402        index: usize,
403        matches: Vec<Self::Match>,
404        cx: &mut ViewContext<Self>,
405    ) {
406        self.editor
407            .update(cx, |editor, cx| editor.activate_match(index, matches, cx))
408    }
409
410    fn find_matches(
411        &mut self,
412        query: project::search::SearchQuery,
413        cx: &mut ViewContext<Self>,
414    ) -> Task<Vec<Self::Match>> {
415        self.editor
416            .update(cx, |editor, cx| editor.find_matches(query, cx))
417    }
418
419    fn active_match_index(
420        &mut self,
421        matches: Vec<Self::Match>,
422        cx: &mut ViewContext<Self>,
423    ) -> Option<usize> {
424        self.editor
425            .update(cx, |editor, cx| editor.active_match_index(matches, cx))
426    }
427}