feedback_editor.rs

  1use std::{ops::Range, sync::Arc};
  2
  3use anyhow::bail;
  4use client::{Client, ZED_SECRET_CLIENT_TOKEN};
  5use editor::{Anchor, Editor};
  6use futures::AsyncReadExt;
  7use gpui::{
  8    actions,
  9    elements::{ChildView, Flex, Label, MouseEventHandler, ParentElement, Stack, Text},
 10    serde_json, AnyViewHandle, AppContext, CursorStyle, Element, ElementBox, Entity, ModelHandle,
 11    MouseButton, MutableAppContext, PromptLevel, RenderContext, Task, View, ViewContext,
 12    ViewHandle,
 13};
 14use isahc::Request;
 15use language::Buffer;
 16use postage::prelude::Stream;
 17
 18use lazy_static::lazy_static;
 19use project::Project;
 20use serde::Serialize;
 21use settings::Settings;
 22use workspace::{
 23    item::{Item, ItemHandle},
 24    searchable::{SearchableItem, SearchableItemHandle},
 25    StatusItemView, Workspace,
 26};
 27
 28use crate::system_specs::SystemSpecs;
 29
 30lazy_static! {
 31    pub static ref ZED_SERVER_URL: String =
 32        std::env::var("ZED_SERVER_URL").unwrap_or_else(|_| "https://zed.dev".to_string());
 33}
 34
 35const FEEDBACK_CHAR_COUNT_RANGE: Range<usize> = Range {
 36    start: 10,
 37    end: 1000,
 38};
 39
 40const FEEDBACK_PLACEHOLDER_TEXT: &str = "Thanks for spending time with Zed. Enter your feedback here in the form of 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    fn focus_in(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {}
 82
 83    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {}
 84
 85    fn key_down(&mut self, _: &gpui::KeyDownEvent, _: &mut ViewContext<Self>) -> bool {
 86        false
 87    }
 88
 89    fn key_up(&mut self, _: &gpui::KeyUpEvent, _: &mut ViewContext<Self>) -> bool {
 90        false
 91    }
 92
 93    fn modifiers_changed(
 94        &mut self,
 95        _: &gpui::ModifiersChangedEvent,
 96        _: &mut ViewContext<Self>,
 97    ) -> bool {
 98        false
 99    }
100
101    fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap_matcher::KeymapContext {
102        Self::default_keymap_context()
103    }
104
105    fn default_keymap_context() -> gpui::keymap_matcher::KeymapContext {
106        let mut cx = gpui::keymap_matcher::KeymapContext::default();
107        cx.set.insert(Self::ui_name().into());
108        cx
109    }
110
111    fn debug_json(&self, _: &gpui::AppContext) -> gpui::serde_json::Value {
112        gpui::serde_json::Value::Null
113    }
114
115    fn text_for_range(&self, _: Range<usize>, _: &gpui::AppContext) -> Option<String> {
116        None
117    }
118
119    fn selected_text_range(&self, _: &gpui::AppContext) -> Option<Range<usize>> {
120        None
121    }
122
123    fn marked_text_range(&self, _: &gpui::AppContext) -> Option<Range<usize>> {
124        None
125    }
126
127    fn unmark_text(&mut self, _: &mut ViewContext<Self>) {}
128
129    fn replace_text_in_range(
130        &mut self,
131        _: Option<Range<usize>>,
132        _: &str,
133        _: &mut ViewContext<Self>,
134    ) {
135    }
136
137    fn replace_and_mark_text_in_range(
138        &mut self,
139        _: Option<Range<usize>>,
140        _: &str,
141        _: Option<Range<usize>>,
142        _: &mut ViewContext<Self>,
143    ) {
144    }
145}
146
147impl StatusItemView for FeedbackButton {
148    fn set_active_pane_item(
149        &mut self,
150        _: Option<&dyn ItemHandle>,
151        _: &mut gpui::ViewContext<Self>,
152    ) {
153    }
154}
155
156#[derive(Serialize)]
157struct FeedbackRequestBody<'a> {
158    feedback_text: &'a str,
159    metrics_id: Option<Arc<str>>,
160    system_specs: SystemSpecs,
161    token: &'a str,
162}
163
164#[derive(Clone)]
165struct FeedbackEditor {
166    editor: ViewHandle<Editor>,
167    project: ModelHandle<Project>,
168}
169
170impl FeedbackEditor {
171    fn new_with_buffer(
172        project: ModelHandle<Project>,
173        buffer: ModelHandle<Buffer>,
174        cx: &mut ViewContext<Self>,
175    ) -> Self {
176        let editor = cx.add_view(|cx| {
177            let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx);
178            editor.set_vertical_scroll_margin(5, cx);
179            editor.set_placeholder_text(FEEDBACK_PLACEHOLDER_TEXT, cx);
180            editor
181        });
182
183        cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone()))
184            .detach();
185
186        let this = Self { editor, project };
187        this
188    }
189
190    fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
191        let markdown_language = project.read(cx).languages().get_language("Markdown");
192
193        let buffer = project
194            .update(cx, |project, cx| {
195                project.create_buffer("", markdown_language, cx)
196            })
197            .expect("creating buffers on a local workspace always succeeds");
198
199        Self::new_with_buffer(project, buffer, cx)
200    }
201
202    fn handle_save(
203        &mut self,
204        _: gpui::ModelHandle<Project>,
205        cx: &mut ViewContext<Self>,
206    ) -> Task<anyhow::Result<()>> {
207        let feedback_text_length = self.editor.read(cx).buffer().read(cx).len(cx);
208
209        if feedback_text_length <= FEEDBACK_CHAR_COUNT_RANGE.start {
210            cx.prompt(
211                PromptLevel::Critical,
212                &format!(
213                    "Feedback must be longer than {} characters",
214                    FEEDBACK_CHAR_COUNT_RANGE.start
215                ),
216                &["OK"],
217            );
218
219            return Task::ready(Ok(()));
220        }
221
222        let mut answer = cx.prompt(
223            PromptLevel::Info,
224            "Ready to submit your feedback?",
225            &["Yes, Submit!", "No"],
226        );
227
228        let this = cx.handle();
229        let client = cx.global::<Arc<Client>>().clone();
230        let feedback_text = self.editor.read(cx).text(cx);
231        let specs = SystemSpecs::new(cx);
232
233        cx.spawn(|_, mut cx| async move {
234            let answer = answer.recv().await;
235
236            if answer == Some(0) {
237                match FeedbackEditor::submit_feedback(&feedback_text, client, specs).await {
238                    Ok(_) => {
239                        cx.update(|cx| {
240                            this.update(cx, |_, cx| {
241                                cx.dispatch_action(workspace::CloseActiveItem);
242                            })
243                        });
244                    }
245                    Err(error) => {
246                        log::error!("{}", error);
247
248                        cx.update(|cx| {
249                            this.update(cx, |_, cx| {
250                                cx.prompt(
251                                    PromptLevel::Critical,
252                                    FEEDBACK_SUBMISSION_ERROR_TEXT,
253                                    &["OK"],
254                                );
255                            })
256                        });
257                    }
258                }
259            }
260        })
261        .detach();
262
263        Task::ready(Ok(()))
264    }
265
266    async fn submit_feedback(
267        feedback_text: &str,
268        zed_client: Arc<Client>,
269        system_specs: SystemSpecs,
270    ) -> anyhow::Result<()> {
271        let feedback_endpoint = format!("{}/api/feedback", *ZED_SERVER_URL);
272
273        let metrics_id = zed_client.metrics_id();
274        let http_client = zed_client.http_client();
275
276        let request = FeedbackRequestBody {
277            feedback_text: &feedback_text,
278            metrics_id,
279            system_specs,
280            token: ZED_SECRET_CLIENT_TOKEN,
281        };
282
283        let json_bytes = serde_json::to_vec(&request)?;
284
285        let request = Request::post(feedback_endpoint)
286            .header("content-type", "application/json")
287            .body(json_bytes.into())?;
288
289        let mut response = http_client.send(request).await?;
290        let mut body = String::new();
291        response.body_mut().read_to_string(&mut body).await?;
292
293        let response_status = response.status();
294
295        if !response_status.is_success() {
296            bail!("Feedback API failed with error: {}", response_status)
297        }
298
299        Ok(())
300    }
301}
302
303impl FeedbackEditor {
304    pub fn deploy(workspace: &mut Workspace, _: &GiveFeedback, cx: &mut ViewContext<Workspace>) {
305        let feedback_editor =
306            cx.add_view(|cx| FeedbackEditor::new(workspace.project().clone(), cx));
307        workspace.add_item(Box::new(feedback_editor), cx);
308    }
309}
310
311impl View for FeedbackEditor {
312    fn ui_name() -> &'static str {
313        "FeedbackEditor"
314    }
315
316    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
317        ChildView::new(&self.editor, cx).boxed()
318    }
319
320    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
321        if cx.is_self_focused() {
322            cx.focus(&self.editor);
323        }
324    }
325}
326
327impl Entity for FeedbackEditor {
328    type Event = editor::Event;
329}
330
331impl Item for FeedbackEditor {
332    fn tab_content(
333        &self,
334        _: Option<usize>,
335        style: &theme::Tab,
336        _: &gpui::AppContext,
337    ) -> ElementBox {
338        Flex::row()
339            .with_child(
340                Label::new("Feedback".to_string(), style.label.clone())
341                    .aligned()
342                    .contained()
343                    .boxed(),
344            )
345            .boxed()
346    }
347
348    fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
349        self.editor.for_each_project_item(cx, f)
350    }
351
352    fn to_item_events(_: &Self::Event) -> Vec<workspace::item::ItemEvent> {
353        Vec::new()
354    }
355
356    fn is_singleton(&self, _: &gpui::AppContext) -> bool {
357        true
358    }
359
360    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
361
362    fn can_save(&self, _: &gpui::AppContext) -> bool {
363        true
364    }
365
366    fn save(
367        &mut self,
368        project: gpui::ModelHandle<Project>,
369        cx: &mut ViewContext<Self>,
370    ) -> Task<anyhow::Result<()>> {
371        self.handle_save(project, cx)
372    }
373
374    fn save_as(
375        &mut self,
376        project: gpui::ModelHandle<Project>,
377        _: std::path::PathBuf,
378        cx: &mut ViewContext<Self>,
379    ) -> Task<anyhow::Result<()>> {
380        self.handle_save(project, cx)
381    }
382
383    fn reload(
384        &mut self,
385        _: gpui::ModelHandle<Project>,
386        _: &mut ViewContext<Self>,
387    ) -> Task<anyhow::Result<()>> {
388        unreachable!("reload should not have been called")
389    }
390
391    fn clone_on_split(
392        &self,
393        _workspace_id: workspace::WorkspaceId,
394        cx: &mut ViewContext<Self>,
395    ) -> Option<Self>
396    where
397        Self: Sized,
398    {
399        let buffer = self
400            .editor
401            .read(cx)
402            .buffer()
403            .read(cx)
404            .as_singleton()
405            .expect("Feedback buffer is only ever singleton");
406
407        Some(Self::new_with_buffer(
408            self.project.clone(),
409            buffer.clone(),
410            cx,
411        ))
412    }
413
414    fn serialized_item_kind() -> Option<&'static str> {
415        None
416    }
417
418    fn deserialize(
419        _: gpui::ModelHandle<Project>,
420        _: gpui::WeakViewHandle<Workspace>,
421        _: workspace::WorkspaceId,
422        _: workspace::ItemId,
423        _: &mut ViewContext<workspace::Pane>,
424    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
425        unreachable!()
426    }
427
428    fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
429        Some(Box::new(handle.clone()))
430    }
431}
432
433impl SearchableItem for FeedbackEditor {
434    type Match = Range<Anchor>;
435
436    fn to_search_event(event: &Self::Event) -> Option<workspace::searchable::SearchEvent> {
437        Editor::to_search_event(event)
438    }
439
440    fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
441        self.editor
442            .update(cx, |editor, cx| editor.clear_matches(cx))
443    }
444
445    fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
446        self.editor
447            .update(cx, |editor, cx| editor.update_matches(matches, cx))
448    }
449
450    fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
451        self.editor
452            .update(cx, |editor, cx| editor.query_suggestion(cx))
453    }
454
455    fn activate_match(
456        &mut self,
457        index: usize,
458        matches: Vec<Self::Match>,
459        cx: &mut ViewContext<Self>,
460    ) {
461        self.editor
462            .update(cx, |editor, cx| editor.activate_match(index, matches, cx))
463    }
464
465    fn find_matches(
466        &mut self,
467        query: project::search::SearchQuery,
468        cx: &mut ViewContext<Self>,
469    ) -> Task<Vec<Self::Match>> {
470        self.editor
471            .update(cx, |editor, cx| editor.find_matches(query, cx))
472    }
473
474    fn active_match_index(
475        &mut self,
476        matches: Vec<Self::Match>,
477        cx: &mut ViewContext<Self>,
478    ) -> Option<usize> {
479        self.editor
480            .update(cx, |editor, cx| editor.active_match_index(matches, cx))
481    }
482}