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