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