feedback_editor.rs

  1use std::{ops::Range, sync::Arc};
  2
  3use anyhow::bail;
  4use client::{Client, ZED_SECRET_CLIENT_TOKEN};
  5use editor::Editor;
  6use futures::AsyncReadExt;
  7use gpui::{
  8    actions,
  9    elements::{ChildView, Flex, Label, MouseEventHandler, ParentElement, Stack, Text},
 10    serde_json, AnyViewHandle, CursorStyle, Element, ElementBox, Entity, ModelHandle, MouseButton,
 11    MutableAppContext, PromptLevel, RenderContext, Task, View, ViewContext, ViewHandle,
 12    WeakViewHandle,
 13};
 14use isahc::Request;
 15use language::{Language, LanguageConfig};
 16use postage::prelude::Stream;
 17
 18use lazy_static::lazy_static;
 19use project::{Project, ProjectEntryId, ProjectPath};
 20use serde::Serialize;
 21use settings::Settings;
 22use smallvec::SmallVec;
 23use workspace::{
 24    item::{Item, ItemHandle},
 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
 35// TODO FEEDBACK: In the future, it would be nice to use this is some sort of live-rendering character counter thing
 36// Currently, we are just checking on submit that the the text exceeds the `start` value in this range
 37const FEEDBACK_CHAR_COUNT_RANGE: Range<usize> = Range {
 38    start: 5,
 39    end: 1000,
 40};
 41
 42actions!(feedback, [SubmitFeedback, GiveFeedback, DeployFeedback]);
 43
 44pub fn init(cx: &mut MutableAppContext) {
 45    cx.add_action(FeedbackEditor::deploy);
 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    fn focus_in(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {}
 80
 81    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {}
 82
 83    fn key_down(&mut self, _: &gpui::KeyDownEvent, _: &mut ViewContext<Self>) -> bool {
 84        false
 85    }
 86
 87    fn key_up(&mut self, _: &gpui::KeyUpEvent, _: &mut ViewContext<Self>) -> bool {
 88        false
 89    }
 90
 91    fn modifiers_changed(
 92        &mut self,
 93        _: &gpui::ModifiersChangedEvent,
 94        _: &mut ViewContext<Self>,
 95    ) -> bool {
 96        false
 97    }
 98
 99    fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap_matcher::KeymapContext {
100        Self::default_keymap_context()
101    }
102
103    fn default_keymap_context() -> gpui::keymap_matcher::KeymapContext {
104        let mut cx = gpui::keymap_matcher::KeymapContext::default();
105        cx.set.insert(Self::ui_name().into());
106        cx
107    }
108
109    fn debug_json(&self, _: &gpui::AppContext) -> gpui::serde_json::Value {
110        gpui::serde_json::Value::Null
111    }
112
113    fn text_for_range(&self, _: Range<usize>, _: &gpui::AppContext) -> Option<String> {
114        None
115    }
116
117    fn selected_text_range(&self, _: &gpui::AppContext) -> Option<Range<usize>> {
118        None
119    }
120
121    fn marked_text_range(&self, _: &gpui::AppContext) -> Option<Range<usize>> {
122        None
123    }
124
125    fn unmark_text(&mut self, _: &mut ViewContext<Self>) {}
126
127    fn replace_text_in_range(
128        &mut self,
129        _: Option<Range<usize>>,
130        _: &str,
131        _: &mut ViewContext<Self>,
132    ) {
133    }
134
135    fn replace_and_mark_text_in_range(
136        &mut self,
137        _: Option<Range<usize>>,
138        _: &str,
139        _: Option<Range<usize>>,
140        _: &mut ViewContext<Self>,
141    ) {
142    }
143}
144
145impl StatusItemView for FeedbackButton {
146    fn set_active_pane_item(
147        &mut self,
148        _: Option<&dyn ItemHandle>,
149        _: &mut gpui::ViewContext<Self>,
150    ) {
151    }
152}
153
154#[derive(Serialize)]
155struct FeedbackRequestBody<'a> {
156    feedback_text: &'a str,
157    metrics_id: Option<Arc<str>>,
158    system_specs: SystemSpecs,
159    token: &'a str,
160}
161
162#[derive(Clone)]
163struct FeedbackEditor {
164    editor: ViewHandle<Editor>,
165}
166
167impl FeedbackEditor {
168    fn new(
169        project_handle: ModelHandle<Project>,
170        _: WeakViewHandle<Workspace>,
171        cx: &mut ViewContext<Self>,
172    ) -> Self {
173        // TODO FEEDBACK: This doesn't work like I expected it would
174        // let markdown_language = Arc::new(Language::new(
175        //     LanguageConfig::default(),
176        //     Some(tree_sitter_markdown::language()),
177        // ));
178
179        let markdown_language = project_handle
180            .read(cx)
181            .languages()
182            .get_language("Markdown")
183            .unwrap();
184
185        let buffer = project_handle
186            .update(cx, |project, cx| {
187                project.create_buffer("", Some(markdown_language), cx)
188            })
189            .expect("creating buffers on a local workspace always succeeds");
190
191        const FEDBACK_PLACEHOLDER_TEXT: &str = "Thanks for spending time with Zed. Enter your feedback here in the form of Markdown. Save the tab to submit your feedback.";
192
193        let editor = cx.add_view(|cx| {
194            let mut editor = Editor::for_buffer(buffer, Some(project_handle.clone()), cx);
195            editor.set_vertical_scroll_margin(5, cx);
196            editor.set_placeholder_text(FEDBACK_PLACEHOLDER_TEXT, cx);
197            editor
198        });
199
200        let this = Self { editor };
201        this
202    }
203
204    fn handle_save(
205        &mut self,
206        _: gpui::ModelHandle<Project>,
207        cx: &mut ViewContext<Self>,
208    ) -> Task<anyhow::Result<()>> {
209        // TODO FEEDBACK: These don't look right
210        let feedback_text_length = self.editor.read(cx).buffer().read(cx).len(cx);
211
212        if feedback_text_length <= FEEDBACK_CHAR_COUNT_RANGE.start {
213            cx.prompt(
214                PromptLevel::Critical,
215                &format!(
216                    "Feedback must be longer than {} characters",
217                    FEEDBACK_CHAR_COUNT_RANGE.start
218                ),
219                &["OK"],
220            );
221
222            return Task::ready(Ok(()));
223        }
224
225        let mut answer = cx.prompt(
226            PromptLevel::Warning,
227            "Ready to submit your feedback?",
228            &["Yes, Submit!", "No"],
229        );
230
231        let this = cx.handle();
232        cx.spawn(|_, mut cx| async move {
233            let answer = answer.recv().await;
234
235            if answer == Some(0) {
236                cx.update(|cx| {
237                    this.update(cx, |this, cx| match this.submit_feedback(cx) {
238                        // TODO FEEDBACK
239                        Ok(_) => {
240                            // Close file after feedback sent successfully
241                            // workspace
242                            //     .update(cx, |workspace, cx| {
243                            //         Pane::close_active_item(workspace, &Default::default(), cx)
244                            //             .unwrap()
245                            //     })
246                            //     .await
247                            //     .unwrap();
248                        }
249                        Err(error) => {
250                            cx.prompt(PromptLevel::Critical, &error.to_string(), &["OK"]);
251                            // Prompt that something failed (and to check the log for the exact error? and to try again?)
252                        }
253                    })
254                })
255            }
256        })
257        .detach();
258
259        Task::ready(Ok(()))
260    }
261
262    fn submit_feedback(&mut self, cx: &mut ViewContext<'_, Self>) -> anyhow::Result<()> {
263        let feedback_text = self.editor.read(cx).text(cx);
264        let zed_client = cx.global::<Arc<Client>>();
265        let system_specs = SystemSpecs::new(cx);
266        let feedback_endpoint = format!("{}/api/feedback", *ZED_SERVER_URL);
267
268        let metrics_id = zed_client.metrics_id();
269        let http_client = zed_client.http_client();
270
271        // TODO FEEDBACK: how to get error out of the thread
272
273        let this = cx.handle();
274
275        cx.spawn(|_, async_cx| {
276            async move {
277                let request = FeedbackRequestBody {
278                    feedback_text: &feedback_text,
279                    metrics_id,
280                    system_specs,
281                    token: ZED_SECRET_CLIENT_TOKEN,
282                };
283
284                let json_bytes = serde_json::to_vec(&request)?;
285
286                let request = Request::post(feedback_endpoint)
287                    .header("content-type", "application/json")
288                    .body(json_bytes.into())?;
289
290                let mut response = http_client.send(request).await?;
291                let mut body = String::new();
292                response.body_mut().read_to_string(&mut body).await?;
293
294                let response_status = response.status();
295
296                if !response_status.is_success() {
297                    bail!("Feedback API failed with: {}", response_status)
298                }
299
300                this.read_with(&async_cx, |this, cx| -> anyhow::Result<()> {
301                    bail!("Error")
302                })?;
303
304                // TODO FEEDBACK: Use or remove
305                // Will need to handle error cases
306                // async_cx.update(|cx| {
307                //     this.update(cx, |this, cx| {
308                //         this.handle_error(error);
309                //         cx.notify();
310                //         cx.dispatch_action(ShowErrorPopover);
311                //         this.error_text = "Embedding failed"
312                //     })
313                // });
314
315                Ok(())
316            }
317        })
318        .detach();
319
320        Ok(())
321    }
322}
323
324impl FeedbackEditor {
325    pub fn deploy(workspace: &mut Workspace, _: &GiveFeedback, cx: &mut ViewContext<Workspace>) {
326        // if let Some(existing) = workspace.item_of_type::<FeedbackEditor>(cx) {
327        //     workspace.activate_item(&existing, cx);
328        // } else {
329        let workspace_handle = cx.weak_handle();
330        let feedback_editor = cx
331            .add_view(|cx| FeedbackEditor::new(workspace.project().clone(), workspace_handle, cx));
332        workspace.add_item(Box::new(feedback_editor), cx);
333        // }
334    }
335}
336
337impl View for FeedbackEditor {
338    fn ui_name() -> &'static str {
339        "FeedbackEditor"
340    }
341
342    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
343        ChildView::new(&self.editor, cx).boxed()
344    }
345
346    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
347        if cx.is_self_focused() {
348            cx.focus(&self.editor);
349        }
350    }
351}
352
353impl Entity for FeedbackEditor {
354    type Event = ();
355}
356
357impl Item for FeedbackEditor {
358    fn tab_content(
359        &self,
360        _: Option<usize>,
361        style: &theme::Tab,
362        _: &gpui::AppContext,
363    ) -> ElementBox {
364        Flex::row()
365            .with_child(
366                Label::new("Feedback".to_string(), style.label.clone())
367                    .aligned()
368                    .contained()
369                    .boxed(),
370            )
371            .boxed()
372    }
373
374    fn to_item_events(_: &Self::Event) -> Vec<workspace::item::ItemEvent> {
375        Vec::new()
376    }
377
378    fn project_path(&self, _: &gpui::AppContext) -> Option<ProjectPath> {
379        None
380    }
381
382    fn project_entry_ids(&self, _: &gpui::AppContext) -> SmallVec<[ProjectEntryId; 3]> {
383        SmallVec::new()
384    }
385
386    fn is_singleton(&self, _: &gpui::AppContext) -> bool {
387        true
388    }
389
390    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
391
392    fn can_save(&self, _: &gpui::AppContext) -> bool {
393        true
394    }
395
396    fn save(
397        &mut self,
398        project_handle: gpui::ModelHandle<Project>,
399        cx: &mut ViewContext<Self>,
400    ) -> Task<anyhow::Result<()>> {
401        self.handle_save(project_handle, cx)
402    }
403
404    fn save_as(
405        &mut self,
406        project_handle: gpui::ModelHandle<Project>,
407        _: std::path::PathBuf,
408        cx: &mut ViewContext<Self>,
409    ) -> Task<anyhow::Result<()>> {
410        self.handle_save(project_handle, cx)
411    }
412
413    fn reload(
414        &mut self,
415        _: gpui::ModelHandle<Project>,
416        _: &mut ViewContext<Self>,
417    ) -> Task<anyhow::Result<()>> {
418        unreachable!("reload should not have been called")
419    }
420
421    fn clone_on_split(
422        &self,
423        _workspace_id: workspace::WorkspaceId,
424        cx: &mut ViewContext<Self>,
425    ) -> Option<Self>
426    where
427        Self: Sized,
428    {
429        // TODO FEEDBACK: split is busted
430        // Some(self.clone())
431        None
432    }
433
434    fn serialized_item_kind() -> Option<&'static str> {
435        None
436    }
437
438    fn deserialize(
439        _: gpui::ModelHandle<Project>,
440        _: gpui::WeakViewHandle<Workspace>,
441        _: workspace::WorkspaceId,
442        _: workspace::ItemId,
443        _: &mut ViewContext<workspace::Pane>,
444    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
445        unreachable!()
446    }
447}
448
449// TODO FEEDBACK: search buffer?
450// TODO FEEDBACK: warnings