feedback_editor.rs

  1use std::{ops::Range, sync::Arc};
  2
  3use anyhow::bail;
  4use client::{Client, ZED_SECRET_CLIENT_TOKEN};
  5use editor::{Anchor, Editor};
  6use futures::AsyncReadExt;
  7use gpui::{
  8    actions,
  9    elements::{ChildView, Flex, Label, MouseEventHandler, ParentElement, Stack, Text},
 10    serde_json, AnyViewHandle, AppContext, CursorStyle, Element, ElementBox, Entity, ModelHandle,
 11    MouseButton, MutableAppContext, PromptLevel, RenderContext, Task, View, ViewContext,
 12    ViewHandle,
 13};
 14use isahc::Request;
 15use language::Buffer;
 16use postage::prelude::Stream;
 17
 18use lazy_static::lazy_static;
 19use project::Project;
 20use serde::Serialize;
 21use settings::Settings;
 22use workspace::{
 23    item::{Item, ItemHandle},
 24    searchable::{SearchableItem, SearchableItemHandle},
 25    StatusItemView, Workspace,
 26};
 27
 28use crate::system_specs::SystemSpecs;
 29
 30lazy_static! {
 31    pub static ref ZED_SERVER_URL: String =
 32        std::env::var("ZED_SERVER_URL").unwrap_or_else(|_| "https://zed.dev".to_string());
 33}
 34
 35const FEEDBACK_CHAR_COUNT_RANGE: Range<usize> = Range {
 36    start: 10,
 37    end: 1000,
 38};
 39
 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(cx: &mut MutableAppContext) {
 47    cx.add_action(FeedbackEditor::deploy);
 48}
 49
 50pub struct FeedbackButton;
 51
 52impl Entity for FeedbackButton {
 53    type Event = ();
 54}
 55
 56impl View for FeedbackButton {
 57    fn ui_name() -> &'static str {
 58        "FeedbackButton"
 59    }
 60
 61    fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
 62        Stack::new()
 63            .with_child(
 64                MouseEventHandler::<Self>::new(0, cx, |state, cx| {
 65                    let theme = &cx.global::<Settings>().theme;
 66                    let theme = &theme.workspace.status_bar.feedback;
 67
 68                    Text::new(
 69                        "Give Feedback".to_string(),
 70                        theme.style_for(state, true).clone(),
 71                    )
 72                    .boxed()
 73                })
 74                .with_cursor_style(CursorStyle::PointingHand)
 75                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(GiveFeedback))
 76                .boxed(),
 77            )
 78            .boxed()
 79    }
 80}
 81
 82impl StatusItemView for FeedbackButton {
 83    fn set_active_pane_item(
 84        &mut self,
 85        _: Option<&dyn ItemHandle>,
 86        _: &mut gpui::ViewContext<Self>,
 87    ) {
 88    }
 89}
 90
 91#[derive(Serialize)]
 92struct FeedbackRequestBody<'a> {
 93    feedback_text: &'a str,
 94    metrics_id: Option<Arc<str>>,
 95    system_specs: SystemSpecs,
 96    token: &'a str,
 97}
 98
 99#[derive(Clone)]
100struct FeedbackEditor {
101    editor: ViewHandle<Editor>,
102    project: ModelHandle<Project>,
103}
104
105impl FeedbackEditor {
106    fn new_with_buffer(
107        project: ModelHandle<Project>,
108        buffer: ModelHandle<Buffer>,
109        cx: &mut ViewContext<Self>,
110    ) -> Self {
111        let editor = cx.add_view(|cx| {
112            let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx);
113            editor.set_vertical_scroll_margin(5, cx);
114            editor.set_placeholder_text(FEEDBACK_PLACEHOLDER_TEXT, cx);
115            editor
116        });
117
118        cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone()))
119            .detach();
120
121        let this = Self { editor, project };
122        this
123    }
124
125    fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
126        let markdown_language = project.read(cx).languages().get_language("Markdown");
127
128        let buffer = project
129            .update(cx, |project, cx| {
130                project.create_buffer("", markdown_language, cx)
131            })
132            .expect("creating buffers on a local workspace always succeeds");
133
134        Self::new_with_buffer(project, buffer, cx)
135    }
136
137    fn handle_save(
138        &mut self,
139        _: gpui::ModelHandle<Project>,
140        cx: &mut ViewContext<Self>,
141    ) -> Task<anyhow::Result<()>> {
142        let feedback_text_length = self.editor.read(cx).buffer().read(cx).len(cx);
143
144        if feedback_text_length <= FEEDBACK_CHAR_COUNT_RANGE.start {
145            cx.prompt(
146                PromptLevel::Critical,
147                &format!(
148                    "Feedback must be longer than {} characters",
149                    FEEDBACK_CHAR_COUNT_RANGE.start
150                ),
151                &["OK"],
152            );
153
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(workspace: &mut Workspace, _: &GiveFeedback, cx: &mut ViewContext<Workspace>) {
240        let feedback_editor =
241            cx.add_view(|cx| FeedbackEditor::new(workspace.project().clone(), cx));
242        workspace.add_item(Box::new(feedback_editor), cx);
243    }
244}
245
246impl View for FeedbackEditor {
247    fn ui_name() -> &'static str {
248        "FeedbackEditor"
249    }
250
251    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
252        ChildView::new(&self.editor, cx).boxed()
253    }
254
255    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
256        if cx.is_self_focused() {
257            cx.focus(&self.editor);
258        }
259    }
260}
261
262impl Entity for FeedbackEditor {
263    type Event = editor::Event;
264}
265
266impl Item for FeedbackEditor {
267    fn tab_content(
268        &self,
269        _: Option<usize>,
270        style: &theme::Tab,
271        _: &gpui::AppContext,
272    ) -> ElementBox {
273        Flex::row()
274            .with_child(
275                Label::new("Feedback".to_string(), style.label.clone())
276                    .aligned()
277                    .contained()
278                    .boxed(),
279            )
280            .boxed()
281    }
282
283    fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
284        self.editor.for_each_project_item(cx, f)
285    }
286
287    fn to_item_events(_: &Self::Event) -> Vec<workspace::item::ItemEvent> {
288        Vec::new()
289    }
290
291    fn is_singleton(&self, _: &gpui::AppContext) -> bool {
292        true
293    }
294
295    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
296
297    fn can_save(&self, _: &gpui::AppContext) -> bool {
298        true
299    }
300
301    fn save(
302        &mut self,
303        project: gpui::ModelHandle<Project>,
304        cx: &mut ViewContext<Self>,
305    ) -> Task<anyhow::Result<()>> {
306        self.handle_save(project, cx)
307    }
308
309    fn save_as(
310        &mut self,
311        project: gpui::ModelHandle<Project>,
312        _: std::path::PathBuf,
313        cx: &mut ViewContext<Self>,
314    ) -> Task<anyhow::Result<()>> {
315        self.handle_save(project, cx)
316    }
317
318    fn reload(
319        &mut self,
320        _: gpui::ModelHandle<Project>,
321        _: &mut ViewContext<Self>,
322    ) -> Task<anyhow::Result<()>> {
323        unreachable!("reload should not have been called")
324    }
325
326    fn clone_on_split(
327        &self,
328        _workspace_id: workspace::WorkspaceId,
329        cx: &mut ViewContext<Self>,
330    ) -> Option<Self>
331    where
332        Self: Sized,
333    {
334        let buffer = self
335            .editor
336            .read(cx)
337            .buffer()
338            .read(cx)
339            .as_singleton()
340            .expect("Feedback buffer is only ever singleton");
341
342        Some(Self::new_with_buffer(
343            self.project.clone(),
344            buffer.clone(),
345            cx,
346        ))
347    }
348
349    fn serialized_item_kind() -> Option<&'static str> {
350        None
351    }
352
353    fn deserialize(
354        _: gpui::ModelHandle<Project>,
355        _: gpui::WeakViewHandle<Workspace>,
356        _: workspace::WorkspaceId,
357        _: workspace::ItemId,
358        _: &mut ViewContext<workspace::Pane>,
359    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
360        unreachable!()
361    }
362
363    fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
364        Some(Box::new(handle.clone()))
365    }
366}
367
368impl SearchableItem for FeedbackEditor {
369    type Match = Range<Anchor>;
370
371    fn to_search_event(event: &Self::Event) -> Option<workspace::searchable::SearchEvent> {
372        Editor::to_search_event(event)
373    }
374
375    fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
376        self.editor
377            .update(cx, |editor, cx| editor.clear_matches(cx))
378    }
379
380    fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
381        self.editor
382            .update(cx, |editor, cx| editor.update_matches(matches, cx))
383    }
384
385    fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
386        self.editor
387            .update(cx, |editor, cx| editor.query_suggestion(cx))
388    }
389
390    fn activate_match(
391        &mut self,
392        index: usize,
393        matches: Vec<Self::Match>,
394        cx: &mut ViewContext<Self>,
395    ) {
396        self.editor
397            .update(cx, |editor, cx| editor.activate_match(index, matches, cx))
398    }
399
400    fn find_matches(
401        &mut self,
402        query: project::search::SearchQuery,
403        cx: &mut ViewContext<Self>,
404    ) -> Task<Vec<Self::Match>> {
405        self.editor
406            .update(cx, |editor, cx| editor.find_matches(query, cx))
407    }
408
409    fn active_match_index(
410        &mut self,
411        matches: Vec<Self::Match>,
412        cx: &mut ViewContext<Self>,
413    ) -> Option<usize> {
414        self.editor
415            .update(cx, |editor, cx| editor.active_match_index(matches, cx))
416    }
417}