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 AppState, 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(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
47 cx.add_action({
48 move |workspace: &mut Workspace, _: &GiveFeedback, cx: &mut ViewContext<Workspace>| {
49 FeedbackEditor::deploy(workspace, app_state.clone(), cx);
50 }
51 });
52}
53
54pub struct FeedbackButton;
55
56impl Entity for FeedbackButton {
57 type Event = ();
58}
59
60impl View for FeedbackButton {
61 fn ui_name() -> &'static str {
62 "FeedbackButton"
63 }
64
65 fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
66 Stack::new()
67 .with_child(
68 MouseEventHandler::<Self>::new(0, cx, |state, cx| {
69 let theme = &cx.global::<Settings>().theme;
70 let theme = &theme.workspace.status_bar.feedback;
71
72 Text::new(
73 "Give Feedback".to_string(),
74 theme.style_for(state, true).clone(),
75 )
76 .boxed()
77 })
78 .with_cursor_style(CursorStyle::PointingHand)
79 .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(GiveFeedback))
80 .boxed(),
81 )
82 .boxed()
83 }
84}
85
86impl StatusItemView for FeedbackButton {
87 fn set_active_pane_item(&mut self, _: Option<&dyn ItemHandle>, _: &mut ViewContext<Self>) {}
88}
89
90#[derive(Serialize)]
91struct FeedbackRequestBody<'a> {
92 feedback_text: &'a str,
93 metrics_id: Option<Arc<str>>,
94 system_specs: SystemSpecs,
95 token: &'a str,
96}
97
98#[derive(Clone)]
99struct FeedbackEditor {
100 editor: ViewHandle<Editor>,
101 project: ModelHandle<Project>,
102}
103
104impl FeedbackEditor {
105 fn new_with_buffer(
106 project: ModelHandle<Project>,
107 buffer: ModelHandle<Buffer>,
108 cx: &mut ViewContext<Self>,
109 ) -> Self {
110 let editor = cx.add_view(|cx| {
111 let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx);
112 editor.set_vertical_scroll_margin(5, cx);
113 editor.set_placeholder_text(FEEDBACK_PLACEHOLDER_TEXT, cx);
114 editor
115 });
116
117 cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone()))
118 .detach();
119
120 Self { editor, project }
121 }
122
123 fn new(
124 project: ModelHandle<Project>,
125 buffer: ModelHandle<Buffer>,
126 cx: &mut ViewContext<Self>,
127 ) -> Self {
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(
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 = cx.add_view(|cx| FeedbackEditor::new(project, buffer, cx));
254 workspace.add_item(Box::new(feedback_editor), cx);
255 })
256 .detach();
257 }
258}
259
260impl View for FeedbackEditor {
261 fn ui_name() -> &'static str {
262 "FeedbackEditor"
263 }
264
265 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
266 ChildView::new(&self.editor, cx).boxed()
267 }
268
269 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
270 if cx.is_self_focused() {
271 cx.focus(&self.editor);
272 }
273 }
274}
275
276impl Entity for FeedbackEditor {
277 type Event = editor::Event;
278}
279
280impl Item for FeedbackEditor {
281 fn tab_content(&self, _: Option<usize>, style: &theme::Tab, _: &AppContext) -> ElementBox {
282 Flex::row()
283 .with_child(
284 Label::new("Feedback".to_string(), style.label.clone())
285 .aligned()
286 .contained()
287 .boxed(),
288 )
289 .boxed()
290 }
291
292 fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
293 self.editor.for_each_project_item(cx, f)
294 }
295
296 fn to_item_events(_: &Self::Event) -> Vec<workspace::item::ItemEvent> {
297 Vec::new()
298 }
299
300 fn is_singleton(&self, _: &AppContext) -> bool {
301 true
302 }
303
304 fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
305
306 fn can_save(&self, _: &AppContext) -> bool {
307 true
308 }
309
310 fn save(
311 &mut self,
312 project: ModelHandle<Project>,
313 cx: &mut ViewContext<Self>,
314 ) -> Task<anyhow::Result<()>> {
315 self.handle_save(project, cx)
316 }
317
318 fn save_as(
319 &mut self,
320 project: ModelHandle<Project>,
321 _: std::path::PathBuf,
322 cx: &mut ViewContext<Self>,
323 ) -> Task<anyhow::Result<()>> {
324 self.handle_save(project, cx)
325 }
326
327 fn reload(
328 &mut self,
329 _: ModelHandle<Project>,
330 _: &mut ViewContext<Self>,
331 ) -> Task<anyhow::Result<()>> {
332 unreachable!("reload should not have been called")
333 }
334
335 fn clone_on_split(
336 &self,
337 _workspace_id: workspace::WorkspaceId,
338 cx: &mut ViewContext<Self>,
339 ) -> Option<Self>
340 where
341 Self: Sized,
342 {
343 let buffer = self
344 .editor
345 .read(cx)
346 .buffer()
347 .read(cx)
348 .as_singleton()
349 .expect("Feedback buffer is only ever singleton");
350
351 Some(Self::new_with_buffer(
352 self.project.clone(),
353 buffer.clone(),
354 cx,
355 ))
356 }
357
358 fn serialized_item_kind() -> Option<&'static str> {
359 None
360 }
361
362 fn deserialize(
363 _: ModelHandle<Project>,
364 _: WeakViewHandle<Workspace>,
365 _: workspace::WorkspaceId,
366 _: workspace::ItemId,
367 _: &mut ViewContext<workspace::Pane>,
368 ) -> Task<anyhow::Result<ViewHandle<Self>>> {
369 unreachable!()
370 }
371
372 fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
373 Some(Box::new(handle.clone()))
374 }
375
376 fn act_as_type(
377 &self,
378 type_id: TypeId,
379 self_handle: &ViewHandle<Self>,
380 _: &AppContext,
381 ) -> Option<AnyViewHandle> {
382 if type_id == TypeId::of::<Self>() {
383 Some(self_handle.into())
384 } else if type_id == TypeId::of::<Editor>() {
385 Some((&self.editor).into())
386 } else {
387 None
388 }
389 }
390}
391
392impl SearchableItem for FeedbackEditor {
393 type Match = Range<Anchor>;
394
395 fn to_search_event(event: &Self::Event) -> Option<workspace::searchable::SearchEvent> {
396 Editor::to_search_event(event)
397 }
398
399 fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
400 self.editor
401 .update(cx, |editor, cx| editor.clear_matches(cx))
402 }
403
404 fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
405 self.editor
406 .update(cx, |editor, cx| editor.update_matches(matches, cx))
407 }
408
409 fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
410 self.editor
411 .update(cx, |editor, cx| editor.query_suggestion(cx))
412 }
413
414 fn activate_match(
415 &mut self,
416 index: usize,
417 matches: Vec<Self::Match>,
418 cx: &mut ViewContext<Self>,
419 ) {
420 self.editor
421 .update(cx, |editor, cx| editor.activate_match(index, matches, cx))
422 }
423
424 fn find_matches(
425 &mut self,
426 query: project::search::SearchQuery,
427 cx: &mut ViewContext<Self>,
428 ) -> Task<Vec<Self::Match>> {
429 self.editor
430 .update(cx, |editor, cx| editor.find_matches(query, cx))
431 }
432
433 fn active_match_index(
434 &mut self,
435 matches: Vec<Self::Match>,
436 cx: &mut ViewContext<Self>,
437 ) -> Option<usize> {
438 self.editor
439 .update(cx, |editor, cx| editor.active_match_index(matches, cx))
440 }
441}