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