1use gpui::{
34 App, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement,
35 KeyDownEvent, ParentElement, Render, SharedString, Styled, Window, div, prelude::FluentBuilder,
36};
37use gpui_kit_semantics::{NodeSpec, Role, Semantic};
38use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, TypeScale};
39
40use crate::controls::button::{Button, ButtonVariant};
41use crate::display::badge::Tone;
42use crate::display::description_list::{DescriptionItem, DescriptionList};
43use crate::display::status::StatusLine;
44use crate::foundation::{Ident, StyledExt, text};
45use crate::strings::{ActiveStrings, StringKey};
46
47#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum AlwaysScope {
54 Session,
57 Tool(SharedString),
59 Path(SharedString),
61 Host(SharedString),
63}
64
65impl AlwaysScope {
66 pub fn tool(name: impl Into<SharedString>) -> Self {
67 Self::Tool(name.into())
68 }
69
70 pub fn path(path: impl Into<SharedString>) -> Self {
71 Self::Path(path.into())
72 }
73
74 pub fn host(host: impl Into<SharedString>) -> Self {
75 Self::Host(host.into())
76 }
77
78 pub fn name(&self) -> &'static str {
81 match self {
82 Self::Session => "session",
83 Self::Tool(_) => "tool",
84 Self::Path(_) => "path",
85 Self::Host(_) => "host",
86 }
87 }
88
89 pub fn subject(&self) -> Option<&SharedString> {
91 match self {
92 Self::Session => None,
93 Self::Tool(name) | Self::Path(name) | Self::Host(name) => Some(name),
94 }
95 }
96
97 pub fn label(&self, cx: &App) -> SharedString {
100 let strings = cx.strings();
101 match self {
102 Self::Session => strings.text(StringKey::ApprovalAlwaysSession),
103 Self::Tool(name) => strings.format(StringKey::ApprovalAlwaysTool, &[name]),
104 Self::Path(path) => strings.format(StringKey::ApprovalAlwaysPath, &[path]),
105 Self::Host(host) => strings.format(StringKey::ApprovalAlwaysHost, &[host]),
106 }
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum ApprovalDecision {
113 Once,
115 Always(AlwaysScope),
117}
118
119impl ApprovalDecision {
120 pub fn label(&self, cx: &App) -> SharedString {
123 match self {
124 Self::Once => cx.strings().text(StringKey::ApprovalOnceScope),
125 Self::Always(scope) => scope.label(cx),
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Default)]
135pub enum ApprovalStatus {
136 #[default]
138 Pending,
139 Declined,
141 Approved(ApprovalDecision),
143 Expired,
145 Superseded { by: SharedString },
148}
149
150impl ApprovalStatus {
151 pub fn name(&self) -> &'static str {
153 match self {
154 Self::Pending => "pending",
155 Self::Declined => "declined",
156 Self::Approved(_) => "approved",
157 Self::Expired => "expired",
158 Self::Superseded { .. } => "superseded",
159 }
160 }
161
162 fn is_pending(&self) -> bool {
163 matches!(self, Self::Pending)
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum ApprovalEvent {
170 Approved(ApprovalDecision),
172 Declined,
174}
175
176impl EventEmitter<ApprovalEvent> for ApprovalPrompt {}
177
178pub struct ApprovalPrompt {
183 ident: Ident,
184 focus_handle: FocusHandle,
185 decline_focus: FocusHandle,
186 approve_focus: FocusHandle,
187 always_focus: Vec<FocusHandle>,
188 action: SharedString,
192 details: Vec<DescriptionItem>,
193 always: Vec<AlwaysScope>,
194 status: ApprovalStatus,
195 pending_focus: bool,
197}
198
199impl std::fmt::Debug for ApprovalPrompt {
200 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 formatter
202 .debug_struct("ApprovalPrompt")
203 .field("ident", &self.ident)
204 .field("action", &self.action)
205 .field("details", &self.details.len())
206 .field("always", &self.always)
207 .field("status", &self.status)
208 .finish()
209 }
210}
211
212impl ApprovalPrompt {
213 pub fn new(
216 ident: impl Into<Ident>,
217 action: impl Into<SharedString>,
218 _window: &mut Window,
219 cx: &mut Context<Self>,
220 ) -> Self {
221 Self {
222 ident: ident.into(),
223 focus_handle: cx.focus_handle(),
224 decline_focus: cx.focus_handle(),
225 approve_focus: cx.focus_handle(),
226 always_focus: Vec::new(),
227 action: action.into(),
228 details: Vec::new(),
229 always: Vec::new(),
230 status: ApprovalStatus::Pending,
231 pending_focus: true,
232 }
233 }
234
235 pub fn detail(mut self, detail: DescriptionItem) -> Self {
238 self.details.push(detail);
239 self
240 }
241
242 pub fn details(mut self, details: impl IntoIterator<Item = DescriptionItem>) -> Self {
243 self.details.extend(details);
244 self
245 }
246
247 pub fn always(mut self, scope: AlwaysScope) -> Self {
250 self.always.push(scope);
251 self
252 }
253
254 pub fn status(mut self, status: ApprovalStatus) -> Self {
255 self.status = status;
256 self
257 }
258
259 pub fn current_status(&self) -> &ApprovalStatus {
260 &self.status
261 }
262
263 pub fn set_status(&mut self, status: ApprovalStatus, cx: &mut Context<Self>) {
267 self.status = status;
268 cx.notify();
269 }
270
271 pub fn approve(&mut self, decision: ApprovalDecision, cx: &mut Context<Self>) {
274 if !self.status.is_pending() {
275 return;
276 }
277 cx.emit(ApprovalEvent::Approved(decision));
278 }
279
280 pub fn decline(&mut self, cx: &mut Context<Self>) {
281 if !self.status.is_pending() {
282 return;
283 }
284 cx.emit(ApprovalEvent::Declined);
285 }
286
287 fn stops(&self) -> Vec<FocusHandle> {
290 let mut stops = vec![self.decline_focus.clone(), self.approve_focus.clone()];
291 stops.extend(self.always_focus.iter().cloned());
292 stops
293 }
294
295 fn step_focus(&mut self, back: bool, window: &mut Window, cx: &mut Context<Self>) {
301 let stops = self.stops();
302 let at = stops
303 .iter()
304 .position(|handle| handle.is_focused(window))
305 .map(|at| {
306 if back {
307 (at + stops.len() - 1) % stops.len()
308 } else {
309 (at + 1) % stops.len()
310 }
311 })
312 .unwrap_or(0);
313 stops[at].clone().focus(window, cx);
314 }
315
316 fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
319 if !self.status.is_pending() {
320 return;
321 }
322 if event.keystroke.key.as_str() == "tab" {
323 self.step_focus(event.keystroke.modifiers.shift, window, cx);
324 cx.stop_propagation();
325 return;
326 }
327 match event.keystroke.key.as_str() {
328 "escape" => {
329 self.decline(cx);
330 cx.stop_propagation();
331 }
332 "enter" => {
333 if self.approve_focus.is_focused(window) {
334 self.approve(ApprovalDecision::Once, cx);
335 } else if let Some(scope) = self
336 .always_focus
337 .iter()
338 .position(|handle| handle.is_focused(window))
339 .and_then(|index| self.always.get(index).cloned())
340 {
341 self.approve(ApprovalDecision::Always(scope), cx);
342 } else {
343 self.decline(cx);
348 }
349 cx.stop_propagation();
350 }
351 _ => {}
352 }
353 }
354
355 fn outcome(&self, cx: &App) -> Option<(SharedString, Tone)> {
356 let strings = cx.strings();
357 match &self.status {
358 ApprovalStatus::Pending => None,
359 ApprovalStatus::Declined => {
360 Some((strings.text(StringKey::ApprovalDeclined), Tone::Danger))
361 }
362 ApprovalStatus::Approved(decision) => Some((
363 strings.format(StringKey::ApprovalApproved, &[&decision.label(cx)]),
364 Tone::Success,
365 )),
366 ApprovalStatus::Expired => {
367 Some((strings.text(StringKey::ApprovalExpired), Tone::Warning))
368 }
369 ApprovalStatus::Superseded { by } => Some((
370 strings.format(StringKey::ApprovalSuperseded, &[by]),
371 Tone::Neutral,
372 )),
373 }
374 }
375}
376
377impl Focusable for ApprovalPrompt {
378 fn focus_handle(&self, _cx: &App) -> FocusHandle {
379 self.focus_handle.clone()
380 }
381}
382
383impl Render for ApprovalPrompt {
384 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
385 let theme = cx.theme().clone();
386 let pending = self.status.is_pending();
387
388 while self.always_focus.len() < self.always.len() {
389 self.always_focus.push(cx.focus_handle());
390 }
391
392 if pending && self.pending_focus {
393 self.pending_focus = false;
396 self.decline_focus.clone().focus(window, cx);
397 }
398
399 let outcome = self.outcome(cx);
400 let prompt = cx.entity().downgrade();
401
402 let decline = pending.then(|| {
403 let prompt = prompt.clone();
404 Button::new(self.ident.child("decline"))
405 .label(cx.strings().text(StringKey::ApprovalDecline))
406 .secondary()
407 .semantic_parent(self.ident.semantic_id())
408 .track_focus(&self.decline_focus)
409 .on_click(move |_window, cx| {
410 prompt.update(cx, |prompt, cx| prompt.decline(cx)).ok();
411 })
412 });
413
414 let approve = pending.then(|| {
415 let prompt = prompt.clone();
416 Button::new(self.ident.child("approve"))
417 .label(cx.strings().text(StringKey::ApprovalApproveOnce))
418 .variant(ButtonVariant::Primary)
419 .semantic_parent(self.ident.semantic_id())
420 .track_focus(&self.approve_focus)
421 .on_click(move |_window, cx| {
422 prompt
423 .update(cx, |prompt, cx| prompt.approve(ApprovalDecision::Once, cx))
424 .ok();
425 })
426 });
427
428 let always: Vec<_> = if pending {
429 self.always
430 .iter()
431 .zip(self.always_focus.iter())
432 .map(|(scope, handle)| {
433 let prompt = prompt.clone();
434 let chosen = scope.clone();
435 Button::new(self.ident.child("always").child(scope.name()))
436 .label(scope.label(cx))
437 .ghost()
438 .semantic_parent(self.ident.semantic_id())
439 .track_focus(handle)
440 .on_click(move |_window, cx| {
441 let chosen = chosen.clone();
442 prompt
443 .update(cx, |prompt, cx| {
444 prompt.approve(ApprovalDecision::Always(chosen), cx)
445 })
446 .ok();
447 })
448 })
449 .collect()
450 } else {
451 Vec::new()
452 };
453
454 let details = (!self.details.is_empty())
455 .then(|| DescriptionList::new(self.ident.child("detail")).items(self.details.clone()));
456
457 let spec = NodeSpec::new(self.ident.semantic_id(), Role::Form)
458 .text(self.action.clone())
459 .value(SharedString::new_static(self.status.name()))
460 .focus(&self.focus_handle);
461
462 div()
463 .column()
464 .w_full()
465 .gap_token(&theme, Space::Md)
466 .p_token(&theme, Space::Lg)
467 .radius(&theme, Radius::Card)
468 .frame(&theme, gpui_kit_theme::Surface::Raised, Elevation::Raised)
469 .track_focus(&self.focus_handle)
470 .when(pending, |element| {
471 element.on_key_down(cx.listener(Self::on_key))
472 })
473 .child(
474 text(&theme, TypeScale::Body, self.action.clone()).semantic_in(
475 cx,
476 NodeSpec::new(self.ident.child("action").semantic_id(), Role::Text)
477 .text(self.action.clone())
478 .parent(self.ident.semantic_id()),
479 ),
480 )
481 .children(details)
482 .when_some(outcome, |element, (text, tone)| {
483 element
484 .child(div().child(StatusLine::new(text, tone).id(self.ident.child("outcome"))))
485 })
486 .when(pending, |element| {
487 element.child(
488 div()
489 .column()
490 .gap_token(&theme, Space::Sm)
491 .child(
492 div()
493 .row()
494 .gap_token(&theme, Space::Sm)
495 .children(decline)
496 .children(approve),
497 )
498 .when(!always.is_empty(), |element| {
499 element.child(
500 div()
501 .flex()
502 .flex_row()
503 .flex_wrap()
504 .gap_token(&theme, Space::Sm)
505 .children(always),
506 )
507 }),
508 )
509 })
510 .semantic_in(cx, spec)
511 }
512}