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};
 13use isahc::Request;
 14use language::Buffer;
 15use postage::prelude::Stream;
 16
 17use lazy_static::lazy_static;
 18use project::{Project, ProjectEntryId, ProjectPath};
 19use serde::Serialize;
 20use settings::Settings;
 21use smallvec::SmallVec;
 22use workspace::{
 23    item::{Item, ItemHandle},
 24    StatusItemView, Workspace,
 25};
 26
 27use crate::system_specs::SystemSpecs;
 28
 29lazy_static! {
 30    pub static ref ZED_SERVER_URL: String =
 31        std::env::var("ZED_SERVER_URL").unwrap_or_else(|_| "https://zed.dev".to_string());
 32}
 33
 34// TODO FEEDBACK: In the future, it would be nice to use this is some sort of live-rendering character counter thing
 35// Currently, we are just checking on submit that the the text exceeds the `start` value in this range
 36const FEEDBACK_CHAR_COUNT_RANGE: Range<usize> = Range {
 37    start: 5,
 38    end: 1000,
 39};
 40
 41actions!(feedback, [SubmitFeedback, GiveFeedback, DeployFeedback]);
 42
 43pub fn init(cx: &mut MutableAppContext) {
 44    cx.add_action(FeedbackEditor::deploy);
 45}
 46
 47pub struct FeedbackButton;
 48
 49impl Entity for FeedbackButton {
 50    type Event = ();
 51}
 52
 53impl View for FeedbackButton {
 54    fn ui_name() -> &'static str {
 55        "FeedbackButton"
 56    }
 57
 58    fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
 59        Stack::new()
 60            .with_child(
 61                MouseEventHandler::<Self>::new(0, cx, |state, cx| {
 62                    let theme = &cx.global::<Settings>().theme;
 63                    let theme = &theme.workspace.status_bar.feedback;
 64
 65                    Text::new(
 66                        "Give Feedback".to_string(),
 67                        theme.style_for(state, true).clone(),
 68                    )
 69                    .boxed()
 70                })
 71                .with_cursor_style(CursorStyle::PointingHand)
 72                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(GiveFeedback))
 73                .boxed(),
 74            )
 75            .boxed()
 76    }
 77
 78    fn focus_in(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {}
 79
 80    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {}
 81
 82    fn key_down(&mut self, _: &gpui::KeyDownEvent, _: &mut ViewContext<Self>) -> bool {
 83        false
 84    }
 85
 86    fn key_up(&mut self, _: &gpui::KeyUpEvent, _: &mut ViewContext<Self>) -> bool {
 87        false
 88    }
 89
 90    fn modifiers_changed(
 91        &mut self,
 92        _: &gpui::ModifiersChangedEvent,
 93        _: &mut ViewContext<Self>,
 94    ) -> bool {
 95        false
 96    }
 97
 98    fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap_matcher::KeymapContext {
 99        Self::default_keymap_context()
100    }
101
102    fn default_keymap_context() -> gpui::keymap_matcher::KeymapContext {
103        let mut cx = gpui::keymap_matcher::KeymapContext::default();
104        cx.set.insert(Self::ui_name().into());
105        cx
106    }
107
108    fn debug_json(&self, _: &gpui::AppContext) -> gpui::serde_json::Value {
109        gpui::serde_json::Value::Null
110    }
111
112    fn text_for_range(&self, _: Range<usize>, _: &gpui::AppContext) -> Option<String> {
113        None
114    }
115
116    fn selected_text_range(&self, _: &gpui::AppContext) -> Option<Range<usize>> {
117        None
118    }
119
120    fn marked_text_range(&self, _: &gpui::AppContext) -> Option<Range<usize>> {
121        None
122    }
123
124    fn unmark_text(&mut self, _: &mut ViewContext<Self>) {}
125
126    fn replace_text_in_range(
127        &mut self,
128        _: Option<Range<usize>>,
129        _: &str,
130        _: &mut ViewContext<Self>,
131    ) {
132    }
133
134    fn replace_and_mark_text_in_range(
135        &mut self,
136        _: Option<Range<usize>>,
137        _: &str,
138        _: Option<Range<usize>>,
139        _: &mut ViewContext<Self>,
140    ) {
141    }
142}
143
144impl StatusItemView for FeedbackButton {
145    fn set_active_pane_item(
146        &mut self,
147        _: Option<&dyn ItemHandle>,
148        _: &mut gpui::ViewContext<Self>,
149    ) {
150    }
151}
152
153#[derive(Serialize)]
154struct FeedbackRequestBody<'a> {
155    feedback_text: &'a str,
156    metrics_id: Option<Arc<str>>,
157    system_specs: SystemSpecs,
158    token: &'a str,
159}
160
161#[derive(Clone)]
162struct FeedbackEditor {
163    editor: ViewHandle<Editor>,
164    project: ModelHandle<Project>,
165}
166
167impl FeedbackEditor {
168    fn new_with_buffer(
169        project: ModelHandle<Project>,
170        buffer: ModelHandle<Buffer>,
171        cx: &mut ViewContext<Self>,
172    ) -> Self {
173        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.";
174
175        let editor = cx.add_view(|cx| {
176            let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx);
177            editor.set_vertical_scroll_margin(5, cx);
178            editor.set_placeholder_text(FEDBACK_PLACEHOLDER_TEXT, cx);
179            editor
180        });
181
182        let this = Self { editor, project };
183        this
184    }
185
186    fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
187        // TODO FEEDBACK: This doesn't work like I expected it would
188        // let markdown_language = Arc::new(Language::new(
189        //     LanguageConfig::default(),
190        //     Some(tree_sitter_markdown::language()),
191        // ));
192
193        let markdown_language = project.read(cx).languages().get_language("Markdown");
194
195        let buffer = project
196            .update(cx, |project, cx| {
197                project.create_buffer("", markdown_language, cx)
198            })
199            .expect("creating buffers on a local workspace always succeeds");
200
201        Self::new_with_buffer(project, buffer, cx)
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 feedback_editor =
330            cx.add_view(|cx| FeedbackEditor::new(workspace.project().clone(), cx));
331        workspace.add_item(Box::new(feedback_editor), cx);
332        // }
333    }
334}
335
336impl View for FeedbackEditor {
337    fn ui_name() -> &'static str {
338        "FeedbackEditor"
339    }
340
341    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
342        ChildView::new(&self.editor, cx).boxed()
343    }
344
345    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
346        if cx.is_self_focused() {
347            cx.focus(&self.editor);
348        }
349    }
350}
351
352impl Entity for FeedbackEditor {
353    type Event = ();
354}
355
356impl Item for FeedbackEditor {
357    fn tab_content(
358        &self,
359        _: Option<usize>,
360        style: &theme::Tab,
361        _: &gpui::AppContext,
362    ) -> ElementBox {
363        Flex::row()
364            .with_child(
365                Label::new("Feedback".to_string(), style.label.clone())
366                    .aligned()
367                    .contained()
368                    .boxed(),
369            )
370            .boxed()
371    }
372
373    fn to_item_events(_: &Self::Event) -> Vec<workspace::item::ItemEvent> {
374        Vec::new()
375    }
376
377    fn project_path(&self, _: &gpui::AppContext) -> Option<ProjectPath> {
378        None
379    }
380
381    fn project_entry_ids(&self, _: &gpui::AppContext) -> SmallVec<[ProjectEntryId; 3]> {
382        SmallVec::new()
383    }
384
385    fn is_singleton(&self, _: &gpui::AppContext) -> bool {
386        true
387    }
388
389    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
390
391    fn can_save(&self, _: &gpui::AppContext) -> bool {
392        true
393    }
394
395    fn save(
396        &mut self,
397        project: gpui::ModelHandle<Project>,
398        cx: &mut ViewContext<Self>,
399    ) -> Task<anyhow::Result<()>> {
400        self.handle_save(project, cx)
401    }
402
403    fn save_as(
404        &mut self,
405        project: gpui::ModelHandle<Project>,
406        _: std::path::PathBuf,
407        cx: &mut ViewContext<Self>,
408    ) -> Task<anyhow::Result<()>> {
409        self.handle_save(project, cx)
410    }
411
412    fn reload(
413        &mut self,
414        _: gpui::ModelHandle<Project>,
415        _: &mut ViewContext<Self>,
416    ) -> Task<anyhow::Result<()>> {
417        unreachable!("reload should not have been called")
418    }
419
420    fn clone_on_split(
421        &self,
422        _workspace_id: workspace::WorkspaceId,
423        cx: &mut ViewContext<Self>,
424    ) -> Option<Self>
425    where
426        Self: Sized,
427    {
428        let buffer = self
429            .editor
430            .read(cx)
431            .buffer()
432            .read(cx)
433            .as_singleton()
434            .expect("Feedback buffer is only ever singleton");
435
436        Some(Self::new_with_buffer(
437            self.project.clone(),
438            buffer.clone(),
439            cx,
440        ))
441    }
442
443    fn serialized_item_kind() -> Option<&'static str> {
444        None
445    }
446
447    fn deserialize(
448        _: gpui::ModelHandle<Project>,
449        _: gpui::WeakViewHandle<Workspace>,
450        _: workspace::WorkspaceId,
451        _: workspace::ItemId,
452        _: &mut ViewContext<workspace::Pane>,
453    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
454        unreachable!()
455    }
456}
457
458// impl SearchableItem for FeedbackEditor {
459//     type Match = <Editor as SearchableItem>::Match;
460
461//     fn to_search_event(event: &Self::Event) -> Option<workspace::searchable::SearchEvent> {
462//         Editor::to_search_event(event)
463//     }
464
465//     fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
466//         self.
467//     }
468
469//     fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
470//         todo!()
471//     }
472
473//     fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
474//         todo!()
475//     }
476
477//     fn activate_match(
478//         &mut self,
479//         index: usize,
480//         matches: Vec<Self::Match>,
481//         cx: &mut ViewContext<Self>,
482//     ) {
483//         todo!()
484//     }
485
486//     fn find_matches(
487//         &mut self,
488//         query: project::search::SearchQuery,
489//         cx: &mut ViewContext<Self>,
490//     ) -> Task<Vec<Self::Match>> {
491//         todo!()
492//     }
493
494//     fn active_match_index(
495//         &mut self,
496//         matches: Vec<Self::Match>,
497//         cx: &mut ViewContext<Self>,
498//     ) -> Option<usize> {
499//         todo!()
500//     }
501// }
502
503// TODO FEEDBACK: search buffer?
504// TODO FEEDBACK: warnings