1use crate::system_specs::SystemSpecs;
2use anyhow::bail;
3use client::{Client, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL};
4use editor::{Anchor, Editor};
5use futures::AsyncReadExt;
6use gpui::{
7 actions,
8 elements::{ChildView, Flex, Label, ParentElement, Svg},
9 platform::PromptLevel,
10 serde_json, AnyElement, AnyViewHandle, AppContext, Element, Entity, ModelHandle, Task, View,
11 ViewContext, ViewHandle,
12};
13use isahc::Request;
14use language::Buffer;
15use postage::prelude::Stream;
16use project::Project;
17use regex::Regex;
18use serde::Serialize;
19use smallvec::SmallVec;
20use std::{
21 any::TypeId,
22 borrow::Cow,
23 ops::{Range, RangeInclusive},
24 sync::Arc,
25};
26use util::ResultExt;
27use workspace::{
28 item::{Item, ItemEvent, ItemHandle},
29 searchable::{SearchableItem, SearchableItemHandle},
30 Workspace,
31};
32
33const FEEDBACK_CHAR_LIMIT: RangeInclusive<usize> = 10..=5000;
34const FEEDBACK_SUBMISSION_ERROR_TEXT: &str =
35 "Feedback failed to submit, see error log for details.";
36
37actions!(feedback, [GiveFeedback, SubmitFeedback]);
38
39pub fn init(cx: &mut AppContext) {
40 cx.add_action({
41 move |workspace: &mut Workspace, _: &GiveFeedback, cx: &mut ViewContext<Workspace>| {
42 FeedbackEditor::deploy(workspace, cx);
43 }
44 });
45}
46
47#[derive(Serialize)]
48struct FeedbackRequestBody<'a> {
49 feedback_text: &'a str,
50 email: Option<String>,
51 metrics_id: Option<Arc<str>>,
52 installation_id: Option<Arc<str>>,
53 system_specs: SystemSpecs,
54 is_staff: bool,
55 token: &'a str,
56}
57
58#[derive(Clone)]
59pub(crate) struct FeedbackEditor {
60 system_specs: SystemSpecs,
61 editor: ViewHandle<Editor>,
62 project: ModelHandle<Project>,
63}
64
65impl FeedbackEditor {
66 fn new(
67 system_specs: SystemSpecs,
68 project: ModelHandle<Project>,
69 buffer: ModelHandle<Buffer>,
70 cx: &mut ViewContext<Self>,
71 ) -> Self {
72 let editor = cx.add_view(|cx| {
73 let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx);
74 editor.set_vertical_scroll_margin(5, cx);
75 editor
76 });
77
78 cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone()))
79 .detach();
80
81 Self {
82 system_specs: system_specs.clone(),
83 editor,
84 project,
85 }
86 }
87
88 pub fn submit(&mut self, cx: &mut ViewContext<Self>) -> Task<anyhow::Result<()>> {
89 let feedback_text = self.editor.read(cx).text(cx);
90 let feedback_char_count = feedback_text.chars().count();
91 let feedback_text = feedback_text.trim().to_string();
92
93 let error = if feedback_char_count < *FEEDBACK_CHAR_LIMIT.start() {
94 Some(format!(
95 "Feedback can't be shorter than {} characters.",
96 FEEDBACK_CHAR_LIMIT.start()
97 ))
98 } else if feedback_char_count > *FEEDBACK_CHAR_LIMIT.end() {
99 Some(format!(
100 "Feedback can't be longer than {} characters.",
101 FEEDBACK_CHAR_LIMIT.end()
102 ))
103 } else {
104 None
105 };
106
107 if let Some(error) = error {
108 cx.prompt(PromptLevel::Critical, &error, &["OK"]);
109 return Task::ready(Ok(()));
110 }
111
112 let mut answer = cx.prompt(
113 PromptLevel::Info,
114 "Ready to submit your feedback?",
115 &["Yes, Submit!", "No"],
116 );
117
118 let client = cx.global::<Arc<Client>>().clone();
119 let specs = self.system_specs.clone();
120
121 cx.spawn(|this, mut cx| async move {
122 let answer = answer.recv().await;
123
124 if answer == Some(0) {
125 match FeedbackEditor::submit_feedback(&feedback_text, client, specs).await {
126 Ok(_) => {
127 this.update(&mut cx, |_, cx| cx.emit(editor::Event::Closed))
128 .log_err();
129 }
130 Err(error) => {
131 log::error!("{}", error);
132 this.update(&mut cx, |_, cx| {
133 cx.prompt(
134 PromptLevel::Critical,
135 FEEDBACK_SUBMISSION_ERROR_TEXT,
136 &["OK"],
137 );
138 })
139 .log_err();
140 }
141 }
142 }
143 })
144 .detach();
145
146 Task::ready(Ok(()))
147 }
148
149 async fn submit_feedback(
150 feedback_text: &str,
151 zed_client: Arc<Client>,
152 system_specs: SystemSpecs,
153 ) -> anyhow::Result<()> {
154 let feedback_endpoint = format!("{}/api/feedback", *ZED_SERVER_URL);
155
156 let telemetry = zed_client.telemetry();
157 let metrics_id = telemetry.metrics_id();
158 let installation_id = telemetry.installation_id();
159 let is_staff = telemetry.is_staff();
160 let http_client = zed_client.http_client();
161
162 let re = Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b").unwrap();
163
164 let emails: Vec<&str> = re
165 .captures_iter(feedback_text)
166 .map(|capture| capture.get(0).unwrap().as_str())
167 .collect();
168
169 let email = emails.first().map(|e| e.to_string());
170
171 let request = FeedbackRequestBody {
172 feedback_text: &feedback_text,
173 email,
174 metrics_id,
175 installation_id,
176 system_specs,
177 is_staff: is_staff.unwrap_or(false),
178 token: ZED_SECRET_CLIENT_TOKEN,
179 };
180
181 let json_bytes = serde_json::to_vec(&request)?;
182
183 let request = Request::post(feedback_endpoint)
184 .header("content-type", "application/json")
185 .body(json_bytes.into())?;
186
187 let mut response = http_client.send(request).await?;
188 let mut body = String::new();
189 response.body_mut().read_to_string(&mut body).await?;
190
191 let response_status = response.status();
192
193 if !response_status.is_success() {
194 bail!("Feedback API failed with error: {}", response_status)
195 }
196
197 Ok(())
198 }
199}
200
201impl FeedbackEditor {
202 pub fn deploy(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
203 let markdown = workspace
204 .app_state()
205 .languages
206 .language_for_name("Markdown");
207 cx.spawn(|workspace, mut cx| async move {
208 let markdown = markdown.await.log_err();
209 workspace
210 .update(&mut cx, |workspace, cx| {
211 workspace.with_local_workspace(cx, |workspace, cx| {
212 let project = workspace.project().clone();
213 let buffer = project
214 .update(cx, |project, cx| project.create_buffer("", markdown, cx))
215 .expect("creating buffers on a local workspace always succeeds");
216 let system_specs = SystemSpecs::new(cx);
217 let feedback_editor = cx
218 .add_view(|cx| FeedbackEditor::new(system_specs, project, buffer, cx));
219 workspace.add_item(Box::new(feedback_editor), cx);
220 })
221 })?
222 .await
223 })
224 .detach_and_log_err(cx);
225 }
226}
227
228impl View for FeedbackEditor {
229 fn ui_name() -> &'static str {
230 "FeedbackEditor"
231 }
232
233 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
234 ChildView::new(&self.editor, cx).into_any()
235 }
236
237 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
238 if cx.is_self_focused() {
239 cx.focus(&self.editor);
240 }
241 }
242}
243
244impl Entity for FeedbackEditor {
245 type Event = editor::Event;
246}
247
248impl Item for FeedbackEditor {
249 fn tab_tooltip_text(&self, _: &AppContext) -> Option<Cow<str>> {
250 Some("Send Feedback".into())
251 }
252
253 fn tab_content<T: View>(
254 &self,
255 _: Option<usize>,
256 style: &theme::Tab,
257 _: &AppContext,
258 ) -> AnyElement<T> {
259 Flex::row()
260 .with_child(
261 Svg::new("icons/feedback_16.svg")
262 .with_color(style.label.text.color)
263 .constrained()
264 .with_width(style.type_icon_width)
265 .aligned()
266 .contained()
267 .with_margin_right(style.spacing),
268 )
269 .with_child(
270 Label::new("Send Feedback", style.label.clone())
271 .aligned()
272 .contained(),
273 )
274 .into_any()
275 }
276
277 fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
278 self.editor.for_each_project_item(cx, f)
279 }
280
281 fn is_singleton(&self, _: &AppContext) -> bool {
282 true
283 }
284
285 fn can_save(&self, _: &AppContext) -> bool {
286 true
287 }
288
289 fn save(
290 &mut self,
291 _: ModelHandle<Project>,
292 cx: &mut ViewContext<Self>,
293 ) -> Task<anyhow::Result<()>> {
294 self.submit(cx)
295 }
296
297 fn save_as(
298 &mut self,
299 _: ModelHandle<Project>,
300 _: std::path::PathBuf,
301 cx: &mut ViewContext<Self>,
302 ) -> Task<anyhow::Result<()>> {
303 self.submit(cx)
304 }
305
306 fn reload(
307 &mut self,
308 _: ModelHandle<Project>,
309 _: &mut ViewContext<Self>,
310 ) -> Task<anyhow::Result<()>> {
311 Task::Ready(Some(Ok(())))
312 }
313
314 fn clone_on_split(
315 &self,
316 _workspace_id: workspace::WorkspaceId,
317 cx: &mut ViewContext<Self>,
318 ) -> Option<Self>
319 where
320 Self: Sized,
321 {
322 let buffer = self
323 .editor
324 .read(cx)
325 .buffer()
326 .read(cx)
327 .as_singleton()
328 .expect("Feedback buffer is only ever singleton");
329
330 Some(Self::new(
331 self.system_specs.clone(),
332 self.project.clone(),
333 buffer.clone(),
334 cx,
335 ))
336 }
337
338 fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
339 Some(Box::new(handle.clone()))
340 }
341
342 fn act_as_type<'a>(
343 &'a self,
344 type_id: TypeId,
345 self_handle: &'a ViewHandle<Self>,
346 _: &'a AppContext,
347 ) -> Option<&'a AnyViewHandle> {
348 if type_id == TypeId::of::<Self>() {
349 Some(self_handle)
350 } else if type_id == TypeId::of::<Editor>() {
351 Some(&self.editor)
352 } else {
353 None
354 }
355 }
356
357 fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
358 Editor::to_item_events(event)
359 }
360}
361
362impl SearchableItem for FeedbackEditor {
363 type Match = Range<Anchor>;
364
365 fn to_search_event(
366 &mut self,
367 event: &Self::Event,
368 cx: &mut ViewContext<Self>,
369 ) -> Option<workspace::searchable::SearchEvent> {
370 self.editor
371 .update(cx, |editor, cx| editor.to_search_event(event, cx))
372 }
373
374 fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
375 self.editor
376 .update(cx, |editor, cx| editor.clear_matches(cx))
377 }
378
379 fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
380 self.editor
381 .update(cx, |editor, cx| editor.update_matches(matches, cx))
382 }
383
384 fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
385 self.editor
386 .update(cx, |editor, cx| editor.query_suggestion(cx))
387 }
388
389 fn activate_match(
390 &mut self,
391 index: usize,
392 matches: Vec<Self::Match>,
393 cx: &mut ViewContext<Self>,
394 ) {
395 self.editor
396 .update(cx, |editor, cx| editor.activate_match(index, matches, cx))
397 }
398
399 fn select_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
400 self.editor
401 .update(cx, |e, cx| e.select_matches(matches, cx))
402 }
403
404 fn find_matches(
405 &mut self,
406 query: project::search::SearchQuery,
407 cx: &mut ViewContext<Self>,
408 ) -> Task<Vec<Self::Match>> {
409 self.editor
410 .update(cx, |editor, cx| editor.find_matches(query, cx))
411 }
412
413 fn active_match_index(
414 &mut self,
415 matches: Vec<Self::Match>,
416 cx: &mut ViewContext<Self>,
417 ) -> Option<usize> {
418 self.editor
419 .update(cx, |editor, cx| editor.active_match_index(matches, cx))
420 }
421}