feedback_editor.rs

  1use std::{
  2    ops::{Range, RangeInclusive},
  3    sync::Arc,
  4};
  5
  6use anyhow::bail;
  7use client::{Client, ZED_SECRET_CLIENT_TOKEN};
  8use editor::{Anchor, Editor};
  9use futures::AsyncReadExt;
 10use gpui::{
 11    actions,
 12    elements::{ChildView, Flex, Label, MouseEventHandler, ParentElement, Stack, Text},
 13    serde_json, AnyViewHandle, AppContext, CursorStyle, Element, ElementBox, Entity, ModelHandle,
 14    MouseButton, MutableAppContext, PromptLevel, RenderContext, Task, View, ViewContext,
 15    ViewHandle,
 16};
 17use isahc::Request;
 18use language::Buffer;
 19use postage::prelude::Stream;
 20
 21use lazy_static::lazy_static;
 22use project::Project;
 23use serde::Serialize;
 24use settings::Settings;
 25use workspace::{
 26    item::{Item, ItemHandle},
 27    searchable::{SearchableItem, SearchableItemHandle},
 28    StatusItemView, Workspace,
 29};
 30
 31use crate::system_specs::SystemSpecs;
 32
 33lazy_static! {
 34    pub static ref ZED_SERVER_URL: String =
 35        std::env::var("ZED_SERVER_URL").unwrap_or_else(|_| "https://zed.dev".to_string());
 36}
 37
 38const FEEDBACK_CHAR_LIMIT: RangeInclusive<usize> = 10..=5000;
 39const FEEDBACK_PLACEHOLDER_TEXT: &str = "Thanks for spending time with Zed. Enter your feedback here as Markdown. Save the tab to submit your feedback.";
 40const FEEDBACK_SUBMISSION_ERROR_TEXT: &str =
 41    "Feedback failed to submit, see error log for details.";
 42
 43actions!(feedback, [SubmitFeedback, GiveFeedback, DeployFeedback]);
 44
 45pub fn init(cx: &mut MutableAppContext) {
 46    cx.add_action(FeedbackEditor::deploy);
 47}
 48
 49pub struct FeedbackButton;
 50
 51impl Entity for FeedbackButton {
 52    type Event = ();
 53}
 54
 55impl View for FeedbackButton {
 56    fn ui_name() -> &'static str {
 57        "FeedbackButton"
 58    }
 59
 60    fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
 61        Stack::new()
 62            .with_child(
 63                MouseEventHandler::<Self>::new(0, cx, |state, cx| {
 64                    let theme = &cx.global::<Settings>().theme;
 65                    let theme = &theme.workspace.status_bar.feedback;
 66
 67                    Text::new(
 68                        "Give Feedback".to_string(),
 69                        theme.style_for(state, true).clone(),
 70                    )
 71                    .boxed()
 72                })
 73                .with_cursor_style(CursorStyle::PointingHand)
 74                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(GiveFeedback))
 75                .boxed(),
 76            )
 77            .boxed()
 78    }
 79}
 80
 81impl StatusItemView for FeedbackButton {
 82    fn set_active_pane_item(
 83        &mut self,
 84        _: Option<&dyn ItemHandle>,
 85        _: &mut gpui::ViewContext<Self>,
 86    ) {
 87    }
 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        let this = Self { editor, project };
121        this
122    }
123
124    fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
125        let markdown_language = project.read(cx).languages().language_for_name("Markdown");
126
127        let buffer = project
128            .update(cx, |project, cx| {
129                project.create_buffer("", markdown_language, cx)
130            })
131            .expect("creating buffers on a local workspace always succeeds");
132
133        Self::new_with_buffer(project, buffer, cx)
134    }
135
136    fn handle_save(
137        &mut self,
138        _: gpui::ModelHandle<Project>,
139        cx: &mut ViewContext<Self>,
140    ) -> Task<anyhow::Result<()>> {
141        let feedback_char_count = self.editor.read(cx).buffer().read(cx).len(cx);
142
143        let error = if feedback_char_count < *FEEDBACK_CHAR_LIMIT.start() {
144            Some(format!(
145                "Feedback can't be shorter than {} characters.",
146                FEEDBACK_CHAR_LIMIT.start()
147            ))
148        } else if feedback_char_count > *FEEDBACK_CHAR_LIMIT.end() {
149            Some(format!(
150                "Feedback can't be longer than {} characters.",
151                FEEDBACK_CHAR_LIMIT.end()
152            ))
153        } else {
154            None
155        };
156
157        if let Some(error) = error {
158            cx.prompt(PromptLevel::Critical, &error, &["OK"]);
159            return Task::ready(Ok(()));
160        }
161
162        let mut answer = cx.prompt(
163            PromptLevel::Info,
164            "Ready to submit your feedback?",
165            &["Yes, Submit!", "No"],
166        );
167
168        let this = cx.handle();
169        let client = cx.global::<Arc<Client>>().clone();
170        let feedback_text = self.editor.read(cx).text(cx);
171        let specs = SystemSpecs::new(cx);
172
173        cx.spawn(|_, mut cx| async move {
174            let answer = answer.recv().await;
175
176            if answer == Some(0) {
177                match FeedbackEditor::submit_feedback(&feedback_text, client, specs).await {
178                    Ok(_) => {
179                        cx.update(|cx| {
180                            this.update(cx, |_, cx| {
181                                cx.dispatch_action(workspace::CloseActiveItem);
182                            })
183                        });
184                    }
185                    Err(error) => {
186                        log::error!("{}", error);
187
188                        cx.update(|cx| {
189                            this.update(cx, |_, cx| {
190                                cx.prompt(
191                                    PromptLevel::Critical,
192                                    FEEDBACK_SUBMISSION_ERROR_TEXT,
193                                    &["OK"],
194                                );
195                            })
196                        });
197                    }
198                }
199            }
200        })
201        .detach();
202
203        Task::ready(Ok(()))
204    }
205
206    async fn submit_feedback(
207        feedback_text: &str,
208        zed_client: Arc<Client>,
209        system_specs: SystemSpecs,
210    ) -> anyhow::Result<()> {
211        let feedback_endpoint = format!("{}/api/feedback", *ZED_SERVER_URL);
212
213        let metrics_id = zed_client.metrics_id();
214        let http_client = zed_client.http_client();
215
216        let request = FeedbackRequestBody {
217            feedback_text: &feedback_text,
218            metrics_id,
219            system_specs,
220            token: ZED_SECRET_CLIENT_TOKEN,
221        };
222
223        let json_bytes = serde_json::to_vec(&request)?;
224
225        let request = Request::post(feedback_endpoint)
226            .header("content-type", "application/json")
227            .body(json_bytes.into())?;
228
229        let mut response = http_client.send(request).await?;
230        let mut body = String::new();
231        response.body_mut().read_to_string(&mut body).await?;
232
233        let response_status = response.status();
234
235        if !response_status.is_success() {
236            bail!("Feedback API failed with error: {}", response_status)
237        }
238
239        Ok(())
240    }
241}
242
243impl FeedbackEditor {
244    pub fn deploy(workspace: &mut Workspace, _: &GiveFeedback, cx: &mut ViewContext<Workspace>) {
245        let feedback_editor =
246            cx.add_view(|cx| FeedbackEditor::new(workspace.project().clone(), cx));
247        workspace.add_item(Box::new(feedback_editor), cx);
248    }
249}
250
251impl View for FeedbackEditor {
252    fn ui_name() -> &'static str {
253        "FeedbackEditor"
254    }
255
256    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
257        ChildView::new(&self.editor, cx).boxed()
258    }
259
260    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
261        if cx.is_self_focused() {
262            cx.focus(&self.editor);
263        }
264    }
265}
266
267impl Entity for FeedbackEditor {
268    type Event = editor::Event;
269}
270
271impl Item for FeedbackEditor {
272    fn tab_content(
273        &self,
274        _: Option<usize>,
275        style: &theme::Tab,
276        _: &gpui::AppContext,
277    ) -> ElementBox {
278        Flex::row()
279            .with_child(
280                Label::new("Feedback".to_string(), style.label.clone())
281                    .aligned()
282                    .contained()
283                    .boxed(),
284            )
285            .boxed()
286    }
287
288    fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
289        self.editor.for_each_project_item(cx, f)
290    }
291
292    fn to_item_events(_: &Self::Event) -> Vec<workspace::item::ItemEvent> {
293        Vec::new()
294    }
295
296    fn is_singleton(&self, _: &gpui::AppContext) -> bool {
297        true
298    }
299
300    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
301
302    fn can_save(&self, _: &gpui::AppContext) -> bool {
303        true
304    }
305
306    fn save(
307        &mut self,
308        project: gpui::ModelHandle<Project>,
309        cx: &mut ViewContext<Self>,
310    ) -> Task<anyhow::Result<()>> {
311        self.handle_save(project, cx)
312    }
313
314    fn save_as(
315        &mut self,
316        project: gpui::ModelHandle<Project>,
317        _: std::path::PathBuf,
318        cx: &mut ViewContext<Self>,
319    ) -> Task<anyhow::Result<()>> {
320        self.handle_save(project, cx)
321    }
322
323    fn reload(
324        &mut self,
325        _: gpui::ModelHandle<Project>,
326        _: &mut ViewContext<Self>,
327    ) -> Task<anyhow::Result<()>> {
328        unreachable!("reload should not have been called")
329    }
330
331    fn clone_on_split(
332        &self,
333        _workspace_id: workspace::WorkspaceId,
334        cx: &mut ViewContext<Self>,
335    ) -> Option<Self>
336    where
337        Self: Sized,
338    {
339        let buffer = self
340            .editor
341            .read(cx)
342            .buffer()
343            .read(cx)
344            .as_singleton()
345            .expect("Feedback buffer is only ever singleton");
346
347        Some(Self::new_with_buffer(
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        _: gpui::ModelHandle<Project>,
360        _: gpui::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
373impl SearchableItem for FeedbackEditor {
374    type Match = Range<Anchor>;
375
376    fn to_search_event(event: &Self::Event) -> Option<workspace::searchable::SearchEvent> {
377        Editor::to_search_event(event)
378    }
379
380    fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
381        self.editor
382            .update(cx, |editor, cx| editor.clear_matches(cx))
383    }
384
385    fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
386        self.editor
387            .update(cx, |editor, cx| editor.update_matches(matches, cx))
388    }
389
390    fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
391        self.editor
392            .update(cx, |editor, cx| editor.query_suggestion(cx))
393    }
394
395    fn activate_match(
396        &mut self,
397        index: usize,
398        matches: Vec<Self::Match>,
399        cx: &mut ViewContext<Self>,
400    ) {
401        self.editor
402            .update(cx, |editor, cx| editor.activate_match(index, matches, cx))
403    }
404
405    fn find_matches(
406        &mut self,
407        query: project::search::SearchQuery,
408        cx: &mut ViewContext<Self>,
409    ) -> Task<Vec<Self::Match>> {
410        self.editor
411            .update(cx, |editor, cx| editor.find_matches(query, cx))
412    }
413
414    fn active_match_index(
415        &mut self,
416        matches: Vec<Self::Match>,
417        cx: &mut ViewContext<Self>,
418    ) -> Option<usize> {
419        self.editor
420            .update(cx, |editor, cx| editor.active_match_index(matches, cx))
421    }
422}