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