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_with_buffer(
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 new(
124        project: ModelHandle<Project>,
125        buffer: ModelHandle<Buffer>,
126        cx: &mut ViewContext<Self>,
127    ) -> Self {
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(
240        workspace: &mut Workspace,
241        app_state: Arc<AppState>,
242        cx: &mut ViewContext<Workspace>,
243    ) {
244        workspace
245            .with_local_workspace(&app_state, cx, |workspace, cx| {
246                let project = workspace.project().clone();
247                let markdown_language = project.read(cx).languages().language_for_name("Markdown");
248                let buffer = project
249                    .update(cx, |project, cx| {
250                        project.create_buffer("", markdown_language, cx)
251                    })
252                    .expect("creating buffers on a local workspace always succeeds");
253                let feedback_editor = cx.add_view(|cx| FeedbackEditor::new(project, buffer, cx));
254                workspace.add_item(Box::new(feedback_editor), cx);
255            })
256            .detach();
257    }
258}
259
260impl View for FeedbackEditor {
261    fn ui_name() -> &'static str {
262        "FeedbackEditor"
263    }
264
265    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
266        ChildView::new(&self.editor, cx).boxed()
267    }
268
269    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
270        if cx.is_self_focused() {
271            cx.focus(&self.editor);
272        }
273    }
274}
275
276impl Entity for FeedbackEditor {
277    type Event = editor::Event;
278}
279
280impl Item for FeedbackEditor {
281    fn tab_content(&self, _: Option<usize>, style: &theme::Tab, _: &AppContext) -> ElementBox {
282        Flex::row()
283            .with_child(
284                Label::new("Feedback".to_string(), style.label.clone())
285                    .aligned()
286                    .contained()
287                    .boxed(),
288            )
289            .boxed()
290    }
291
292    fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
293        self.editor.for_each_project_item(cx, f)
294    }
295
296    fn to_item_events(_: &Self::Event) -> Vec<workspace::item::ItemEvent> {
297        Vec::new()
298    }
299
300    fn is_singleton(&self, _: &AppContext) -> bool {
301        true
302    }
303
304    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
305
306    fn can_save(&self, _: &AppContext) -> bool {
307        true
308    }
309
310    fn save(
311        &mut self,
312        project: ModelHandle<Project>,
313        cx: &mut ViewContext<Self>,
314    ) -> Task<anyhow::Result<()>> {
315        self.handle_save(project, cx)
316    }
317
318    fn save_as(
319        &mut self,
320        project: ModelHandle<Project>,
321        _: std::path::PathBuf,
322        cx: &mut ViewContext<Self>,
323    ) -> Task<anyhow::Result<()>> {
324        self.handle_save(project, cx)
325    }
326
327    fn reload(
328        &mut self,
329        _: ModelHandle<Project>,
330        _: &mut ViewContext<Self>,
331    ) -> Task<anyhow::Result<()>> {
332        unreachable!("reload should not have been called")
333    }
334
335    fn clone_on_split(
336        &self,
337        _workspace_id: workspace::WorkspaceId,
338        cx: &mut ViewContext<Self>,
339    ) -> Option<Self>
340    where
341        Self: Sized,
342    {
343        let buffer = self
344            .editor
345            .read(cx)
346            .buffer()
347            .read(cx)
348            .as_singleton()
349            .expect("Feedback buffer is only ever singleton");
350
351        Some(Self::new_with_buffer(
352            self.project.clone(),
353            buffer.clone(),
354            cx,
355        ))
356    }
357
358    fn serialized_item_kind() -> Option<&'static str> {
359        None
360    }
361
362    fn deserialize(
363        _: ModelHandle<Project>,
364        _: WeakViewHandle<Workspace>,
365        _: workspace::WorkspaceId,
366        _: workspace::ItemId,
367        _: &mut ViewContext<workspace::Pane>,
368    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
369        unreachable!()
370    }
371
372    fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
373        Some(Box::new(handle.clone()))
374    }
375
376    fn act_as_type(
377        &self,
378        type_id: TypeId,
379        self_handle: &ViewHandle<Self>,
380        _: &AppContext,
381    ) -> Option<AnyViewHandle> {
382        if type_id == TypeId::of::<Self>() {
383            Some(self_handle.into())
384        } else if type_id == TypeId::of::<Editor>() {
385            Some((&self.editor).into())
386        } else {
387            None
388        }
389    }
390}
391
392impl SearchableItem for FeedbackEditor {
393    type Match = Range<Anchor>;
394
395    fn to_search_event(event: &Self::Event) -> Option<workspace::searchable::SearchEvent> {
396        Editor::to_search_event(event)
397    }
398
399    fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
400        self.editor
401            .update(cx, |editor, cx| editor.clear_matches(cx))
402    }
403
404    fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
405        self.editor
406            .update(cx, |editor, cx| editor.update_matches(matches, cx))
407    }
408
409    fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
410        self.editor
411            .update(cx, |editor, cx| editor.query_suggestion(cx))
412    }
413
414    fn activate_match(
415        &mut self,
416        index: usize,
417        matches: Vec<Self::Match>,
418        cx: &mut ViewContext<Self>,
419    ) {
420        self.editor
421            .update(cx, |editor, cx| editor.activate_match(index, matches, cx))
422    }
423
424    fn find_matches(
425        &mut self,
426        query: project::search::SearchQuery,
427        cx: &mut ViewContext<Self>,
428    ) -> Task<Vec<Self::Match>> {
429        self.editor
430            .update(cx, |editor, cx| editor.find_matches(query, cx))
431    }
432
433    fn active_match_index(
434        &mut self,
435        matches: Vec<Self::Match>,
436        cx: &mut ViewContext<Self>,
437    ) -> Option<usize> {
438        self.editor
439            .update(cx, |editor, cx| editor.active_match_index(matches, cx))
440    }
441}