1use std::{
2 any::TypeId,
3 ops::{Range, RangeInclusive},
4 sync::Arc,
5};
6
7use anyhow::bail;
8use client::{Client, ZED_SECRET_CLIENT_TOKEN};
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 lazy_static::lazy_static;
23use project::Project;
24use serde::Serialize;
25use settings::Settings;
26use workspace::{
27 item::{Item, ItemHandle},
28 searchable::{SearchableItem, SearchableItemHandle},
29 StatusItemView, Workspace,
30};
31
32use crate::system_specs::SystemSpecs;
33
34lazy_static! {
35 pub static ref ZED_SERVER_URL: String =
36 std::env::var("ZED_SERVER_URL").unwrap_or_else(|_| "https://zed.dev".to_string());
37}
38
39const FEEDBACK_CHAR_LIMIT: RangeInclusive<usize> = 10..=5000;
40const FEEDBACK_PLACEHOLDER_TEXT: &str = "Thanks for spending time with Zed. Enter your feedback here as 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
82impl StatusItemView for FeedbackButton {
83 fn set_active_pane_item(&mut self, _: Option<&dyn ItemHandle>, _: &mut ViewContext<Self>) {}
84}
85
86#[derive(Serialize)]
87struct FeedbackRequestBody<'a> {
88 feedback_text: &'a str,
89 metrics_id: Option<Arc<str>>,
90 system_specs: SystemSpecs,
91 token: &'a str,
92}
93
94#[derive(Clone)]
95struct FeedbackEditor {
96 editor: ViewHandle<Editor>,
97 project: ModelHandle<Project>,
98}
99
100impl FeedbackEditor {
101 fn new_with_buffer(
102 project: ModelHandle<Project>,
103 buffer: ModelHandle<Buffer>,
104 cx: &mut ViewContext<Self>,
105 ) -> Self {
106 let editor = cx.add_view(|cx| {
107 let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx);
108 editor.set_vertical_scroll_margin(5, cx);
109 editor.set_placeholder_text(FEEDBACK_PLACEHOLDER_TEXT, cx);
110 editor
111 });
112
113 cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone()))
114 .detach();
115
116 Self { editor, project }
117 }
118
119 fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
120 let markdown_language = project.read(cx).languages().language_for_name("Markdown");
121
122 let buffer = project
123 .update(cx, |project, cx| {
124 project.create_buffer("", markdown_language, cx)
125 })
126 .expect("creating buffers on a local workspace always succeeds");
127
128 Self::new_with_buffer(project, buffer, cx)
129 }
130
131 fn handle_save(
132 &mut self,
133 _: ModelHandle<Project>,
134 cx: &mut ViewContext<Self>,
135 ) -> Task<anyhow::Result<()>> {
136 let feedback_char_count = self.editor.read(cx).text(cx).chars().count();
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 feedback_text = self.editor.read(cx).text(cx);
166 let specs = SystemSpecs::new(cx);
167
168 cx.spawn(|_, mut cx| async move {
169 let answer = answer.recv().await;
170
171 if answer == Some(0) {
172 match FeedbackEditor::submit_feedback(&feedback_text, client, specs).await {
173 Ok(_) => {
174 cx.update(|cx| {
175 this.update(cx, |_, cx| {
176 cx.dispatch_action(workspace::CloseActiveItem);
177 })
178 });
179 }
180 Err(error) => {
181 log::error!("{}", error);
182
183 cx.update(|cx| {
184 this.update(cx, |_, cx| {
185 cx.prompt(
186 PromptLevel::Critical,
187 FEEDBACK_SUBMISSION_ERROR_TEXT,
188 &["OK"],
189 );
190 })
191 });
192 }
193 }
194 }
195 })
196 .detach();
197
198 Task::ready(Ok(()))
199 }
200
201 async fn submit_feedback(
202 feedback_text: &str,
203 zed_client: Arc<Client>,
204 system_specs: SystemSpecs,
205 ) -> anyhow::Result<()> {
206 let feedback_endpoint = format!("{}/api/feedback", *ZED_SERVER_URL);
207
208 let metrics_id = zed_client.metrics_id();
209 let http_client = zed_client.http_client();
210
211 let request = FeedbackRequestBody {
212 feedback_text: &feedback_text,
213 metrics_id,
214 system_specs,
215 token: ZED_SECRET_CLIENT_TOKEN,
216 };
217
218 let json_bytes = serde_json::to_vec(&request)?;
219
220 let request = Request::post(feedback_endpoint)
221 .header("content-type", "application/json")
222 .body(json_bytes.into())?;
223
224 let mut response = http_client.send(request).await?;
225 let mut body = String::new();
226 response.body_mut().read_to_string(&mut body).await?;
227
228 let response_status = response.status();
229
230 if !response_status.is_success() {
231 bail!("Feedback API failed with error: {}", response_status)
232 }
233
234 Ok(())
235 }
236}
237
238impl FeedbackEditor {
239 pub fn deploy(workspace: &mut Workspace, _: &GiveFeedback, cx: &mut ViewContext<Workspace>) {
240 let feedback_editor =
241 cx.add_view(|cx| FeedbackEditor::new(workspace.project().clone(), cx));
242 workspace.add_item(Box::new(feedback_editor), cx);
243 }
244}
245
246impl View for FeedbackEditor {
247 fn ui_name() -> &'static str {
248 "FeedbackEditor"
249 }
250
251 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
252 ChildView::new(&self.editor, cx).boxed()
253 }
254
255 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
256 if cx.is_self_focused() {
257 cx.focus(&self.editor);
258 }
259 }
260}
261
262impl Entity for FeedbackEditor {
263 type Event = editor::Event;
264}
265
266impl Item for FeedbackEditor {
267 fn tab_content(&self, _: Option<usize>, style: &theme::Tab, _: &AppContext) -> ElementBox {
268 Flex::row()
269 .with_child(
270 Label::new("Feedback".to_string(), style.label.clone())
271 .aligned()
272 .contained()
273 .boxed(),
274 )
275 .boxed()
276 }
277
278 fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
279 self.editor.for_each_project_item(cx, f)
280 }
281
282 fn to_item_events(_: &Self::Event) -> Vec<workspace::item::ItemEvent> {
283 Vec::new()
284 }
285
286 fn is_singleton(&self, _: &AppContext) -> bool {
287 true
288 }
289
290 fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
291
292 fn can_save(&self, _: &AppContext) -> bool {
293 true
294 }
295
296 fn save(
297 &mut self,
298 project: ModelHandle<Project>,
299 cx: &mut ViewContext<Self>,
300 ) -> Task<anyhow::Result<()>> {
301 self.handle_save(project, cx)
302 }
303
304 fn save_as(
305 &mut self,
306 project: ModelHandle<Project>,
307 _: std::path::PathBuf,
308 cx: &mut ViewContext<Self>,
309 ) -> Task<anyhow::Result<()>> {
310 self.handle_save(project, cx)
311 }
312
313 fn reload(
314 &mut self,
315 _: ModelHandle<Project>,
316 _: &mut ViewContext<Self>,
317 ) -> Task<anyhow::Result<()>> {
318 unreachable!("reload should not have been called")
319 }
320
321 fn clone_on_split(
322 &self,
323 _workspace_id: workspace::WorkspaceId,
324 cx: &mut ViewContext<Self>,
325 ) -> Option<Self>
326 where
327 Self: Sized,
328 {
329 let buffer = self
330 .editor
331 .read(cx)
332 .buffer()
333 .read(cx)
334 .as_singleton()
335 .expect("Feedback buffer is only ever singleton");
336
337 Some(Self::new_with_buffer(
338 self.project.clone(),
339 buffer.clone(),
340 cx,
341 ))
342 }
343
344 fn serialized_item_kind() -> Option<&'static str> {
345 None
346 }
347
348 fn deserialize(
349 _: ModelHandle<Project>,
350 _: WeakViewHandle<Workspace>,
351 _: workspace::WorkspaceId,
352 _: workspace::ItemId,
353 _: &mut ViewContext<workspace::Pane>,
354 ) -> Task<anyhow::Result<ViewHandle<Self>>> {
355 unreachable!()
356 }
357
358 fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
359 Some(Box::new(handle.clone()))
360 }
361
362 fn act_as_type(
363 &self,
364 type_id: TypeId,
365 self_handle: &ViewHandle<Self>,
366 _: &AppContext,
367 ) -> Option<AnyViewHandle> {
368 if type_id == TypeId::of::<Self>() {
369 Some(self_handle.into())
370 } else if type_id == TypeId::of::<Editor>() {
371 Some((&self.editor).into())
372 } else {
373 None
374 }
375 }
376}
377
378impl SearchableItem for FeedbackEditor {
379 type Match = Range<Anchor>;
380
381 fn to_search_event(event: &Self::Event) -> Option<workspace::searchable::SearchEvent> {
382 Editor::to_search_event(event)
383 }
384
385 fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
386 self.editor
387 .update(cx, |editor, cx| editor.clear_matches(cx))
388 }
389
390 fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
391 self.editor
392 .update(cx, |editor, cx| editor.update_matches(matches, cx))
393 }
394
395 fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
396 self.editor
397 .update(cx, |editor, cx| editor.query_suggestion(cx))
398 }
399
400 fn activate_match(
401 &mut self,
402 index: usize,
403 matches: Vec<Self::Match>,
404 cx: &mut ViewContext<Self>,
405 ) {
406 self.editor
407 .update(cx, |editor, cx| editor.activate_match(index, matches, cx))
408 }
409
410 fn find_matches(
411 &mut self,
412 query: project::search::SearchQuery,
413 cx: &mut ViewContext<Self>,
414 ) -> Task<Vec<Self::Match>> {
415 self.editor
416 .update(cx, |editor, cx| editor.find_matches(query, cx))
417 }
418
419 fn active_match_index(
420 &mut self,
421 matches: Vec<Self::Match>,
422 cx: &mut ViewContext<Self>,
423 ) -> Option<usize> {
424 self.editor
425 .update(cx, |editor, cx| editor.active_match_index(matches, cx))
426 }
427}