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