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(
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 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 = SystemSpecs::new(cx);
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 workspace: &mut Workspace,
233 app_state: Arc<AppState>,
234 cx: &mut ViewContext<Workspace>,
235 ) {
236 workspace
237 .with_local_workspace(&app_state, cx, |workspace, cx| {
238 let project = workspace.project().clone();
239 let markdown_language = project.read(cx).languages().language_for_name("Markdown");
240 let buffer = project
241 .update(cx, |project, cx| {
242 project.create_buffer("", markdown_language, cx)
243 })
244 .expect("creating buffers on a local workspace always succeeds");
245 let feedback_editor = cx.add_view(|cx| FeedbackEditor::new(project, buffer, cx));
246 workspace.add_item(Box::new(feedback_editor), cx);
247 })
248 .detach();
249 }
250}
251
252impl View for FeedbackEditor {
253 fn ui_name() -> &'static str {
254 "FeedbackEditor"
255 }
256
257 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
258 ChildView::new(&self.editor, cx).boxed()
259 }
260
261 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
262 if cx.is_self_focused() {
263 cx.focus(&self.editor);
264 }
265 }
266}
267
268impl Entity for FeedbackEditor {
269 type Event = editor::Event;
270}
271
272impl Item for FeedbackEditor {
273 fn tab_content(&self, _: Option<usize>, style: &theme::Tab, _: &AppContext) -> ElementBox {
274 Flex::row()
275 .with_child(
276 Label::new("Feedback".to_string(), style.label.clone())
277 .aligned()
278 .contained()
279 .boxed(),
280 )
281 .boxed()
282 }
283
284 fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
285 self.editor.for_each_project_item(cx, f)
286 }
287
288 fn to_item_events(_: &Self::Event) -> Vec<workspace::item::ItemEvent> {
289 Vec::new()
290 }
291
292 fn is_singleton(&self, _: &AppContext) -> bool {
293 true
294 }
295
296 fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
297
298 fn can_save(&self, _: &AppContext) -> bool {
299 true
300 }
301
302 fn save(
303 &mut self,
304 project: ModelHandle<Project>,
305 cx: &mut ViewContext<Self>,
306 ) -> Task<anyhow::Result<()>> {
307 self.handle_save(project, cx)
308 }
309
310 fn save_as(
311 &mut self,
312 project: ModelHandle<Project>,
313 _: std::path::PathBuf,
314 cx: &mut ViewContext<Self>,
315 ) -> Task<anyhow::Result<()>> {
316 self.handle_save(project, cx)
317 }
318
319 fn reload(
320 &mut self,
321 _: ModelHandle<Project>,
322 _: &mut ViewContext<Self>,
323 ) -> Task<anyhow::Result<()>> {
324 unreachable!("reload should not have been called")
325 }
326
327 fn clone_on_split(
328 &self,
329 _workspace_id: workspace::WorkspaceId,
330 cx: &mut ViewContext<Self>,
331 ) -> Option<Self>
332 where
333 Self: Sized,
334 {
335 let buffer = self
336 .editor
337 .read(cx)
338 .buffer()
339 .read(cx)
340 .as_singleton()
341 .expect("Feedback buffer is only ever singleton");
342
343 Some(Self::new(self.project.clone(), buffer.clone(), cx))
344 }
345
346 fn serialized_item_kind() -> Option<&'static str> {
347 None
348 }
349
350 fn deserialize(
351 _: ModelHandle<Project>,
352 _: WeakViewHandle<Workspace>,
353 _: workspace::WorkspaceId,
354 _: workspace::ItemId,
355 _: &mut ViewContext<workspace::Pane>,
356 ) -> Task<anyhow::Result<ViewHandle<Self>>> {
357 unreachable!()
358 }
359
360 fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
361 Some(Box::new(handle.clone()))
362 }
363
364 fn act_as_type(
365 &self,
366 type_id: TypeId,
367 self_handle: &ViewHandle<Self>,
368 _: &AppContext,
369 ) -> Option<AnyViewHandle> {
370 if type_id == TypeId::of::<Self>() {
371 Some(self_handle.into())
372 } else if type_id == TypeId::of::<Editor>() {
373 Some((&self.editor).into())
374 } else {
375 None
376 }
377 }
378}
379
380impl SearchableItem for FeedbackEditor {
381 type Match = Range<Anchor>;
382
383 fn to_search_event(event: &Self::Event) -> Option<workspace::searchable::SearchEvent> {
384 Editor::to_search_event(event)
385 }
386
387 fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
388 self.editor
389 .update(cx, |editor, cx| editor.clear_matches(cx))
390 }
391
392 fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
393 self.editor
394 .update(cx, |editor, cx| editor.update_matches(matches, cx))
395 }
396
397 fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
398 self.editor
399 .update(cx, |editor, cx| editor.query_suggestion(cx))
400 }
401
402 fn activate_match(
403 &mut self,
404 index: usize,
405 matches: Vec<Self::Match>,
406 cx: &mut ViewContext<Self>,
407 ) {
408 self.editor
409 .update(cx, |editor, cx| editor.activate_match(index, matches, cx))
410 }
411
412 fn find_matches(
413 &mut self,
414 query: project::search::SearchQuery,
415 cx: &mut ViewContext<Self>,
416 ) -> Task<Vec<Self::Match>> {
417 self.editor
418 .update(cx, |editor, cx| editor.find_matches(query, cx))
419 }
420
421 fn active_match_index(
422 &mut self,
423 matches: Vec<Self::Match>,
424 cx: &mut ViewContext<Self>,
425 ) -> Option<usize> {
426 self.editor
427 .update(cx, |editor, cx| editor.active_match_index(matches, cx))
428 }
429}