1use crate::system_specs::SystemSpecs;
2use anyhow::bail;
3use client::{Client, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL};
4use editor::{Anchor, Editor, EditorEvent};
5use futures::AsyncReadExt;
6use gpui::{
7 actions, serde_json, AnyElement, AnyView, AppContext, Div, EntityId, EventEmitter,
8 FocusableView, Model, PromptLevel, Task, View, ViewContext, WindowContext,
9};
10use isahc::Request;
11use language::{Buffer, Event};
12use project::{search::SearchQuery, Project};
13use regex::Regex;
14use serde::Serialize;
15use std::{
16 any::TypeId,
17 ops::{Range, RangeInclusive},
18 sync::Arc,
19};
20use ui::{prelude::*, Icon, IconElement, Label};
21use util::ResultExt;
22use workspace::{
23 item::{Item, ItemEvent, ItemHandle},
24 searchable::{SearchEvent, SearchableItem, SearchableItemHandle},
25 Workspace,
26};
27
28const FEEDBACK_CHAR_LIMIT: RangeInclusive<usize> = 10..=5000;
29const FEEDBACK_SUBMISSION_ERROR_TEXT: &str =
30 "Feedback failed to submit, see error log for details.";
31
32actions!(GiveFeedback, SubmitFeedback);
33
34pub fn init(cx: &mut AppContext) {
35 cx.observe_new_views(|workspace: &mut Workspace, _| {
36 workspace.register_action(|workspace, _: &GiveFeedback, cx| {
37 FeedbackEditor::deploy(workspace, cx);
38 });
39 })
40 .detach();
41}
42
43#[derive(Serialize)]
44struct FeedbackRequestBody<'a> {
45 feedback_text: &'a str,
46 email: Option<String>,
47 metrics_id: Option<Arc<str>>,
48 installation_id: Option<Arc<str>>,
49 system_specs: SystemSpecs,
50 is_staff: bool,
51 token: &'a str,
52}
53
54#[derive(Clone)]
55pub(crate) struct FeedbackEditor {
56 system_specs: SystemSpecs,
57 editor: View<Editor>,
58 project: Model<Project>,
59 pub allow_submission: bool,
60}
61
62impl EventEmitter<Event> for FeedbackEditor {}
63impl EventEmitter<EditorEvent> for FeedbackEditor {}
64
65impl FeedbackEditor {
66 fn new(
67 system_specs: SystemSpecs,
68 project: Model<Project>,
69 buffer: Model<Buffer>,
70 cx: &mut ViewContext<Self>,
71 ) -> Self {
72 let editor = cx.build_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(
79 &editor,
80 |&mut _, _, e: &EditorEvent, cx: &mut ViewContext<_>| cx.emit(e.clone()),
81 )
82 .detach();
83
84 Self {
85 system_specs: system_specs.clone(),
86 editor,
87 project,
88 allow_submission: true,
89 }
90 }
91
92 pub fn submit(&mut self, cx: &mut ViewContext<Self>) -> Task<anyhow::Result<()>> {
93 if !self.allow_submission {
94 return Task::ready(Ok(()));
95 }
96
97 let feedback_text = self.editor.read(cx).text(cx);
98 let feedback_char_count = feedback_text.chars().count();
99 let feedback_text = feedback_text.trim().to_string();
100
101 let error = if feedback_char_count < *FEEDBACK_CHAR_LIMIT.start() {
102 Some(format!(
103 "Feedback can't be shorter than {} characters.",
104 FEEDBACK_CHAR_LIMIT.start()
105 ))
106 } else if feedback_char_count > *FEEDBACK_CHAR_LIMIT.end() {
107 Some(format!(
108 "Feedback can't be longer than {} characters.",
109 FEEDBACK_CHAR_LIMIT.end()
110 ))
111 } else {
112 None
113 };
114
115 if let Some(error) = error {
116 let prompt = cx.prompt(PromptLevel::Critical, &error, &["OK"]);
117 cx.spawn(|_, _cx| async move {
118 prompt.await.ok();
119 })
120 .detach();
121 return Task::ready(Ok(()));
122 }
123
124 let answer = cx.prompt(
125 PromptLevel::Info,
126 "Ready to submit your feedback?",
127 &["Yes, Submit!", "No"],
128 );
129
130 let client = cx.global::<Arc<Client>>().clone();
131 let specs = self.system_specs.clone();
132
133 cx.spawn(|this, mut cx| async move {
134 let answer = answer.await.ok();
135
136 if answer == Some(0) {
137 this.update(&mut cx, |feedback_editor, cx| {
138 feedback_editor.set_allow_submission(false, cx);
139 })
140 .log_err();
141
142 match FeedbackEditor::submit_feedback(&feedback_text, client, specs).await {
143 Ok(_) => {
144 this.update(&mut cx, |_, cx| cx.emit(Event::Closed))
145 .log_err();
146 }
147
148 Err(error) => {
149 log::error!("{}", error);
150 this.update(&mut cx, |feedback_editor, cx| {
151 let prompt = cx.prompt(
152 PromptLevel::Critical,
153 FEEDBACK_SUBMISSION_ERROR_TEXT,
154 &["OK"],
155 );
156 cx.spawn(|_, _cx| async move {
157 prompt.await.ok();
158 })
159 .detach();
160 feedback_editor.set_allow_submission(true, cx);
161 })
162 .log_err();
163 }
164 }
165 }
166 })
167 .detach();
168
169 Task::ready(Ok(()))
170 }
171
172 fn set_allow_submission(&mut self, allow_submission: bool, cx: &mut ViewContext<Self>) {
173 self.allow_submission = allow_submission;
174 cx.notify();
175 }
176
177 async fn submit_feedback(
178 feedback_text: &str,
179 zed_client: Arc<Client>,
180 system_specs: SystemSpecs,
181 ) -> anyhow::Result<()> {
182 let feedback_endpoint = format!("{}/api/feedback", *ZED_SERVER_URL);
183
184 let telemetry = zed_client.telemetry();
185 let metrics_id = telemetry.metrics_id();
186 let installation_id = telemetry.installation_id();
187 let is_staff = telemetry.is_staff();
188 let http_client = zed_client.http_client();
189
190 let re = Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b").unwrap();
191
192 let emails: Vec<&str> = re
193 .captures_iter(feedback_text)
194 .map(|capture| capture.get(0).unwrap().as_str())
195 .collect();
196
197 let email = emails.first().map(|e| e.to_string());
198
199 let request = FeedbackRequestBody {
200 feedback_text: &feedback_text,
201 email,
202 metrics_id,
203 installation_id,
204 system_specs,
205 is_staff: is_staff.unwrap_or(false),
206 token: ZED_SECRET_CLIENT_TOKEN,
207 };
208
209 let json_bytes = serde_json::to_vec(&request)?;
210
211 let request = Request::post(feedback_endpoint)
212 .header("content-type", "application/json")
213 .body(json_bytes.into())?;
214
215 let mut response = http_client.send(request).await?;
216 let mut body = String::new();
217 response.body_mut().read_to_string(&mut body).await?;
218
219 let response_status = response.status();
220
221 if !response_status.is_success() {
222 bail!("Feedback API failed with error: {}", response_status)
223 }
224
225 Ok(())
226 }
227}
228
229impl FeedbackEditor {
230 pub fn deploy(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
231 let markdown = workspace
232 .app_state()
233 .languages
234 .language_for_name("Markdown");
235 cx.spawn(|workspace, mut cx| async move {
236 let markdown = markdown.await.log_err();
237 workspace
238 .update(&mut cx, |workspace, cx| {
239 workspace.with_local_workspace(cx, |workspace, cx| {
240 let project = workspace.project().clone();
241 let buffer = project
242 .update(cx, |project, cx| project.create_buffer("", markdown, cx))
243 .expect("creating buffers on a local workspace always succeeds");
244 let system_specs = SystemSpecs::new(cx);
245 let feedback_editor = cx.build_view(|cx| {
246 FeedbackEditor::new(system_specs, project, buffer, cx)
247 });
248 workspace.add_item(Box::new(feedback_editor), cx);
249 })
250 })?
251 .await
252 })
253 .detach_and_log_err(cx);
254 }
255}
256
257// TODO
258impl Render for FeedbackEditor {
259 type Element = Div;
260
261 fn render(&mut self, _: &mut ViewContext<Self>) -> Self::Element {
262 div().size_full().child(self.editor.clone())
263 }
264}
265
266impl EventEmitter<ItemEvent> for FeedbackEditor {}
267
268impl FocusableView for FeedbackEditor {
269 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
270 self.editor.focus_handle(cx)
271 }
272}
273
274impl Item for FeedbackEditor {
275 fn tab_tooltip_text(&self, _: &AppContext) -> Option<SharedString> {
276 Some("Send Feedback".into())
277 }
278
279 fn tab_content(&self, detail: Option<usize>, cx: &WindowContext) -> AnyElement {
280 h_stack()
281 .gap_1()
282 .child(IconElement::new(Icon::Envelope).color(Color::Accent))
283 .child(Label::new("Send Feedback".to_string()))
284 .into_any_element()
285 }
286
287 fn for_each_project_item(
288 &self,
289 cx: &AppContext,
290 f: &mut dyn FnMut(EntityId, &dyn project::Item),
291 ) {
292 self.editor.for_each_project_item(cx, f)
293 }
294
295 fn is_singleton(&self, _: &AppContext) -> bool {
296 true
297 }
298
299 fn can_save(&self, _: &AppContext) -> bool {
300 true
301 }
302
303 fn save(
304 &mut self,
305 _project: Model<Project>,
306 cx: &mut ViewContext<Self>,
307 ) -> Task<anyhow::Result<()>> {
308 self.submit(cx)
309 }
310
311 fn save_as(
312 &mut self,
313 _: Model<Project>,
314 _: std::path::PathBuf,
315 cx: &mut ViewContext<Self>,
316 ) -> Task<anyhow::Result<()>> {
317 self.submit(cx)
318 }
319
320 fn reload(&mut self, _: Model<Project>, _: &mut ViewContext<Self>) -> Task<anyhow::Result<()>> {
321 Task::Ready(Some(Ok(())))
322 }
323
324 fn clone_on_split(
325 &self,
326 _workspace_id: workspace::WorkspaceId,
327 cx: &mut ViewContext<Self>,
328 ) -> Option<View<Self>>
329 where
330 Self: Sized,
331 {
332 let buffer = self
333 .editor
334 .read(cx)
335 .buffer()
336 .read(cx)
337 .as_singleton()
338 .expect("Feedback buffer is only ever singleton");
339
340 Some(cx.build_view(|cx| {
341 Self::new(
342 self.system_specs.clone(),
343 self.project.clone(),
344 buffer.clone(),
345 cx,
346 )
347 }))
348 }
349
350 fn as_searchable(&self, handle: &View<Self>) -> Option<Box<dyn SearchableItemHandle>> {
351 Some(Box::new(handle.clone()))
352 }
353
354 fn act_as_type<'a>(
355 &'a self,
356 type_id: TypeId,
357 self_handle: &'a View<Self>,
358 cx: &'a AppContext,
359 ) -> Option<AnyView> {
360 if type_id == TypeId::of::<Self>() {
361 Some(self_handle.to_any())
362 } else if type_id == TypeId::of::<Editor>() {
363 Some(self.editor.to_any())
364 } else {
365 None
366 }
367 }
368
369 fn deactivated(&mut self, _: &mut ViewContext<Self>) {}
370
371 fn workspace_deactivated(&mut self, _: &mut ViewContext<Self>) {}
372
373 fn navigate(&mut self, _: Box<dyn std::any::Any>, _: &mut ViewContext<Self>) -> bool {
374 false
375 }
376
377 fn tab_description(&self, _: usize, _: &AppContext) -> Option<ui::prelude::SharedString> {
378 None
379 }
380
381 fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
382
383 fn is_dirty(&self, _: &AppContext) -> bool {
384 false
385 }
386
387 fn has_conflict(&self, _: &AppContext) -> bool {
388 false
389 }
390
391 fn breadcrumb_location(&self) -> workspace::ToolbarItemLocation {
392 workspace::ToolbarItemLocation::Hidden
393 }
394
395 fn breadcrumbs(
396 &self,
397 _theme: &theme::Theme,
398 _cx: &AppContext,
399 ) -> Option<Vec<workspace::item::BreadcrumbText>> {
400 None
401 }
402
403 fn added_to_workspace(&mut self, _workspace: &mut Workspace, _cx: &mut ViewContext<Self>) {}
404
405 fn serialized_item_kind() -> Option<&'static str> {
406 Some("feedback")
407 }
408
409 fn deserialize(
410 _project: gpui::Model<Project>,
411 _workspace: gpui::WeakView<Workspace>,
412 _workspace_id: workspace::WorkspaceId,
413 _item_id: workspace::ItemId,
414 _cx: &mut ViewContext<workspace::Pane>,
415 ) -> Task<anyhow::Result<View<Self>>> {
416 unimplemented!(
417 "deserialize() must be implemented if serialized_item_kind() returns Some(_)"
418 )
419 }
420
421 fn show_toolbar(&self) -> bool {
422 true
423 }
424
425 fn pixel_position_of_cursor(&self, _: &AppContext) -> Option<gpui::Point<gpui::Pixels>> {
426 None
427 }
428}
429
430impl EventEmitter<SearchEvent> for FeedbackEditor {}
431
432impl SearchableItem for FeedbackEditor {
433 type Match = Range<Anchor>;
434
435 fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
436 self.editor
437 .update(cx, |editor, cx| editor.clear_matches(cx))
438 }
439
440 fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
441 self.editor
442 .update(cx, |editor, cx| editor.update_matches(matches, cx))
443 }
444
445 fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
446 self.editor
447 .update(cx, |editor, cx| editor.query_suggestion(cx))
448 }
449
450 fn activate_match(
451 &mut self,
452 index: usize,
453 matches: Vec<Self::Match>,
454 cx: &mut ViewContext<Self>,
455 ) {
456 self.editor
457 .update(cx, |editor, cx| editor.activate_match(index, matches, cx))
458 }
459
460 fn select_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
461 self.editor
462 .update(cx, |e, cx| e.select_matches(matches, cx))
463 }
464 fn replace(&mut self, matches: &Self::Match, query: &SearchQuery, cx: &mut ViewContext<Self>) {
465 self.editor
466 .update(cx, |e, cx| e.replace(matches, query, cx));
467 }
468 fn find_matches(
469 &mut self,
470 query: Arc<project::search::SearchQuery>,
471 cx: &mut ViewContext<Self>,
472 ) -> Task<Vec<Self::Match>> {
473 self.editor
474 .update(cx, |editor, cx| editor.find_matches(query, cx))
475 }
476
477 fn active_match_index(
478 &mut self,
479 matches: Vec<Self::Match>,
480 cx: &mut ViewContext<Self>,
481 ) -> Option<usize> {
482 self.editor
483 .update(cx, |editor, cx| editor.active_match_index(matches, cx))
484 }
485}