In-app feedback WIP

Joseph Lyons created

Change summary

crates/editor/src/editor.rs        |   9 
crates/theme/src/theme.rs          |  17 +
crates/workspace/src/pane.rs       |   6 
crates/zed/src/feedback.rs         |  50 ----
crates/zed/src/feedback_popover.rs | 326 ++++++++++++++++++++++++++++++++
crates/zed/src/main.rs             |   5 
crates/zed/src/system_specs.rs     |   2 
crates/zed/src/zed.rs              |   4 
8 files changed, 366 insertions(+), 53 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -991,6 +991,15 @@ impl Editor {
         Self::new(EditorMode::SingleLine, buffer, None, field_editor_style, cx)
     }
 
+    pub fn multi_line(
+        field_editor_style: Option<Arc<GetFieldEditorTheme>>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let buffer = cx.add_model(|cx| Buffer::new(0, String::new(), cx));
+        let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+        Self::new(EditorMode::Full, buffer, None, field_editor_style, cx)
+    }
+
     pub fn auto_height(
         max_lines: usize,
         field_editor_style: Option<Arc<GetFieldEditorTheme>>,

crates/theme/src/theme.rs 🔗

@@ -25,6 +25,7 @@ pub struct Theme {
     pub command_palette: CommandPalette,
     pub picker: Picker,
     pub editor: Editor,
+    // pub feedback_box: Editor,
     pub search: Search,
     pub project_diagnostics: ProjectDiagnostics,
     pub breadcrumbs: ContainedText,
@@ -119,6 +120,22 @@ pub struct ContactList {
     pub calling_indicator: ContainedText,
 }
 
+// TODO FEEDBACK: Remove or use this
+// #[derive(Deserialize, Default)]
+// pub struct FeedbackPopover {
+//     #[serde(flatten)]
+//     pub container: ContainerStyle,
+//     pub height: f32,
+//     pub width: f32,
+//     pub invite_row_height: f32,
+//     pub invite_row: Interactive<ContainedLabel>,
+// }
+
+// #[derive(Deserialize, Default)]
+// pub struct FeedbackBox {
+//     pub feedback_editor: FieldEditor,
+// }
+
 #[derive(Deserialize, Default)]
 pub struct ProjectRow {
     #[serde(flatten)]

crates/workspace/src/pane.rs 🔗

@@ -95,6 +95,11 @@ pub struct DeployNewMenu {
     position: Vector2F,
 }
 
+#[derive(Clone, PartialEq)]
+pub struct DeployFeedbackModal {
+    position: Vector2F,
+}
+
 impl_actions!(pane, [GoBack, GoForward, ActivateItem]);
 impl_internal_actions!(
     pane,
@@ -104,6 +109,7 @@ impl_internal_actions!(
         DeployNewMenu,
         DeployDockMenu,
         MoveItem,
+        DeployFeedbackModal
     ]
 );
 

crates/zed/src/feedback.rs 🔗

@@ -1,50 +0,0 @@
-use crate::OpenBrowser;
-use gpui::{
-    elements::{MouseEventHandler, Text},
-    platform::CursorStyle,
-    Element, Entity, MouseButton, RenderContext, View,
-};
-use settings::Settings;
-use workspace::{item::ItemHandle, StatusItemView};
-
-pub const NEW_ISSUE_URL: &str = "https://github.com/zed-industries/feedback/issues/new/choose";
-
-pub struct FeedbackLink;
-
-impl Entity for FeedbackLink {
-    type Event = ();
-}
-
-impl View for FeedbackLink {
-    fn ui_name() -> &'static str {
-        "FeedbackLink"
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> gpui::ElementBox {
-        MouseEventHandler::<Self>::new(0, cx, |state, cx| {
-            let theme = &cx.global::<Settings>().theme;
-            let theme = &theme.workspace.status_bar.feedback;
-            Text::new(
-                "Give Feedback".to_string(),
-                theme.style_for(state, false).clone(),
-            )
-            .boxed()
-        })
-        .with_cursor_style(CursorStyle::PointingHand)
-        .on_click(MouseButton::Left, |_, cx| {
-            cx.dispatch_action(OpenBrowser {
-                url: NEW_ISSUE_URL.into(),
-            })
-        })
-        .boxed()
-    }
-}
-
-impl StatusItemView for FeedbackLink {
-    fn set_active_pane_item(
-        &mut self,
-        _: Option<&dyn ItemHandle>,
-        _: &mut gpui::ViewContext<Self>,
-    ) {
-    }
-}

crates/zed/src/feedback_popover.rs 🔗

@@ -0,0 +1,326 @@
+use std::{ops::Range, sync::Arc};
+
+use anyhow::bail;
+use client::{http::HttpClient, ZED_SECRET_CLIENT_TOKEN};
+use editor::Editor;
+use futures::AsyncReadExt;
+use gpui::{
+    actions,
+    elements::{
+        AnchorCorner, ChildView, Flex, MouseEventHandler, Overlay, OverlayFitMode, ParentElement,
+        Stack, Text,
+    },
+    serde_json, CursorStyle, Element, ElementBox, Entity, MouseButton, MutableAppContext,
+    RenderContext, View, ViewContext, ViewHandle,
+};
+use isahc::Request;
+use lazy_static::lazy_static;
+use serde::Serialize;
+use settings::Settings;
+use workspace::{item::ItemHandle, StatusItemView};
+
+use crate::{feedback_popover, system_specs::SystemSpecs};
+
+/*
+    TODO FEEDBACK
+
+    Next steps from Mikayla:
+    1: Find the bottom bar height and maybe guess some feedback button widths?
+       Basically, just use to position the modal
+    2: Look at ContactList::render() and ContactPopover::render() for clues on how
+       to make the modal look nice. Copy the theme values from the contact list styles
+
+    Now
+        Fix all layout issues, theming, buttons, etc
+        Make multi-line editor without line numbers
+        Some sort of feedback when something fails or succeeds
+        Naming of all UI stuff and separation out into files (follow a convention already in place)
+        Disable submit button when text length is 0
+        Should we store staff boolean?
+        Put behind experiments flag
+        Move to separate crate
+        Render a character counter
+        All warnings
+        Remove all comments
+    Later
+        If a character limit is imposed, switch submit button over to a "GitHub Issue" button
+        Should editor by treated as a markdown file
+        Limit characters?
+        Disable submit button when text length is GTE to character limit
+
+        Pay for AirTable
+        Add AirTable to system architecture diagram in Figma
+*/
+
+lazy_static! {
+    pub static ref ZED_SERVER_URL: String =
+        std::env::var("ZED_SERVER_URL").unwrap_or_else(|_| "https://zed.dev".to_string());
+}
+
+const FEEDBACK_CHAR_COUNT_RANGE: Range<usize> = Range {
+    start: 5,
+    end: 1000,
+};
+
+actions!(feedback, [ToggleFeedbackPopover, SubmitFeedback]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(FeedbackButton::toggle_feedback);
+    cx.add_action(FeedbackPopover::submit_feedback);
+}
+
+pub struct FeedbackButton {
+    feedback_popover: Option<ViewHandle<FeedbackPopover>>,
+}
+
+impl FeedbackButton {
+    pub fn new() -> Self {
+        Self {
+            feedback_popover: None,
+        }
+    }
+
+    pub fn toggle_feedback(&mut self, _: &ToggleFeedbackPopover, cx: &mut ViewContext<Self>) {
+        match self.feedback_popover.take() {
+            Some(_) => {}
+            None => {
+                let popover_view = cx.add_view(|_cx| FeedbackPopover::new(_cx));
+                self.feedback_popover = Some(popover_view.clone());
+            }
+        }
+
+        cx.notify();
+    }
+}
+
+impl Entity for FeedbackButton {
+    type Event = ();
+}
+
+impl View for FeedbackButton {
+    fn ui_name() -> &'static str {
+        "FeedbackButton"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
+        Stack::new()
+            .with_child(
+                MouseEventHandler::<Self>::new(0, cx, |state, cx| {
+                    let theme = &cx.global::<Settings>().theme;
+                    let theme = &theme.workspace.status_bar.feedback;
+
+                    Text::new(
+                        "Give Feedback".to_string(),
+                        theme
+                            .style_for(state, self.feedback_popover.is_some())
+                            .clone(),
+                    )
+                    .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, cx| {
+                    cx.dispatch_action(ToggleFeedbackPopover)
+                })
+                .boxed(),
+            )
+            .with_children(self.feedback_popover.as_ref().map(|popover| {
+                Overlay::new(
+                    ChildView::new(popover, cx)
+                        .contained()
+                        // .with_height(theme.contact_list.user_query_editor_height)
+                        // .with_margin_top(-50.0)
+                        // .with_margin_left(titlebar.toggle_contacts_button.default.button_width)
+                        // .with_margin_right(-titlebar.toggle_contacts_button.default.button_width)
+                        .boxed(),
+                )
+                .with_fit_mode(OverlayFitMode::SwitchAnchor)
+                .with_anchor_corner(AnchorCorner::TopLeft)
+                .with_z_index(999)
+                .boxed()
+            }))
+            .boxed()
+    }
+}
+
+impl StatusItemView for FeedbackButton {
+    fn set_active_pane_item(
+        &mut self,
+        _: Option<&dyn ItemHandle>,
+        _: &mut gpui::ViewContext<Self>,
+    ) {
+        // N/A
+    }
+}
+
+pub struct FeedbackPopover {
+    feedback_editor: ViewHandle<Editor>,
+    // _subscriptions: Vec<Subscription>,
+}
+
+impl Entity for FeedbackPopover {
+    type Event = ();
+}
+
+#[derive(Serialize)]
+struct FeedbackRequestBody<'a> {
+    feedback_text: &'a str,
+    metrics_id: Option<Arc<str>>,
+    system_specs: SystemSpecs,
+    token: &'a str,
+}
+
+impl FeedbackPopover {
+    pub fn new(cx: &mut ViewContext<Self>) -> Self {
+        let feedback_editor = cx.add_view(|cx| {
+            let editor = Editor::multi_line(
+                Some(Arc::new(|theme| {
+                    theme.contact_list.user_query_editor.clone()
+                })),
+                cx,
+            );
+            editor
+        });
+
+        cx.focus(&feedback_editor);
+
+        cx.subscribe(&feedback_editor, |this, _, event, cx| {
+            if let editor::Event::BufferEdited = event {
+                let buffer_len = this.feedback_editor.read(cx).buffer().read(cx).len(cx);
+                let feedback_chars_remaining = FEEDBACK_CHAR_COUNT_RANGE.end - buffer_len;
+                dbg!(feedback_chars_remaining);
+            }
+        })
+        .detach();
+
+        // let active_call = ActiveCall::global(cx);
+        // let mut subscriptions = Vec::new();
+        // subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
+        // subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
+        let this = Self {
+            feedback_editor, // _subscriptions: subscriptions,
+        };
+        // this.update_entries(cx);
+        this
+    }
+
+    fn submit_feedback(&mut self, _: &SubmitFeedback, cx: &mut ViewContext<'_, Self>) {
+        let feedback_text = self.feedback_editor.read(cx).text(cx);
+        let http_client = cx.global::<Arc<dyn HttpClient>>().clone();
+        let system_specs = SystemSpecs::new(cx);
+        let feedback_endpoint = format!("{}/api/feedback", *ZED_SERVER_URL);
+
+        cx.spawn(|this, async_cx| {
+            async move {
+                // TODO FEEDBACK: Use or remove
+                // this.read_with(&async_cx, |this, cx| {
+                //     // Now we have a &self and a &AppContext
+                // });
+
+                let metrics_id = None;
+
+                let request = FeedbackRequestBody {
+                    feedback_text: &feedback_text,
+                    metrics_id,
+                    system_specs,
+                    token: ZED_SECRET_CLIENT_TOKEN,
+                };
+
+                let json_bytes = serde_json::to_vec(&request)?;
+
+                let request = Request::post(feedback_endpoint)
+                    .header("content-type", "application/json")
+                    .body(json_bytes.into())?;
+
+                let mut response = http_client.send(request).await?;
+                let mut body = String::new();
+                response.body_mut().read_to_string(&mut body).await?;
+
+                let response_status = response.status();
+
+                dbg!(response_status);
+
+                if !response_status.is_success() {
+                    // TODO FEEDBACK: Do some sort of error reporting here for if store fails
+                    bail!("Error")
+                }
+
+                // TODO FEEDBACK: Use or remove
+                // Will need to handle error cases
+                // async_cx.update(|cx| {
+                //     this.update(cx, |this, cx| {
+                //         this.handle_error(error);
+                //         cx.notify();
+                //         cx.dispatch_action(ShowErrorPopover);
+                //         this.error_text = "Embedding failed"
+                //     })
+                // });
+
+                Ok(())
+            }
+        })
+        .detach();
+    }
+}
+
+impl View for FeedbackPopover {
+    fn ui_name() -> &'static str {
+        "FeedbackPopover"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        enum SubmitFeedback {}
+
+        let theme = cx.global::<Settings>().theme.clone();
+        let status_bar_height = theme.workspace.status_bar.height;
+        let submit_feedback_text_button_height = 20.0;
+
+        // I'd like to just define:
+
+        // 1. Overall popover width x height dimensions
+        // 2. Submit Feedback button height dimensions
+        // 3. Allow editor to dynamically fill in the remaining space
+
+        Flex::column()
+            .with_child(
+                Flex::row()
+                    .with_child(
+                        ChildView::new(self.feedback_editor.clone(), cx)
+                            .contained()
+                            .with_style(theme.contact_list.user_query_editor.container)
+                            .flex(1., true)
+                            .boxed(),
+                    )
+                    .constrained()
+                    .with_width(theme.contacts_popover.width)
+                    .with_height(theme.contacts_popover.height - submit_feedback_text_button_height)
+                    .boxed(),
+            )
+            .with_child(
+                MouseEventHandler::<SubmitFeedback>::new(0, cx, |state, _| {
+                    let theme = &theme.workspace.status_bar.feedback;
+
+                    Text::new(
+                        "Submit Feedback".to_string(),
+                        theme.style_for(state, true).clone(),
+                    )
+                    .constrained()
+                    .with_height(submit_feedback_text_button_height)
+                    .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, cx| {
+                    cx.dispatch_action(feedback_popover::SubmitFeedback)
+                })
+                .on_click(MouseButton::Left, |_, cx| {
+                    cx.dispatch_action(feedback_popover::ToggleFeedbackPopover)
+                })
+                .boxed(),
+            )
+            .contained()
+            .with_style(theme.contacts_popover.container)
+            .constrained()
+            .with_width(theme.contacts_popover.width + 200.0)
+            .with_height(theme.contacts_popover.height)
+            .boxed()
+    }
+}

crates/zed/src/main.rs 🔗

@@ -41,7 +41,7 @@ use util::{channel::RELEASE_CHANNEL, paths, ResultExt, TryFutureExt};
 use workspace::{
     self, item::ItemHandle, notifications::NotifyResultExt, AppState, NewFile, OpenPaths, Workspace,
 };
-use zed::{self, build_window_options, initialize_workspace, languages, menus};
+use zed::{self, build_window_options, feedback_popover, initialize_workspace, languages, menus};
 
 fn main() {
     let http = http::client();
@@ -108,6 +108,9 @@ fn main() {
         watch_settings_file(default_settings, settings_file_content, themes.clone(), cx);
         watch_keymap_file(keymap_file, cx);
 
+        cx.set_global(http.clone());
+
+        feedback_popover::init(cx);
         context_menu::init(cx);
         project::Project::init(&client);
         client::init(client.clone(), cx);

crates/zed/src/system_specs.rs 🔗

@@ -2,9 +2,11 @@ use std::{env, fmt::Display};
 
 use gpui::AppContext;
 use human_bytes::human_bytes;
+use serde::Serialize;
 use sysinfo::{System, SystemExt};
 use util::channel::ReleaseChannel;
 
+#[derive(Debug, Serialize)]
 pub struct SystemSpecs {
     app_version: &'static str,
     release_channel: &'static str,

crates/zed/src/zed.rs 🔗

@@ -1,4 +1,4 @@
-mod feedback;
+pub mod feedback_popover;
 pub mod languages;
 pub mod menus;
 pub mod system_specs;
@@ -369,7 +369,7 @@ pub fn initialize_workspace(
     let activity_indicator =
         activity_indicator::ActivityIndicator::new(workspace, app_state.languages.clone(), cx);
     let cursor_position = cx.add_view(|_| editor::items::CursorPosition::new());
-    let feedback_link = cx.add_view(|_| feedback::FeedbackLink);
+    let feedback_link = cx.add_view(|_| feedback_popover::FeedbackButton::new());
     workspace.status_bar().update(cx, |status_bar, cx| {
         status_bar.add_left_item(diagnostic_summary, cx);
         status_bar.add_left_item(activity_indicator, cx);