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