1use std::{ops::Range, sync::Arc};
2
3use anyhow::bail;
4use client::{Client, ZED_SECRET_CLIENT_TOKEN};
5use editor::{Anchor, Editor};
6use futures::AsyncReadExt;
7use gpui::{
8 actions,
9 elements::{ChildView, Flex, Label, MouseEventHandler, ParentElement, Stack, Text},
10 serde_json, AnyViewHandle, AppContext, CursorStyle, Element, ElementBox, Entity, ModelHandle,
11 MouseButton, MutableAppContext, PromptLevel, RenderContext, Task, View, ViewContext,
12 ViewHandle,
13};
14use isahc::Request;
15use language::Buffer;
16use postage::prelude::Stream;
17
18use lazy_static::lazy_static;
19use project::Project;
20use serde::Serialize;
21use settings::Settings;
22use workspace::{
23 item::{Item, ItemHandle},
24 searchable::{SearchableItem, SearchableItemHandle},
25 StatusItemView, Workspace,
26};
27
28use crate::system_specs::SystemSpecs;
29
30lazy_static! {
31 pub static ref ZED_SERVER_URL: String =
32 std::env::var("ZED_SERVER_URL").unwrap_or_else(|_| "https://zed.dev".to_string());
33}
34
35const FEEDBACK_CHAR_COUNT_RANGE: Range<usize> = Range {
36 start: 10,
37 end: 1000,
38};
39
40const FEEDBACK_PLACEHOLDER_TEXT: &str = "Thanks for spending time with Zed. Enter your feedback here in the form of Markdown. Save the tab to submit your feedback.";
41const FEEDBACK_SUBMISSION_ERROR_TEXT: &str =
42 "Feedback failed to submit, see error log for details.";
43
44actions!(feedback, [SubmitFeedback, GiveFeedback, DeployFeedback]);
45
46pub fn init(cx: &mut MutableAppContext) {
47 cx.add_action(FeedbackEditor::deploy);
48}
49
50pub struct FeedbackButton;
51
52impl Entity for FeedbackButton {
53 type Event = ();
54}
55
56impl View for FeedbackButton {
57 fn ui_name() -> &'static str {
58 "FeedbackButton"
59 }
60
61 fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
62 Stack::new()
63 .with_child(
64 MouseEventHandler::<Self>::new(0, cx, |state, cx| {
65 let theme = &cx.global::<Settings>().theme;
66 let theme = &theme.workspace.status_bar.feedback;
67
68 Text::new(
69 "Give Feedback".to_string(),
70 theme.style_for(state, true).clone(),
71 )
72 .boxed()
73 })
74 .with_cursor_style(CursorStyle::PointingHand)
75 .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(GiveFeedback))
76 .boxed(),
77 )
78 .boxed()
79 }
80
81 fn focus_in(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {}
82
83 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {}
84
85 fn key_down(&mut self, _: &gpui::KeyDownEvent, _: &mut ViewContext<Self>) -> bool {
86 false
87 }
88
89 fn key_up(&mut self, _: &gpui::KeyUpEvent, _: &mut ViewContext<Self>) -> bool {
90 false
91 }
92
93 fn modifiers_changed(
94 &mut self,
95 _: &gpui::ModifiersChangedEvent,
96 _: &mut ViewContext<Self>,
97 ) -> bool {
98 false
99 }
100
101 fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap_matcher::KeymapContext {
102 Self::default_keymap_context()
103 }
104
105 fn default_keymap_context() -> gpui::keymap_matcher::KeymapContext {
106 let mut cx = gpui::keymap_matcher::KeymapContext::default();
107 cx.set.insert(Self::ui_name().into());
108 cx
109 }
110
111 fn debug_json(&self, _: &gpui::AppContext) -> gpui::serde_json::Value {
112 gpui::serde_json::Value::Null
113 }
114
115 fn text_for_range(&self, _: Range<usize>, _: &gpui::AppContext) -> Option<String> {
116 None
117 }
118
119 fn selected_text_range(&self, _: &gpui::AppContext) -> Option<Range<usize>> {
120 None
121 }
122
123 fn marked_text_range(&self, _: &gpui::AppContext) -> Option<Range<usize>> {
124 None
125 }
126
127 fn unmark_text(&mut self, _: &mut ViewContext<Self>) {}
128
129 fn replace_text_in_range(
130 &mut self,
131 _: Option<Range<usize>>,
132 _: &str,
133 _: &mut ViewContext<Self>,
134 ) {
135 }
136
137 fn replace_and_mark_text_in_range(
138 &mut self,
139 _: Option<Range<usize>>,
140 _: &str,
141 _: Option<Range<usize>>,
142 _: &mut ViewContext<Self>,
143 ) {
144 }
145}
146
147impl StatusItemView for FeedbackButton {
148 fn set_active_pane_item(
149 &mut self,
150 _: Option<&dyn ItemHandle>,
151 _: &mut gpui::ViewContext<Self>,
152 ) {
153 }
154}
155
156#[derive(Serialize)]
157struct FeedbackRequestBody<'a> {
158 feedback_text: &'a str,
159 metrics_id: Option<Arc<str>>,
160 system_specs: SystemSpecs,
161 token: &'a str,
162}
163
164#[derive(Clone)]
165struct FeedbackEditor {
166 editor: ViewHandle<Editor>,
167 project: ModelHandle<Project>,
168}
169
170impl FeedbackEditor {
171 fn new_with_buffer(
172 project: ModelHandle<Project>,
173 buffer: ModelHandle<Buffer>,
174 cx: &mut ViewContext<Self>,
175 ) -> Self {
176 let editor = cx.add_view(|cx| {
177 let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx);
178 editor.set_vertical_scroll_margin(5, cx);
179 editor.set_placeholder_text(FEEDBACK_PLACEHOLDER_TEXT, cx);
180 editor
181 });
182
183 cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone()))
184 .detach();
185
186 let this = Self { editor, project };
187 this
188 }
189
190 fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
191 let markdown_language = project.read(cx).languages().get_language("Markdown");
192
193 let buffer = project
194 .update(cx, |project, cx| {
195 project.create_buffer("", markdown_language, cx)
196 })
197 .expect("creating buffers on a local workspace always succeeds");
198
199 Self::new_with_buffer(project, buffer, cx)
200 }
201
202 fn handle_save(
203 &mut self,
204 _: gpui::ModelHandle<Project>,
205 cx: &mut ViewContext<Self>,
206 ) -> Task<anyhow::Result<()>> {
207 let feedback_text_length = self.editor.read(cx).buffer().read(cx).len(cx);
208
209 if feedback_text_length <= FEEDBACK_CHAR_COUNT_RANGE.start {
210 cx.prompt(
211 PromptLevel::Critical,
212 &format!(
213 "Feedback must be longer than {} characters",
214 FEEDBACK_CHAR_COUNT_RANGE.start
215 ),
216 &["OK"],
217 );
218
219 return Task::ready(Ok(()));
220 }
221
222 let mut answer = cx.prompt(
223 PromptLevel::Info,
224 "Ready to submit your feedback?",
225 &["Yes, Submit!", "No"],
226 );
227
228 let this = cx.handle();
229 let client = cx.global::<Arc<Client>>().clone();
230 let feedback_text = self.editor.read(cx).text(cx);
231 let specs = SystemSpecs::new(cx);
232
233 cx.spawn(|_, mut cx| async move {
234 let answer = answer.recv().await;
235
236 if answer == Some(0) {
237 match FeedbackEditor::submit_feedback(&feedback_text, client, specs).await {
238 Ok(_) => {
239 cx.update(|cx| {
240 this.update(cx, |_, cx| {
241 cx.dispatch_action(workspace::CloseActiveItem);
242 })
243 });
244 }
245 Err(error) => {
246 log::error!("{}", error);
247
248 cx.update(|cx| {
249 this.update(cx, |_, cx| {
250 cx.prompt(
251 PromptLevel::Critical,
252 FEEDBACK_SUBMISSION_ERROR_TEXT,
253 &["OK"],
254 );
255 })
256 });
257 }
258 }
259 }
260 })
261 .detach();
262
263 Task::ready(Ok(()))
264 }
265
266 async fn submit_feedback(
267 feedback_text: &str,
268 zed_client: Arc<Client>,
269 system_specs: SystemSpecs,
270 ) -> anyhow::Result<()> {
271 let feedback_endpoint = format!("{}/api/feedback", *ZED_SERVER_URL);
272
273 let metrics_id = zed_client.metrics_id();
274 let http_client = zed_client.http_client();
275
276 let request = FeedbackRequestBody {
277 feedback_text: &feedback_text,
278 metrics_id,
279 system_specs,
280 token: ZED_SECRET_CLIENT_TOKEN,
281 };
282
283 let json_bytes = serde_json::to_vec(&request)?;
284
285 let request = Request::post(feedback_endpoint)
286 .header("content-type", "application/json")
287 .body(json_bytes.into())?;
288
289 let mut response = http_client.send(request).await?;
290 let mut body = String::new();
291 response.body_mut().read_to_string(&mut body).await?;
292
293 let response_status = response.status();
294
295 if !response_status.is_success() {
296 bail!("Feedback API failed with error: {}", response_status)
297 }
298
299 Ok(())
300 }
301}
302
303impl FeedbackEditor {
304 pub fn deploy(workspace: &mut Workspace, _: &GiveFeedback, cx: &mut ViewContext<Workspace>) {
305 let feedback_editor =
306 cx.add_view(|cx| FeedbackEditor::new(workspace.project().clone(), cx));
307 workspace.add_item(Box::new(feedback_editor), cx);
308 }
309}
310
311impl View for FeedbackEditor {
312 fn ui_name() -> &'static str {
313 "FeedbackEditor"
314 }
315
316 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
317 ChildView::new(&self.editor, cx).boxed()
318 }
319
320 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
321 if cx.is_self_focused() {
322 cx.focus(&self.editor);
323 }
324 }
325}
326
327impl Entity for FeedbackEditor {
328 type Event = editor::Event;
329}
330
331impl Item for FeedbackEditor {
332 fn tab_content(
333 &self,
334 _: Option<usize>,
335 style: &theme::Tab,
336 _: &gpui::AppContext,
337 ) -> ElementBox {
338 Flex::row()
339 .with_child(
340 Label::new("Feedback".to_string(), style.label.clone())
341 .aligned()
342 .contained()
343 .boxed(),
344 )
345 .boxed()
346 }
347
348 fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
349 self.editor.for_each_project_item(cx, f)
350 }
351
352 fn to_item_events(_: &Self::Event) -> Vec<workspace::item::ItemEvent> {
353 Vec::new()
354 }
355
356 fn is_singleton(&self, _: &gpui::AppContext) -> bool {
357 true
358 }
359
360 fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
361
362 fn can_save(&self, _: &gpui::AppContext) -> bool {
363 true
364 }
365
366 fn save(
367 &mut self,
368 project: gpui::ModelHandle<Project>,
369 cx: &mut ViewContext<Self>,
370 ) -> Task<anyhow::Result<()>> {
371 self.handle_save(project, cx)
372 }
373
374 fn save_as(
375 &mut self,
376 project: gpui::ModelHandle<Project>,
377 _: std::path::PathBuf,
378 cx: &mut ViewContext<Self>,
379 ) -> Task<anyhow::Result<()>> {
380 self.handle_save(project, cx)
381 }
382
383 fn reload(
384 &mut self,
385 _: gpui::ModelHandle<Project>,
386 _: &mut ViewContext<Self>,
387 ) -> Task<anyhow::Result<()>> {
388 unreachable!("reload should not have been called")
389 }
390
391 fn clone_on_split(
392 &self,
393 _workspace_id: workspace::WorkspaceId,
394 cx: &mut ViewContext<Self>,
395 ) -> Option<Self>
396 where
397 Self: Sized,
398 {
399 let buffer = self
400 .editor
401 .read(cx)
402 .buffer()
403 .read(cx)
404 .as_singleton()
405 .expect("Feedback buffer is only ever singleton");
406
407 Some(Self::new_with_buffer(
408 self.project.clone(),
409 buffer.clone(),
410 cx,
411 ))
412 }
413
414 fn serialized_item_kind() -> Option<&'static str> {
415 None
416 }
417
418 fn deserialize(
419 _: gpui::ModelHandle<Project>,
420 _: gpui::WeakViewHandle<Workspace>,
421 _: workspace::WorkspaceId,
422 _: workspace::ItemId,
423 _: &mut ViewContext<workspace::Pane>,
424 ) -> Task<anyhow::Result<ViewHandle<Self>>> {
425 unreachable!()
426 }
427
428 fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
429 Some(Box::new(handle.clone()))
430 }
431}
432
433impl SearchableItem for FeedbackEditor {
434 type Match = Range<Anchor>;
435
436 fn to_search_event(event: &Self::Event) -> Option<workspace::searchable::SearchEvent> {
437 Editor::to_search_event(event)
438 }
439
440 fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
441 self.editor
442 .update(cx, |editor, cx| editor.clear_matches(cx))
443 }
444
445 fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
446 self.editor
447 .update(cx, |editor, cx| editor.update_matches(matches, cx))
448 }
449
450 fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
451 self.editor
452 .update(cx, |editor, cx| editor.query_suggestion(cx))
453 }
454
455 fn activate_match(
456 &mut self,
457 index: usize,
458 matches: Vec<Self::Match>,
459 cx: &mut ViewContext<Self>,
460 ) {
461 self.editor
462 .update(cx, |editor, cx| editor.activate_match(index, matches, cx))
463 }
464
465 fn find_matches(
466 &mut self,
467 query: project::search::SearchQuery,
468 cx: &mut ViewContext<Self>,
469 ) -> Task<Vec<Self::Match>> {
470 self.editor
471 .update(cx, |editor, cx| editor.find_matches(query, cx))
472 }
473
474 fn active_match_index(
475 &mut self,
476 matches: Vec<Self::Match>,
477 cx: &mut ViewContext<Self>,
478 ) -> Option<usize> {
479 self.editor
480 .update(cx, |editor, cx| editor.active_match_index(matches, cx))
481 }
482}