1use crate::ButtonLabelExt as _;
4use crate::control::ControlMetricsExt as _;
5use crate::cues::{self, Cue};
6use crate::decoration::{DecoratedExt as _, Decoration};
7use crate::handlers::SharedHandler;
8use crate::motion::acknowledged_state;
9use crate::status::{StatusBadge, StatusTone};
10use crate::surface::{card, description, inset, meta, title};
11use gpui::{
12 AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement,
13 RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
14 Window, div, prelude::FluentBuilder as _,
15};
16use gpui_component::{
17 ActiveTheme as _, Sizable as _, StyledExt as _,
18 button::{Button, ButtonVariants as _},
19 h_flex,
20};
21use std::rc::Rc;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
25pub enum ApprovalDecision {
26 #[default]
28 Pending,
29 Approved,
31 Rejected,
33}
34
35impl ApprovalDecision {
36 pub fn label(self) -> &'static str {
38 match self {
39 Self::Pending => "Pending",
40 Self::Approved => "Approved",
41 Self::Rejected => "Rejected",
42 }
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
48pub enum ApprovalTone {
49 #[default]
51 Default,
52 Destructive,
54}
55
56#[derive(IntoElement)]
75pub struct ApprovalCard {
76 id: SharedString,
77 style: StyleRefinement,
78 decoration: Decoration,
79 title: SharedString,
80 description: Option<SharedString>,
81 approve_label: SharedString,
82 reject_label: SharedString,
83 children: Vec<AnyElement>,
84 decision: ApprovalDecision,
85 tone: ApprovalTone,
86 allow_always: bool,
87 note: Option<SharedString>,
88 on_event: Option<SharedHandler<ApprovalEvent>>,
89}
90
91impl ApprovalCard {
92 pub fn decoration(mut self, decoration: Decoration) -> Self {
98 self.decoration = decoration;
99 self
100 }
101
102 pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
104 Self {
105 id: id.into(),
106 style: StyleRefinement::default(),
107 decoration: Decoration::default(),
108 title: title.into(),
109 description: None,
110 approve_label: "Approve".into(),
111 reject_label: "Reject".into(),
112 children: Vec::new(),
113 decision: ApprovalDecision::Pending,
114 tone: ApprovalTone::Default,
115 allow_always: false,
116 note: None,
117 on_event: None,
118 }
119 }
120
121 pub fn decision(mut self, decision: ApprovalDecision) -> Self {
123 self.decision = decision;
124 self
125 }
126
127 pub fn tone(mut self, tone: ApprovalTone) -> Self {
129 self.tone = tone;
130 self
131 }
132
133 pub fn allow_always(mut self, allow_always: bool) -> Self {
135 self.allow_always = allow_always;
136 self
137 }
138
139 pub fn note(mut self, note: impl Into<SharedString>) -> Self {
141 self.note = Some(note.into());
142 self
143 }
144
145 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
147 self.description = Some(description.into());
148 self
149 }
150
151 pub fn approve_label(mut self, label: impl Into<SharedString>) -> Self {
153 self.approve_label = label.into();
154 self
155 }
156
157 pub fn reject_label(mut self, label: impl Into<SharedString>) -> Self {
159 self.reject_label = label.into();
160 self
161 }
162
163 pub fn on_event(
165 mut self,
166 handler: impl Fn(&ApprovalEvent, &mut Window, &mut App) + 'static,
167 ) -> Self {
168 self.on_event = Some(Rc::new(handler));
169 self
170 }
171}
172
173impl ParentElement for ApprovalCard {
174 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
175 self.children.extend(elements);
176 }
177}
178
179impl Styled for ApprovalCard {
180 fn style(&mut self) -> &mut StyleRefinement {
181 &mut self.style
182 }
183}
184
185impl RenderOnce for ApprovalCard {
186 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
187 let tokens = cx.theme().semantic_tokens();
188 let mut decoration = std::mem::take(&mut self.decoration);
191 let has_payload = !self.children.is_empty();
192 let handler = self.on_event;
193 let approve_event = ApprovalEvent::Approved {
194 id: self.id.clone(),
195 };
196 let reject_event = ApprovalEvent::Rejected {
197 id: self.id.clone(),
198 };
199 let always_event = ApprovalEvent::ApprovedAlways {
200 id: self.id.clone(),
201 };
202 let accessibility_label = self.title.clone();
203 let accessibility_description = self.description.clone();
204 let root_id = ElementId::from(self.id.clone());
205 let debug_id = self.id.to_string();
206 let decided = self.decision != ApprovalDecision::Pending;
207 let border = match (self.decision, self.tone) {
208 (ApprovalDecision::Pending, ApprovalTone::Default) => cx.theme().warning,
209 (ApprovalDecision::Pending, ApprovalTone::Destructive) => cx.theme().danger,
210 _ => cx.theme().border,
211 };
212 let destructive = self.tone == ApprovalTone::Destructive;
213 let approve_debug = debug_id.clone();
214 let reject_debug = debug_id.clone();
215 let always_debug = debug_id.clone();
216 let decision_debug = debug_id.clone();
217 let settled = acknowledged_state(
218 ElementId::from((root_id.clone(), "resolved")),
219 self.decision as u64,
220 window,
221 cx,
222 );
223 let footer: AnyElement = if decided {
224 h_flex()
233 .items_baseline()
234 .gap(tokens.spacing.sm)
235 .opacity(settled)
236 .top(tokens.spacing.xxs * (1.0 - settled) * crate::motion::travel(cx))
237 .child(
238 div()
239 .flex_none()
240 .debug_selector(move || format!("approval-decision-{decision_debug}"))
241 .child(
242 StatusBadge::new((root_id.clone(), "decision"), self.decision.label())
243 .tone(match self.decision {
244 ApprovalDecision::Approved => StatusTone::Success,
245 _ => StatusTone::Neutral,
246 }),
247 ),
248 )
249 .when_some(self.note.clone(), |this, note| {
250 this.child(div().min_w_0().child(meta(note, cx)))
251 })
252 .into_any_element()
253 } else {
254 h_flex()
255 .flex_wrap()
256 .items_center()
257 .gap(tokens.spacing.sm)
258 .when_some(handler.clone(), |this, handler| {
259 this.child(
260 div()
261 .debug_selector(move || format!("approval-approve-{approve_debug}"))
262 .child(
263 Button::new(format!("{}-approve", self.id))
264 .map(|button| {
265 if destructive {
266 button.danger()
267 } else {
268 button.primary()
269 }
270 })
271 .small()
272 .control_metrics(cx)
273 .accessibility_id(format!("{}-approve", self.id))
274 .text_label(self.approve_label.clone())
275 .on_click(move |_: &ClickEvent, window, cx| {
276 cues::emit(cx, Cue::Decided { approved: true });
277 handler(&approve_event, window, cx)
278 }),
279 ),
280 )
281 })
282 .when_some(handler.clone(), |this, handler| {
283 this.child(
284 div()
285 .debug_selector(move || format!("approval-reject-{reject_debug}"))
286 .child(
287 Button::new(format!("{}-reject", self.id))
288 .outline()
289 .small()
290 .control_metrics(cx)
291 .accessibility_id(format!("{}-reject", self.id))
292 .text_label(self.reject_label.clone())
293 .on_click(move |_: &ClickEvent, window, cx| {
294 cues::emit(cx, Cue::Decided { approved: false });
295 handler(&reject_event, window, cx)
296 }),
297 ),
298 )
299 })
300 .when_some(handler.filter(|_| self.allow_always), |this, handler| {
301 this.child(
302 div()
303 .debug_selector(move || format!("approval-always-{always_debug}"))
304 .child(
305 Button::new(format!("{}-always", self.id))
306 .ghost()
307 .small()
308 .control_metrics(cx)
309 .accessibility_id(format!("{}-always", self.id))
310 .text_label("Always allow")
311 .on_click(move |_: &ClickEvent, window, cx| {
312 cues::emit(cx, Cue::Decided { approved: true });
313 handler(&always_event, window, cx)
314 }),
315 ),
316 )
317 })
318 .into_any_element()
319 };
320
321 card(self.id.clone(), cx)
324 .decoration_under(&mut decoration)
325 .role(Role::Group)
326 .aria_label(accessibility_label)
327 .when_some(accessibility_description, |this, description| {
328 this.aria_description(description)
329 })
330 .border_color(border)
331 .when(decided, |this| this.bg(cx.theme().muted.opacity(0.25)))
332 .child(title(self.title, cx))
333 .when_some(self.description, |this, text| {
334 this.child(description(text, cx))
335 })
336 .when(has_payload, |this| {
337 this.child(
338 inset(cx)
339 .flex()
340 .flex_col()
341 .gap(tokens.spacing.xs)
342 .children(self.children),
343 )
344 })
345 .child(footer)
346 .decoration_over(&mut decoration)
347 .refine_style(&self.style)
348 }
349}
350#[derive(Debug, Clone, PartialEq, Eq)]
352pub enum ApprovalEvent {
353 Approved {
355 id: SharedString,
357 },
358 Rejected {
360 id: SharedString,
362 },
363 ApprovedAlways {
365 id: SharedString,
367 },
368}