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