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