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