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, Workspace,
29};
30
31use crate::system_specs::SystemSpecs;
32
33const FEEDBACK_CHAR_LIMIT: RangeInclusive<usize> = 10..=5000;
34const FEEDBACK_PLACEHOLDER_TEXT: &str = "Thanks for spending time with Zed. Enter your feedback here as Markdown. Save the tab to submit your feedback.";
35const FEEDBACK_SUBMISSION_ERROR_TEXT: &str =
36 "Feedback failed to submit, see error log for details.";
37
38actions!(feedback, [SubmitFeedback, GiveFeedback, DeployFeedback]);
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
48pub struct FeedbackButton;
49
50impl Entity for FeedbackButton {
51 type Event = ();
52}
53
54impl View for FeedbackButton {
55 fn ui_name() -> &'static str {
56 "FeedbackButton"
57 }
58
59 fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
60 Stack::new()
61 .with_child(
62 MouseEventHandler::<Self>::new(0, cx, |state, cx| {
63 let theme = &cx.global::<Settings>().theme;
64 let theme = &theme.workspace.status_bar.feedback;
65
66 Text::new(
67 "Give Feedback".to_string(),
68 theme.style_for(state, true).clone(),
69 )
70 .boxed()
71 })
72 .with_cursor_style(CursorStyle::PointingHand)
73 .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(GiveFeedback))
74 .boxed(),
75 )
76 .boxed()
77 }
78}
79
80impl StatusItemView for FeedbackButton {
81 fn set_active_pane_item(&mut self, _: Option<&dyn ItemHandle>, _: &mut ViewContext<Self>) {}
82}
83
84#[derive(Serialize)]
85struct FeedbackRequestBody<'a> {
86 feedback_text: &'a str,
87 metrics_id: Option<Arc<str>>,
88 system_specs: SystemSpecs,
89 token: &'a str,
90}
91
92#[derive(Clone)]
93struct FeedbackEditor {
94 system_specs: SystemSpecs,
95 editor: ViewHandle<Editor>,
96 project: ModelHandle<Project>,
97}
98
99impl FeedbackEditor {
100 fn new(
101 system_specs: SystemSpecs,
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 {
117 system_specs: system_specs.clone(),
118 editor,
119 project,
120 }
121 }
122
123 fn handle_save(
124 &mut self,
125 _: ModelHandle<Project>,
126 cx: &mut ViewContext<Self>,
127 ) -> Task<anyhow::Result<()>> {
128 let feedback_char_count = self.editor.read(cx).text(cx).chars().count();
129
130 let error = if feedback_char_count < *FEEDBACK_CHAR_LIMIT.start() {
131 Some(format!(
132 "Feedback can't be shorter than {} characters.",
133 FEEDBACK_CHAR_LIMIT.start()
134 ))
135 } else if feedback_char_count > *FEEDBACK_CHAR_LIMIT.end() {
136 Some(format!(
137 "Feedback can't be longer than {} characters.",
138 FEEDBACK_CHAR_LIMIT.end()
139 ))
140 } else {
141 None
142 };
143
144 if let Some(error) = error {
145 cx.prompt(PromptLevel::Critical, &error, &["OK"]);
146 return Task::ready(Ok(()));
147 }
148
149 let mut answer = cx.prompt(
150 PromptLevel::Info,
151 "Ready to submit your feedback?",
152 &["Yes, Submit!", "No"],
153 );
154
155 let this = cx.handle();
156 let client = cx.global::<Arc<Client>>().clone();
157 let feedback_text = self.editor.read(cx).text(cx);
158 let specs = self.system_specs.clone();
159
160 cx.spawn(|_, mut cx| async move {
161 let answer = answer.recv().await;
162
163 if answer == Some(0) {
164 match FeedbackEditor::submit_feedback(&feedback_text, client, specs).await {
165 Ok(_) => {
166 cx.update(|cx| {
167 this.update(cx, |_, cx| {
168 cx.dispatch_action(workspace::CloseActiveItem);
169 })
170 });
171 }
172 Err(error) => {
173 log::error!("{}", error);
174
175 cx.update(|cx| {
176 this.update(cx, |_, cx| {
177 cx.prompt(
178 PromptLevel::Critical,
179 FEEDBACK_SUBMISSION_ERROR_TEXT,
180 &["OK"],
181 );
182 })
183 });
184 }
185 }
186 }
187 })
188 .detach();
189
190 Task::ready(Ok(()))
191 }
192
193 async fn submit_feedback(
194 feedback_text: &str,
195 zed_client: Arc<Client>,
196 system_specs: SystemSpecs,
197 ) -> anyhow::Result<()> {
198 let feedback_endpoint = format!("{}/api/feedback", *ZED_SERVER_URL);
199
200 let metrics_id = zed_client.metrics_id();
201 let http_client = zed_client.http_client();
202
203 let request = FeedbackRequestBody {
204 feedback_text: &feedback_text,
205 metrics_id,
206 system_specs,
207 token: ZED_SECRET_CLIENT_TOKEN,
208 };
209
210 let json_bytes = serde_json::to_vec(&request)?;
211
212 let request = Request::post(feedback_endpoint)
213 .header("content-type", "application/json")
214 .body(json_bytes.into())?;
215
216 let mut response = http_client.send(request).await?;
217 let mut body = String::new();
218 response.body_mut().read_to_string(&mut body).await?;
219
220 let response_status = response.status();
221
222 if !response_status.is_success() {
223 bail!("Feedback API failed with error: {}", response_status)
224 }
225
226 Ok(())
227 }
228}
229
230impl FeedbackEditor {
231 pub fn deploy(
232 system_specs: SystemSpecs,
233 workspace: &mut Workspace,
234 app_state: Arc<AppState>,
235 cx: &mut ViewContext<Workspace>,
236 ) {
237 workspace
238 .with_local_workspace(&app_state, cx, |workspace, cx| {
239 let project = workspace.project().clone();
240 let markdown_language = project.read(cx).languages().language_for_name("Markdown");
241 let buffer = project
242 .update(cx, |project, cx| {
243 project.create_buffer("", markdown_language, cx)
244 })
245 .expect("creating buffers on a local workspace always succeeds");
246 let feedback_editor =
247 cx.add_view(|cx| FeedbackEditor::new(system_specs, project, buffer, cx));
248 workspace.add_item(Box::new(feedback_editor), cx);
249 })
250 .detach();
251 }
252}
253
254impl View for FeedbackEditor {
255 fn ui_name() -> &'static str {
256 "FeedbackEditor"
257 }
258
259 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
260 ChildView::new(&self.editor, cx).boxed()
261 }
262
263 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
264 if cx.is_self_focused() {
265 cx.focus(&self.editor);
266 }
267 }
268}
269
270impl Entity for FeedbackEditor {
271 type Event = editor::Event;
272}
273
274impl Item for FeedbackEditor {
275 fn tab_content(&self, _: Option<usize>, style: &theme::Tab, _: &AppContext) -> ElementBox {
276 Flex::row()
277 .with_child(
278 Label::new("Feedback".to_string(), style.label.clone())
279 .aligned()
280 .contained()
281 .boxed(),
282 )
283 .boxed()
284 }
285
286 fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
287 self.editor.for_each_project_item(cx, f)
288 }
289
290 fn to_item_events(_: &Self::Event) -> Vec<workspace::item::ItemEvent> {
291 Vec::new()
292 }
293
294 fn is_singleton(&self, _: &AppContext) -> bool {
295 true
296 }
297
298 fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
299
300 fn can_save(&self, _: &AppContext) -> bool {
301 true
302 }
303
304 fn save(
305 &mut self,
306 project: ModelHandle<Project>,
307 cx: &mut ViewContext<Self>,
308 ) -> Task<anyhow::Result<()>> {
309 self.handle_save(project, cx)
310 }
311
312 fn save_as(
313 &mut self,
314 project: ModelHandle<Project>,
315 _: std::path::PathBuf,
316 cx: &mut ViewContext<Self>,
317 ) -> Task<anyhow::Result<()>> {
318 self.handle_save(project, cx)
319 }
320
321 fn reload(
322 &mut self,
323 _: ModelHandle<Project>,
324 _: &mut ViewContext<Self>,
325 ) -> Task<anyhow::Result<()>> {
326 unreachable!("reload should not have been called")
327 }
328
329 fn clone_on_split(
330 &self,
331 _workspace_id: workspace::WorkspaceId,
332 cx: &mut ViewContext<Self>,
333 ) -> Option<Self>
334 where
335 Self: Sized,
336 {
337 let buffer = self
338 .editor
339 .read(cx)
340 .buffer()
341 .read(cx)
342 .as_singleton()
343 .expect("Feedback buffer is only ever singleton");
344
345 Some(Self::new(
346 self.system_specs.clone(),
347 self.project.clone(),
348 buffer.clone(),
349 cx,
350 ))
351 }
352
353 fn serialized_item_kind() -> Option<&'static str> {
354 None
355 }
356
357 fn deserialize(
358 _: ModelHandle<Project>,
359 _: WeakViewHandle<Workspace>,
360 _: workspace::WorkspaceId,
361 _: workspace::ItemId,
362 _: &mut ViewContext<workspace::Pane>,
363 ) -> Task<anyhow::Result<ViewHandle<Self>>> {
364 unreachable!()
365 }
366
367 fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
368 Some(Box::new(handle.clone()))
369 }
370
371 fn act_as_type(
372 &self,
373 type_id: TypeId,
374 self_handle: &ViewHandle<Self>,
375 _: &AppContext,
376 ) -> Option<AnyViewHandle> {
377 if type_id == TypeId::of::<Self>() {
378 Some(self_handle.into())
379 } else if type_id == TypeId::of::<Editor>() {
380 Some((&self.editor).into())
381 } else {
382 None
383 }
384 }
385}
386
387impl SearchableItem for FeedbackEditor {
388 type Match = Range<Anchor>;
389
390 fn to_search_event(event: &Self::Event) -> Option<workspace::searchable::SearchEvent> {
391 Editor::to_search_event(event)
392 }
393
394 fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
395 self.editor
396 .update(cx, |editor, cx| editor.clear_matches(cx))
397 }
398
399 fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
400 self.editor
401 .update(cx, |editor, cx| editor.update_matches(matches, cx))
402 }
403
404 fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
405 self.editor
406 .update(cx, |editor, cx| editor.query_suggestion(cx))
407 }
408
409 fn activate_match(
410 &mut self,
411 index: usize,
412 matches: Vec<Self::Match>,
413 cx: &mut ViewContext<Self>,
414 ) {
415 self.editor
416 .update(cx, |editor, cx| editor.activate_match(index, matches, cx))
417 }
418
419 fn find_matches(
420 &mut self,
421 query: project::search::SearchQuery,
422 cx: &mut ViewContext<Self>,
423 ) -> Task<Vec<Self::Match>> {
424 self.editor
425 .update(cx, |editor, cx| editor.find_matches(query, cx))
426 }
427
428 fn active_match_index(
429 &mut self,
430 matches: Vec<Self::Match>,
431 cx: &mut ViewContext<Self>,
432 ) -> Option<usize> {
433 self.editor
434 .update(cx, |editor, cx| editor.active_match_index(matches, cx))
435 }
436}