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