Feedback 2 (#3527)

Joseph T. Lyons created

Ports feedback crate over to zed2. Introduces modal feedback. Feedback
submission works, but there are some TODOs in the code for things that
need to be done (needs a UI pass, dismissing the modal in certain cases,
etc), but I might merge this to reduce chances of conflicts (aleady had
to deal with a few).

<img width="1378" alt="SCR-20231206-udgp"
src="https://github.com/zed-industries/zed/assets/19867440/99f9e843-ac9c-4df1-b600-2522863e6459">

Release Notes:

- N/A

Change summary

Cargo.lock                                     |  34 +
crates/editor2/src/editor.rs                   |  24 
crates/feedback2/Cargo.toml                    |  44 ++
crates/feedback2/src/deploy_feedback_button.rs |  50 ++
crates/feedback2/src/feedback2.rs              |  58 +++
crates/feedback2/src/feedback_modal.rs         | 382 ++++++++++++++++++++
crates/feedback2/src/system_specs.rs           |  68 +++
crates/gpui2/src/element.rs                    |  18 
crates/gpui2/src/platform.rs                   |   2 
crates/ui2/src/components/keybinding.rs        |   9 
crates/workspace2/src/workspace2.rs            |   1 
crates/zed2/Cargo.toml                         |   2 
crates/zed2/src/main.rs                        |   2 
crates/zed2/src/zed2.rs                        |  32 +
14 files changed, 709 insertions(+), 17 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3223,6 +3223,39 @@ dependencies = [
  "workspace",
 ]
 
+[[package]]
+name = "feedback2"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "client2",
+ "db2",
+ "editor2",
+ "futures 0.3.28",
+ "gpui2",
+ "human_bytes",
+ "isahc",
+ "language2",
+ "lazy_static",
+ "log",
+ "menu2",
+ "postage",
+ "project2",
+ "regex",
+ "search2",
+ "serde",
+ "serde_derive",
+ "settings2",
+ "smallvec",
+ "sysinfo",
+ "theme2",
+ "tree-sitter-markdown",
+ "ui2",
+ "urlencoding",
+ "util",
+ "workspace2",
+]
+
 [[package]]
 name = "file-per-thread-logger"
 version = "0.1.6"
@@ -11943,6 +11976,7 @@ dependencies = [
  "editor2",
  "env_logger 0.9.3",
  "feature_flags2",
+ "feedback2",
  "file_finder2",
  "fs2",
  "fsevent",

crates/editor2/src/editor.rs 🔗

@@ -1717,6 +1717,11 @@ impl Editor {
         let focus_handle = cx.focus_handle();
         cx.on_focus(&focus_handle, Self::handle_focus).detach();
         cx.on_blur(&focus_handle, Self::handle_blur).detach();
+        cx.on_release(|this, cx| {
+            //todo!()
+            //cx.emit_global(EditorReleased(self.handle.clone()));
+        })
+        .detach();
 
         let mut this = Self {
             handle: cx.view().downgrade(),
@@ -8191,6 +8196,17 @@ impl Editor {
         self.buffer.read(cx).read(cx).text()
     }
 
+    pub fn text_option(&self, cx: &AppContext) -> Option<String> {
+        let text = self.buffer.read(cx).read(cx).text();
+        let text = text.trim();
+
+        if text.is_empty() {
+            return None;
+        }
+
+        Some(text.to_string())
+    }
+
     pub fn set_text(&mut self, text: impl Into<Arc<str>>, cx: &mut ViewContext<Self>) {
         self.transact(cx, |this, cx| {
             this.buffer
@@ -9252,14 +9268,6 @@ pub struct EditorFocused(pub View<Editor>);
 pub struct EditorBlurred(pub View<Editor>);
 pub struct EditorReleased(pub WeakView<Editor>);
 
-// impl Entity for Editor {
-//     type Event = Event;
-
-//     fn release(&mut self, cx: &mut AppContext) {
-//         cx.emit_global(EditorReleased(self.handle.clone()));
-//     }
-// }
-//
 impl EventEmitter<EditorEvent> for Editor {}
 
 impl FocusableView for Editor {

crates/feedback2/Cargo.toml 🔗

@@ -0,0 +1,44 @@
+[package]
+name = "feedback2"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+path = "src/feedback2.rs"
+
+[features]
+test-support = []
+
+[dependencies]
+client = { package = "client2", path = "../client2" }
+db = { package = "db2", path = "../db2" }
+editor = { package = "editor2", path = "../editor2" }
+gpui = { package = "gpui2", path = "../gpui2" }
+language = { package = "language2", path = "../language2" }
+menu = { package = "menu2", path = "../menu2" }
+project = { package = "project2", path = "../project2" }
+regex.workspace = true
+search = { package = "search2", path = "../search2" }
+settings = { package = "settings2", path = "../settings2" }
+theme = { package = "theme2", path = "../theme2" }
+ui = { package = "ui2", path = "../ui2" }
+util = { path = "../util" }
+workspace = { package = "workspace2", path = "../workspace2"}
+
+log.workspace = true
+futures.workspace = true
+anyhow.workspace = true
+smallvec.workspace = true
+human_bytes = "0.4.1"
+isahc.workspace = true
+lazy_static.workspace = true
+postage.workspace = true
+serde.workspace = true
+serde_derive.workspace = true
+sysinfo.workspace = true
+tree-sitter-markdown = { git = "https://github.com/MDeiml/tree-sitter-markdown", rev = "330ecab87a3e3a7211ac69bbadc19eabecdb1cca" }
+urlencoding = "2.1.2"
+
+[dev-dependencies]
+editor = { package = "editor2", path = "../editor2", features = ["test-support"] }

crates/feedback2/src/deploy_feedback_button.rs 🔗

@@ -0,0 +1,50 @@
+use gpui::{AnyElement, Render, ViewContext, WeakView};
+use ui::{prelude::*, ButtonCommon, Icon, IconButton, Tooltip};
+use workspace::{item::ItemHandle, StatusItemView, Workspace};
+
+use crate::{feedback_modal::FeedbackModal, GiveFeedback};
+
+pub struct DeployFeedbackButton {
+    workspace: WeakView<Workspace>,
+}
+
+impl DeployFeedbackButton {
+    pub fn new(workspace: &Workspace) -> Self {
+        DeployFeedbackButton {
+            workspace: workspace.weak_handle(),
+        }
+    }
+}
+
+impl Render for DeployFeedbackButton {
+    type Element = AnyElement;
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
+        let is_open = self
+            .workspace
+            .upgrade()
+            .and_then(|workspace| {
+                workspace.update(cx, |workspace, cx| {
+                    workspace.active_modal::<FeedbackModal>(cx)
+                })
+            })
+            .is_some();
+        IconButton::new("give-feedback", Icon::Envelope)
+            .style(ui::ButtonStyle::Subtle)
+            .selected(is_open)
+            .tooltip(|cx| Tooltip::text("Give Feedback", cx))
+            .on_click(|_, cx| {
+                cx.dispatch_action(Box::new(GiveFeedback));
+            })
+            .into_any_element()
+    }
+}
+
+impl StatusItemView for DeployFeedbackButton {
+    fn set_active_pane_item(
+        &mut self,
+        _item: Option<&dyn ItemHandle>,
+        _cx: &mut ViewContext<Self>,
+    ) {
+    }
+}

crates/feedback2/src/feedback2.rs 🔗

@@ -0,0 +1,58 @@
+use gpui::{actions, AppContext, ClipboardItem, PromptLevel};
+use system_specs::SystemSpecs;
+use workspace::Workspace;
+
+pub mod deploy_feedback_button;
+pub mod feedback_modal;
+
+actions!(GiveFeedback, SubmitFeedback);
+
+mod system_specs;
+
+actions!(
+    CopySystemSpecsIntoClipboard,
+    FileBugReport,
+    RequestFeature,
+    OpenZedCommunityRepo
+);
+
+pub fn init(cx: &mut AppContext) {
+    // TODO: a way to combine these two into one?
+    cx.observe_new_views(feedback_modal::FeedbackModal::register)
+        .detach();
+
+    cx.observe_new_views(|workspace: &mut Workspace, _| {
+        workspace
+            .register_action(|_, _: &CopySystemSpecsIntoClipboard, cx| {
+                    let specs = SystemSpecs::new(&cx).to_string();
+
+                    let prompt = cx.prompt(
+                        PromptLevel::Info,
+                        &format!("Copied into clipboard:\n\n{specs}"),
+                        &["OK"],
+                    );
+                    cx.spawn(|_, _cx| async move {
+                        prompt.await.ok();
+                    })
+                    .detach();
+                    let item = ClipboardItem::new(specs.clone());
+                    cx.write_to_clipboard(item);
+                })
+            .register_action(|_, _: &RequestFeature, cx| {
+                let url = "https://github.com/zed-industries/community/issues/new?assignees=&labels=enhancement%2Ctriage&template=0_feature_request.yml";
+                cx.open_url(url);
+            })
+            .register_action(move |_, _: &FileBugReport, cx| {
+                let url = format!(
+                    "https://github.com/zed-industries/community/issues/new?assignees=&labels=defect%2Ctriage&template=2_bug_report.yml&environment={}",
+                    urlencoding::encode(&SystemSpecs::new(&cx).to_string())
+                );
+                cx.open_url(&url);
+            })
+            .register_action(move |_, _: &OpenZedCommunityRepo, cx| {
+                let url = "https://github.com/zed-industries/community";
+                cx.open_url(&url);
+        });
+    })
+    .detach();
+}

crates/feedback2/src/feedback_modal.rs 🔗

@@ -0,0 +1,382 @@
+use std::{ops::RangeInclusive, sync::Arc};
+
+use anyhow::bail;
+use client::{Client, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL};
+use db::kvp::KEY_VALUE_STORE;
+use editor::{Editor, EditorEvent};
+use futures::AsyncReadExt;
+use gpui::{
+    div, red, rems, serde_json, AppContext, DismissEvent, Div, EventEmitter, FocusHandle,
+    FocusableView, Model, PromptLevel, Render, Task, View, ViewContext,
+};
+use isahc::Request;
+use language::Buffer;
+use project::Project;
+use regex::Regex;
+use serde_derive::Serialize;
+use ui::{prelude::*, Button, ButtonStyle, Label, Tooltip};
+use util::ResultExt;
+use workspace::Workspace;
+
+use crate::{system_specs::SystemSpecs, GiveFeedback, OpenZedCommunityRepo};
+
+const DATABASE_KEY_NAME: &str = "email_address";
+const EMAIL_REGEX: &str = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b";
+const FEEDBACK_CHAR_LIMIT: RangeInclusive<usize> = 10..=5000;
+const FEEDBACK_SUBMISSION_ERROR_TEXT: &str =
+    "Feedback failed to submit, see error log for details.";
+
+#[derive(Serialize)]
+struct FeedbackRequestBody<'a> {
+    feedback_text: &'a str,
+    email: Option<String>,
+    metrics_id: Option<Arc<str>>,
+    installation_id: Option<Arc<str>>,
+    system_specs: SystemSpecs,
+    is_staff: bool,
+    token: &'a str,
+}
+
+pub struct FeedbackModal {
+    system_specs: SystemSpecs,
+    feedback_editor: View<Editor>,
+    email_address_editor: View<Editor>,
+    character_count: usize,
+    pending_submission: bool,
+}
+
+impl FocusableView for FeedbackModal {
+    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
+        self.feedback_editor.focus_handle(cx)
+    }
+}
+impl EventEmitter<DismissEvent> for FeedbackModal {}
+
+impl FeedbackModal {
+    pub fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
+        let _handle = cx.view().downgrade();
+        workspace.register_action(move |workspace, _: &GiveFeedback, cx| {
+            let markdown = workspace
+                .app_state()
+                .languages
+                .language_for_name("Markdown");
+
+            let project = workspace.project().clone();
+
+            cx.spawn(|workspace, mut cx| async move {
+                let markdown = markdown.await.log_err();
+                let buffer = project
+                    .update(&mut cx, |project, cx| {
+                        project.create_buffer("", markdown, cx)
+                    })?
+                    .expect("creating buffers on a local workspace always succeeds");
+
+                workspace.update(&mut cx, |workspace, cx| {
+                    let system_specs = SystemSpecs::new(cx);
+
+                    workspace.toggle_modal(cx, move |cx| {
+                        FeedbackModal::new(system_specs, project, buffer, cx)
+                    });
+                })?;
+
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+        });
+    }
+
+    pub fn new(
+        system_specs: SystemSpecs,
+        project: Model<Project>,
+        buffer: Model<Buffer>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let email_address_editor = cx.build_view(|cx| {
+            let mut editor = Editor::single_line(cx);
+            editor.set_placeholder_text("Email address (optional)", cx);
+
+            if let Ok(Some(email_address)) = KEY_VALUE_STORE.read_kvp(DATABASE_KEY_NAME) {
+                editor.set_text(email_address, cx)
+            }
+
+            editor
+        });
+
+        let feedback_editor = cx.build_view(|cx| {
+            let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx);
+            editor.set_vertical_scroll_margin(5, cx);
+            editor
+        });
+
+        cx.subscribe(
+            &feedback_editor,
+            |this, editor, event: &EditorEvent, cx| match event {
+                EditorEvent::Edited => {
+                    this.character_count = editor
+                        .read(cx)
+                        .buffer()
+                        .read(cx)
+                        .as_singleton()
+                        .expect("Feedback editor is never a multi-buffer")
+                        .read(cx)
+                        .len();
+                    cx.notify();
+                }
+                _ => {}
+            },
+        )
+        .detach();
+
+        Self {
+            system_specs: system_specs.clone(),
+            feedback_editor,
+            email_address_editor,
+            pending_submission: false,
+            character_count: 0,
+        }
+    }
+
+    pub fn submit(&mut self, cx: &mut ViewContext<Self>) -> Task<anyhow::Result<()>> {
+        let feedback_text = self.feedback_editor.read(cx).text(cx).trim().to_string();
+        let email = self.email_address_editor.read(cx).text_option(cx);
+
+        let answer = cx.prompt(
+            PromptLevel::Info,
+            "Ready to submit your feedback?",
+            &["Yes, Submit!", "No"],
+        );
+        let client = cx.global::<Arc<Client>>().clone();
+        let specs = self.system_specs.clone();
+        cx.spawn(|this, mut cx| async move {
+            let answer = answer.await.ok();
+            if answer == Some(0) {
+                match email.clone() {
+                    Some(email) => {
+                        let _ = KEY_VALUE_STORE
+                            .write_kvp(DATABASE_KEY_NAME.to_string(), email)
+                            .await;
+                    }
+                    None => {
+                        let _ = KEY_VALUE_STORE
+                            .delete_kvp(DATABASE_KEY_NAME.to_string())
+                            .await;
+                    }
+                };
+
+                this.update(&mut cx, |feedback_editor, cx| {
+                    feedback_editor.set_pending_submission(true, cx);
+                })
+                .log_err();
+
+                if let Err(error) =
+                    FeedbackModal::submit_feedback(&feedback_text, email, client, specs).await
+                {
+                    log::error!("{}", error);
+                    this.update(&mut cx, |feedback_editor, cx| {
+                        let prompt = cx.prompt(
+                            PromptLevel::Critical,
+                            FEEDBACK_SUBMISSION_ERROR_TEXT,
+                            &["OK"],
+                        );
+                        cx.spawn(|_, _cx| async move {
+                            prompt.await.ok();
+                        })
+                        .detach();
+                        feedback_editor.set_pending_submission(false, cx);
+                    })
+                    .log_err();
+                }
+            }
+        })
+        .detach();
+        Task::ready(Ok(()))
+    }
+
+    fn set_pending_submission(&mut self, pending_submission: bool, cx: &mut ViewContext<Self>) {
+        self.pending_submission = pending_submission;
+        cx.notify();
+    }
+
+    async fn submit_feedback(
+        feedback_text: &str,
+        email: Option<String>,
+        zed_client: Arc<Client>,
+        system_specs: SystemSpecs,
+    ) -> anyhow::Result<()> {
+        let feedback_endpoint = format!("{}/api/feedback", *ZED_SERVER_URL);
+        let telemetry = zed_client.telemetry();
+        let metrics_id = telemetry.metrics_id();
+        let installation_id = telemetry.installation_id();
+        let is_staff = telemetry.is_staff();
+        let http_client = zed_client.http_client();
+        let request = FeedbackRequestBody {
+            feedback_text: &feedback_text,
+            email,
+            metrics_id,
+            installation_id,
+            system_specs,
+            is_staff: is_staff.unwrap_or(false),
+            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();
+        if !response_status.is_success() {
+            bail!("Feedback API failed with error: {}", response_status)
+        }
+        Ok(())
+    }
+
+    // TODO: Escape button calls dismiss
+    // TODO: Should do same as hitting cancel / clicking outside of modal
+    //     Close immediately if no text in field
+    //     Ask to close if text in the field
+    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
+        cx.emit(DismissEvent);
+    }
+}
+
+impl Render for FeedbackModal {
+    type Element = Div;
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
+        let valid_email_address = match self.email_address_editor.read(cx).text_option(cx) {
+            Some(email_address) => Regex::new(EMAIL_REGEX).unwrap().is_match(&email_address),
+            None => true,
+        };
+
+        let valid_character_count = FEEDBACK_CHAR_LIMIT.contains(&self.character_count);
+        let characters_remaining =
+            if valid_character_count || self.character_count > *FEEDBACK_CHAR_LIMIT.end() {
+                *FEEDBACK_CHAR_LIMIT.end() as i32 - self.character_count as i32
+            } else {
+                self.character_count as i32 - *FEEDBACK_CHAR_LIMIT.start() as i32
+            };
+
+        let allow_submission =
+            valid_character_count && valid_email_address && !self.pending_submission;
+
+        let has_feedback = self.feedback_editor.read(cx).text_option(cx).is_some();
+
+        let submit_button_text = if self.pending_submission {
+            "Sending..."
+        } else {
+            "Send Feedback"
+        };
+        let dismiss = cx.listener(|_, _, cx| {
+            cx.emit(DismissEvent);
+        });
+        // TODO: get the "are you sure you want to dismiss?" prompt here working
+        let dismiss_prompt = cx.listener(|_, _, _| {
+            // let answer = cx.prompt(PromptLevel::Info, "Exit feedback?", &["Yes", "No"]);
+            // cx.spawn(|_, _| async move {
+            //     let answer = answer.await.ok();
+            //     if answer == Some(0) {
+            //         cx.emit(DismissEvent);
+            //     }
+            // })
+            // .detach();
+        });
+        let open_community_repo =
+            cx.listener(|_, _, cx| cx.dispatch_action(Box::new(OpenZedCommunityRepo)));
+
+        // TODO: Nate UI pass
+        v_stack()
+            .elevation_3(cx)
+            .key_context("GiveFeedback")
+            .on_action(cx.listener(Self::cancel))
+            .min_w(rems(40.))
+            .max_w(rems(96.))
+            .border()
+            .border_color(red())
+            .h(rems(40.))
+            .p_2()
+            .gap_2()
+            .child(
+                v_stack().child(
+                    div()
+                        .size_full()
+                        .child(Label::new("Give Feedback").color(Color::Default))
+                        .child(Label::new("This editor supports markdown").color(Color::Muted)),
+                ),
+            )
+            .child(
+                div()
+                    .flex_1()
+                    .bg(cx.theme().colors().editor_background)
+                    .border()
+                    .border_color(cx.theme().colors().border)
+                    .child(self.feedback_editor.clone()),
+            )
+            .child(
+                div().child(
+                    Label::new(format!(
+                        "Characters: {}",
+                        characters_remaining
+                    ))
+                    .when_else(
+                        valid_character_count,
+                        |this| this.color(Color::Success),
+                        |this| this.color(Color::Error)
+                    )
+                ),
+            )
+            .child(
+                div()
+                .bg(cx.theme().colors().editor_background)
+                .border()
+                .border_color(cx.theme().colors().border)
+                .child(self.email_address_editor.clone())
+            )
+            .child(
+                h_stack()
+                    .justify_between()
+                    .gap_1()
+                    .child(Button::new("community_repo", "Community Repo")
+                        .style(ButtonStyle::Filled)
+                        .color(Color::Muted)
+                        .on_click(open_community_repo)
+                    )
+                    .child(h_stack().justify_between().gap_1()
+                        .child(
+                            Button::new("cancel_feedback", "Cancel")
+                                .style(ButtonStyle::Subtle)
+                                .color(Color::Muted)
+                                // TODO: replicate this logic when clicking outside the modal
+                                // TODO: Will require somehow overriding the modal dismal default behavior
+                                .when_else(
+                                    has_feedback,
+                                    |this| this.on_click(dismiss_prompt),
+                                    |this| this.on_click(dismiss)
+                                )
+                        )
+                        .child(
+                            Button::new("send_feedback", submit_button_text)
+                                .color(Color::Accent)
+                                .style(ButtonStyle::Filled)
+                                // TODO: Ensure that while submitting, "Sending..." is shown and disable the button
+                                // TODO: If submit errors: show popup with error, don't close modal, set text back to "Send Feedback", and re-enable button
+                                // TODO: If submit is successful, close the modal
+                                .on_click(cx.listener(|this, _, cx| {
+                                    let _ = this.submit(cx);
+                                }))
+                                .tooltip(|cx| {
+                                    Tooltip::with_meta(
+                                        "Submit feedback to the Zed team.",
+                                        None,
+                                        "Provide an email address if you want us to be able to reply.",
+                                        cx,
+                                    )
+                                })
+                                .when(!allow_submission, |this| this.disabled(true))
+                        ),
+                    )
+
+            )
+    }
+}

crates/feedback2/src/system_specs.rs 🔗

@@ -0,0 +1,68 @@
+use client::ZED_APP_VERSION;
+use gpui::AppContext;
+use human_bytes::human_bytes;
+use serde::Serialize;
+use std::{env, fmt::Display};
+use sysinfo::{System, SystemExt};
+use util::channel::ReleaseChannel;
+
+#[derive(Clone, Debug, Serialize)]
+pub struct SystemSpecs {
+    app_version: Option<String>,
+    release_channel: &'static str,
+    os_name: &'static str,
+    os_version: Option<String>,
+    memory: u64,
+    architecture: &'static str,
+}
+
+impl SystemSpecs {
+    pub fn new(cx: &AppContext) -> Self {
+        let app_version = ZED_APP_VERSION
+            .or_else(|| cx.app_metadata().app_version)
+            .map(|v| v.to_string());
+        let release_channel = cx.global::<ReleaseChannel>().dev_name();
+        let os_name = cx.app_metadata().os_name;
+        let system = System::new_all();
+        let memory = system.total_memory();
+        let architecture = env::consts::ARCH;
+        let os_version = cx
+            .app_metadata()
+            .os_version
+            .map(|os_version| os_version.to_string());
+
+        SystemSpecs {
+            app_version,
+            release_channel,
+            os_name,
+            os_version,
+            memory,
+            architecture,
+        }
+    }
+}
+
+impl Display for SystemSpecs {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let os_information = match &self.os_version {
+            Some(os_version) => format!("OS: {} {}", self.os_name, os_version),
+            None => format!("OS: {}", self.os_name),
+        };
+        let app_version_information = self
+            .app_version
+            .as_ref()
+            .map(|app_version| format!("Zed: v{} ({})", app_version, self.release_channel));
+        let system_specs = [
+            app_version_information,
+            Some(os_information),
+            Some(format!("Memory: {}", human_bytes(self.memory as f64))),
+            Some(format!("Architecture: {}", self.architecture)),
+        ]
+        .into_iter()
+        .flatten()
+        .collect::<Vec<String>>()
+        .join("\n");
+
+        write!(f, "{system_specs}")
+    }
+}

crates/gpui2/src/element.rs 🔗

@@ -69,6 +69,24 @@ pub trait IntoElement: Sized {
         self.map(|this| if condition { then(this) } else { this })
     }
 
+    fn when_else(
+        self,
+        condition: bool,
+        then: impl FnOnce(Self) -> Self,
+        otherwise: impl FnOnce(Self) -> Self,
+    ) -> Self
+    where
+        Self: Sized,
+    {
+        self.map(|this| {
+            if condition {
+                then(this)
+            } else {
+                otherwise(this)
+            }
+        })
+    }
+
     fn when_some<T>(self, option: Option<T>, then: impl FnOnce(Self, T) -> Self) -> Self
     where
         Self: Sized,

crates/gpui2/src/platform.rs 🔗

@@ -509,7 +509,7 @@ impl Default for CursorStyle {
     }
 }
 
-#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd, Serialize)]
 pub struct SemanticVersion {
     major: usize,
     minor: usize,

crates/ui2/src/components/keybinding.rs 🔗

@@ -98,10 +98,11 @@ impl RenderOnce for Key {
 
         div()
             .py_0()
-            .when(single_char, |el| {
-                el.w(rems(14. / 16.)).flex().flex_none().justify_center()
-            })
-            .when(!single_char, |el| el.px_0p5())
+            .when_else(
+                single_char,
+                |el| el.w(rems(14. / 16.)).flex().flex_none().justify_center(),
+                |el| el.px_0p5(),
+            )
             .h(rems(14. / 16.))
             .text_ui()
             .line_height(relative(1.))

crates/workspace2/src/workspace2.rs 🔗

@@ -68,6 +68,7 @@ use std::{
 use theme::{ActiveTheme, ThemeSettings};
 pub use toolbar::{Toolbar, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView};
 pub use ui;
+// use ui::{h_stack, v_stack, StyledExt};
 use util::ResultExt;
 use uuid::Uuid;
 pub use workspace_settings::{AutosaveSetting, WorkspaceSettings};

crates/zed2/Cargo.toml 🔗

@@ -34,7 +34,7 @@ copilot_button = { package = "copilot_button2", path = "../copilot_button2" }
 diagnostics = { package = "diagnostics2", path = "../diagnostics2" }
 db = { package = "db2", path = "../db2" }
 editor = { package="editor2", path = "../editor2" }
-# feedback = { path = "../feedback" }
+feedback = { package="feedback2", path = "../feedback2" }
 file_finder = { package="file_finder2", path = "../file_finder2" }
 search = { package = "search2", path = "../search2" }
 fs = { package = "fs2", path = "../fs2" }

crates/zed2/src/main.rs 🔗

@@ -221,7 +221,7 @@ fn main() {
         // language_tools::init(cx);
         call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
         collab_ui::init(&app_state, cx);
-        // feedback::init(cx);
+        feedback::init(cx);
         welcome::init(cx);
 
         cx.set_menus(app_menus());

crates/zed2/src/zed2.rs 🔗

@@ -102,6 +102,31 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
         cx.subscribe(&workspace_handle, {
             move |workspace, _, event, cx| {
                 if let workspace::Event::PaneAdded(pane) = event {
+                    pane.update(cx, |pane, cx| {
+                        pane.toolbar().update(cx, |toolbar, cx| {
+                            let breadcrumbs = cx.build_view(|_| Breadcrumbs::new(workspace));
+                            toolbar.add_item(breadcrumbs, cx);
+                            let buffer_search_bar = cx.build_view(search::BufferSearchBar::new);
+                            toolbar.add_item(buffer_search_bar.clone(), cx);
+                            // todo!()
+                            //     let quick_action_bar = cx.add_view(|_| {
+                            //         QuickActionBar::new(buffer_search_bar, workspace)
+                            //     });
+                            //     toolbar.add_item(quick_action_bar, cx);
+                            let diagnostic_editor_controls =
+                                cx.build_view(|_| diagnostics::ToolbarControls::new());
+                            //     toolbar.add_item(diagnostic_editor_controls, cx);
+                            //     let project_search_bar = cx.add_view(|_| ProjectSearchBar::new());
+                            //     toolbar.add_item(project_search_bar, cx);
+                            //     let lsp_log_item =
+                            //         cx.add_view(|_| language_tools::LspLogToolbarItemView::new());
+                            //     toolbar.add_item(lsp_log_item, cx);
+                            //     let syntax_tree_item = cx
+                            //         .add_view(|_| language_tools::SyntaxTreeToolbarItemView::new());
+                            //     toolbar.add_item(syntax_tree_item, cx);
+                        })
+                    });
+
                     initialize_pane(workspace, pane, cx);
                 }
             }
@@ -125,6 +150,9 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
         let active_buffer_language =
             cx.build_view(|_| language_selector::ActiveBufferLanguage::new(workspace));
         //     let vim_mode_indicator = cx.add_view(|cx| vim::ModeIndicator::new(cx));
+        let feedback_button = cx
+            .build_view(|_| feedback::deploy_feedback_button::DeployFeedbackButton::new(workspace));
+        //     let cursor_position = cx.add_view(|_| editor::items::CursorPosition::new());
         //     let feedback_button = cx.add_view(|_| {
         //         feedback::deploy_feedback_button::DeployFeedbackButton::new(workspace)
         //     });
@@ -132,8 +160,8 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
         workspace.status_bar().update(cx, |status_bar, cx| {
             status_bar.add_left_item(diagnostic_summary, cx);
             status_bar.add_left_item(activity_indicator, cx);
-
-            // status_bar.add_right_item(feedback_button, cx);
+            status_bar.add_right_item(feedback_button, cx);
+            // status_bar.add_right_item(copilot, cx);
             status_bar.add_right_item(copilot, cx);
             status_bar.add_right_item(active_buffer_language, cx);
             // status_bar.add_right_item(vim_mode_indicator, cx);