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, ZED_SERVER_URL};
  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 project::Project;
 23use serde::Serialize;
 24use settings::Settings;
 25use workspace::{
 26    item::{Item, ItemHandle},
 27    searchable::{SearchableItem, SearchableItemHandle},
 28    AppState, StatusItemView, Workspace,
 29};
 30
 31use crate::system_specs::SystemSpecs;
 32
 33const FEEDBACK_CHAR_LIMIT: RangeInclusive<usize> = 10..=5000;
 34const FEEDBACK_PLACEHOLDER_TEXT: &str = "Thanks for spending time with Zed. Enter your feedback here as Markdown. Save the tab to submit your feedback.";
 35const FEEDBACK_SUBMISSION_ERROR_TEXT: &str =
 36    "Feedback failed to submit, see error log for details.";
 37
 38actions!(feedback, [SubmitFeedback, GiveFeedback, DeployFeedback]);
 39
 40pub fn init(system_specs: SystemSpecs, app_state: Arc<AppState>, cx: &mut MutableAppContext) {
 41    cx.add_action({
 42        move |workspace: &mut Workspace, _: &GiveFeedback, cx: &mut ViewContext<Workspace>| {
 43            FeedbackEditor::deploy(system_specs.clone(), workspace, app_state.clone(), cx);
 44        }
 45    });
 46}
 47
 48pub struct FeedbackButton;
 49
 50impl Entity for FeedbackButton {
 51    type Event = ();
 52}
 53
 54impl View for FeedbackButton {
 55    fn ui_name() -> &'static str {
 56        "FeedbackButton"
 57    }
 58
 59    fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
 60        Stack::new()
 61            .with_child(
 62                MouseEventHandler::<Self>::new(0, cx, |state, cx| {
 63                    let theme = &cx.global::<Settings>().theme;
 64                    let theme = &theme.workspace.status_bar.feedback;
 65
 66                    Text::new(
 67                        "Give Feedback".to_string(),
 68                        theme.style_for(state, true).clone(),
 69                    )
 70                    .boxed()
 71                })
 72                .with_cursor_style(CursorStyle::PointingHand)
 73                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(GiveFeedback))
 74                .boxed(),
 75            )
 76            .boxed()
 77    }
 78}
 79
 80impl StatusItemView for FeedbackButton {
 81    fn set_active_pane_item(&mut self, _: Option<&dyn ItemHandle>, _: &mut ViewContext<Self>) {}
 82}
 83
 84#[derive(Serialize)]
 85struct FeedbackRequestBody<'a> {
 86    feedback_text: &'a str,
 87    metrics_id: Option<Arc<str>>,
 88    system_specs: SystemSpecs,
 89    token: &'a str,
 90}
 91
 92#[derive(Clone)]
 93struct FeedbackEditor {
 94    system_specs: SystemSpecs,
 95    editor: ViewHandle<Editor>,
 96    project: ModelHandle<Project>,
 97}
 98
 99impl FeedbackEditor {
100    fn new(
101        system_specs: SystemSpecs,
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 {
117            system_specs: system_specs.clone(),
118            editor,
119            project,
120        }
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 = self.system_specs.clone();
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        system_specs: SystemSpecs,
233        workspace: &mut Workspace,
234        app_state: Arc<AppState>,
235        cx: &mut ViewContext<Workspace>,
236    ) {
237        workspace
238            .with_local_workspace(&app_state, cx, |workspace, cx| {
239                let project = workspace.project().clone();
240                let markdown_language = project.read(cx).languages().language_for_name("Markdown");
241                let buffer = project
242                    .update(cx, |project, cx| {
243                        project.create_buffer("", markdown_language, cx)
244                    })
245                    .expect("creating buffers on a local workspace always succeeds");
246                let feedback_editor =
247                    cx.add_view(|cx| FeedbackEditor::new(system_specs, project, buffer, cx));
248                workspace.add_item(Box::new(feedback_editor), cx);
249            })
250            .detach();
251    }
252}
253
254impl View for FeedbackEditor {
255    fn ui_name() -> &'static str {
256        "FeedbackEditor"
257    }
258
259    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
260        ChildView::new(&self.editor, cx).boxed()
261    }
262
263    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
264        if cx.is_self_focused() {
265            cx.focus(&self.editor);
266        }
267    }
268}
269
270impl Entity for FeedbackEditor {
271    type Event = editor::Event;
272}
273
274impl Item for FeedbackEditor {
275    fn tab_content(&self, _: Option<usize>, style: &theme::Tab, _: &AppContext) -> ElementBox {
276        Flex::row()
277            .with_child(
278                Label::new("Feedback".to_string(), style.label.clone())
279                    .aligned()
280                    .contained()
281                    .boxed(),
282            )
283            .boxed()
284    }
285
286    fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
287        self.editor.for_each_project_item(cx, f)
288    }
289
290    fn to_item_events(_: &Self::Event) -> Vec<workspace::item::ItemEvent> {
291        Vec::new()
292    }
293
294    fn is_singleton(&self, _: &AppContext) -> bool {
295        true
296    }
297
298    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
299
300    fn can_save(&self, _: &AppContext) -> bool {
301        true
302    }
303
304    fn save(
305        &mut self,
306        project: ModelHandle<Project>,
307        cx: &mut ViewContext<Self>,
308    ) -> Task<anyhow::Result<()>> {
309        self.handle_save(project, cx)
310    }
311
312    fn save_as(
313        &mut self,
314        project: ModelHandle<Project>,
315        _: std::path::PathBuf,
316        cx: &mut ViewContext<Self>,
317    ) -> Task<anyhow::Result<()>> {
318        self.handle_save(project, cx)
319    }
320
321    fn reload(
322        &mut self,
323        _: ModelHandle<Project>,
324        _: &mut ViewContext<Self>,
325    ) -> Task<anyhow::Result<()>> {
326        unreachable!("reload should not have been called")
327    }
328
329    fn clone_on_split(
330        &self,
331        _workspace_id: workspace::WorkspaceId,
332        cx: &mut ViewContext<Self>,
333    ) -> Option<Self>
334    where
335        Self: Sized,
336    {
337        let buffer = self
338            .editor
339            .read(cx)
340            .buffer()
341            .read(cx)
342            .as_singleton()
343            .expect("Feedback buffer is only ever singleton");
344
345        Some(Self::new(
346            self.system_specs.clone(),
347            self.project.clone(),
348            buffer.clone(),
349            cx,
350        ))
351    }
352
353    fn serialized_item_kind() -> Option<&'static str> {
354        None
355    }
356
357    fn deserialize(
358        _: ModelHandle<Project>,
359        _: WeakViewHandle<Workspace>,
360        _: workspace::WorkspaceId,
361        _: workspace::ItemId,
362        _: &mut ViewContext<workspace::Pane>,
363    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
364        unreachable!()
365    }
366
367    fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
368        Some(Box::new(handle.clone()))
369    }
370
371    fn act_as_type(
372        &self,
373        type_id: TypeId,
374        self_handle: &ViewHandle<Self>,
375        _: &AppContext,
376    ) -> Option<AnyViewHandle> {
377        if type_id == TypeId::of::<Self>() {
378            Some(self_handle.into())
379        } else if type_id == TypeId::of::<Editor>() {
380            Some((&self.editor).into())
381        } else {
382            None
383        }
384    }
385}
386
387impl SearchableItem for FeedbackEditor {
388    type Match = Range<Anchor>;
389
390    fn to_search_event(event: &Self::Event) -> Option<workspace::searchable::SearchEvent> {
391        Editor::to_search_event(event)
392    }
393
394    fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
395        self.editor
396            .update(cx, |editor, cx| editor.clear_matches(cx))
397    }
398
399    fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
400        self.editor
401            .update(cx, |editor, cx| editor.update_matches(matches, cx))
402    }
403
404    fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
405        self.editor
406            .update(cx, |editor, cx| editor.query_suggestion(cx))
407    }
408
409    fn activate_match(
410        &mut self,
411        index: usize,
412        matches: Vec<Self::Match>,
413        cx: &mut ViewContext<Self>,
414    ) {
415        self.editor
416            .update(cx, |editor, cx| editor.activate_match(index, matches, cx))
417    }
418
419    fn find_matches(
420        &mut self,
421        query: project::search::SearchQuery,
422        cx: &mut ViewContext<Self>,
423    ) -> Task<Vec<Self::Match>> {
424        self.editor
425            .update(cx, |editor, cx| editor.find_matches(query, cx))
426    }
427
428    fn active_match_index(
429        &mut self,
430        matches: Vec<Self::Match>,
431        cx: &mut ViewContext<Self>,
432    ) -> Option<usize> {
433        self.editor
434            .update(cx, |editor, cx| editor.active_match_index(matches, cx))
435    }
436}