use gpui::{
App, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement,
KeyDownEvent, ParentElement, Render, SharedString, Styled, Window, div, prelude::FluentBuilder,
};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, TypeScale};
use crate::controls::button::{Button, ButtonVariant};
use crate::display::badge::Tone;
use crate::display::description_list::{DescriptionItem, DescriptionList};
use crate::display::status::StatusLine;
use crate::foundation::{Ident, StyledExt, text};
use crate::strings::{ActiveStrings, StringKey};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AlwaysScope {
Session,
Tool(SharedString),
Path(SharedString),
Host(SharedString),
}
impl AlwaysScope {
pub fn tool(name: impl Into<SharedString>) -> Self {
Self::Tool(name.into())
}
pub fn path(path: impl Into<SharedString>) -> Self {
Self::Path(path.into())
}
pub fn host(host: impl Into<SharedString>) -> Self {
Self::Host(host.into())
}
pub fn name(&self) -> &'static str {
match self {
Self::Session => "session",
Self::Tool(_) => "tool",
Self::Path(_) => "path",
Self::Host(_) => "host",
}
}
pub fn subject(&self) -> Option<&SharedString> {
match self {
Self::Session => None,
Self::Tool(name) | Self::Path(name) | Self::Host(name) => Some(name),
}
}
pub fn label(&self, cx: &App) -> SharedString {
let strings = cx.strings();
match self {
Self::Session => strings.text(StringKey::ApprovalAlwaysSession),
Self::Tool(name) => strings.format(StringKey::ApprovalAlwaysTool, &[name]),
Self::Path(path) => strings.format(StringKey::ApprovalAlwaysPath, &[path]),
Self::Host(host) => strings.format(StringKey::ApprovalAlwaysHost, &[host]),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalDecision {
Once,
Always(AlwaysScope),
}
impl ApprovalDecision {
pub fn label(&self, cx: &App) -> SharedString {
match self {
Self::Once => cx.strings().text(StringKey::ApprovalOnceScope),
Self::Always(scope) => scope.label(cx),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ApprovalStatus {
#[default]
Pending,
Declined,
Approved(ApprovalDecision),
Expired,
Superseded { by: SharedString },
}
impl ApprovalStatus {
pub fn name(&self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Declined => "declined",
Self::Approved(_) => "approved",
Self::Expired => "expired",
Self::Superseded { .. } => "superseded",
}
}
fn is_pending(&self) -> bool {
matches!(self, Self::Pending)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalEvent {
Approved(ApprovalDecision),
Declined,
}
impl EventEmitter<ApprovalEvent> for ApprovalPrompt {}
pub struct ApprovalPrompt {
ident: Ident,
focus_handle: FocusHandle,
decline_focus: FocusHandle,
approve_focus: FocusHandle,
always_focus: Vec<FocusHandle>,
action: SharedString,
details: Vec<DescriptionItem>,
always: Vec<AlwaysScope>,
status: ApprovalStatus,
pending_focus: bool,
}
impl std::fmt::Debug for ApprovalPrompt {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ApprovalPrompt")
.field("ident", &self.ident)
.field("action", &self.action)
.field("details", &self.details.len())
.field("always", &self.always)
.field("status", &self.status)
.finish()
}
}
impl ApprovalPrompt {
pub fn new(
ident: impl Into<Ident>,
action: impl Into<SharedString>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
ident: ident.into(),
focus_handle: cx.focus_handle(),
decline_focus: cx.focus_handle(),
approve_focus: cx.focus_handle(),
always_focus: Vec::new(),
action: action.into(),
details: Vec::new(),
always: Vec::new(),
status: ApprovalStatus::Pending,
pending_focus: true,
}
}
pub fn detail(mut self, detail: DescriptionItem) -> Self {
self.details.push(detail);
self
}
pub fn details(mut self, details: impl IntoIterator<Item = DescriptionItem>) -> Self {
self.details.extend(details);
self
}
pub fn always(mut self, scope: AlwaysScope) -> Self {
self.always.push(scope);
self
}
pub fn status(mut self, status: ApprovalStatus) -> Self {
self.status = status;
self
}
pub fn current_status(&self) -> &ApprovalStatus {
&self.status
}
pub fn set_status(&mut self, status: ApprovalStatus, cx: &mut Context<Self>) {
self.status = status;
cx.notify();
}
pub fn approve(&mut self, decision: ApprovalDecision, cx: &mut Context<Self>) {
if !self.status.is_pending() {
return;
}
cx.emit(ApprovalEvent::Approved(decision));
}
pub fn decline(&mut self, cx: &mut Context<Self>) {
if !self.status.is_pending() {
return;
}
cx.emit(ApprovalEvent::Declined);
}
fn stops(&self) -> Vec<FocusHandle> {
let mut stops = vec![self.decline_focus.clone(), self.approve_focus.clone()];
stops.extend(self.always_focus.iter().cloned());
stops
}
fn step_focus(&mut self, back: bool, window: &mut Window, cx: &mut Context<Self>) {
let stops = self.stops();
let at = stops
.iter()
.position(|handle| handle.is_focused(window))
.map(|at| {
if back {
(at + stops.len() - 1) % stops.len()
} else {
(at + 1) % stops.len()
}
})
.unwrap_or(0);
stops[at].clone().focus(window, cx);
}
fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
if !self.status.is_pending() {
return;
}
if event.keystroke.key.as_str() == "tab" {
self.step_focus(event.keystroke.modifiers.shift, window, cx);
cx.stop_propagation();
return;
}
match event.keystroke.key.as_str() {
"escape" => {
self.decline(cx);
cx.stop_propagation();
}
"enter" => {
if self.approve_focus.is_focused(window) {
self.approve(ApprovalDecision::Once, cx);
} else if let Some(scope) = self
.always_focus
.iter()
.position(|handle| handle.is_focused(window))
.and_then(|index| self.always.get(index).cloned())
{
self.approve(ApprovalDecision::Always(scope), cx);
} else {
self.decline(cx);
}
cx.stop_propagation();
}
_ => {}
}
}
fn outcome(&self, cx: &App) -> Option<(SharedString, Tone)> {
let strings = cx.strings();
match &self.status {
ApprovalStatus::Pending => None,
ApprovalStatus::Declined => {
Some((strings.text(StringKey::ApprovalDeclined), Tone::Danger))
}
ApprovalStatus::Approved(decision) => Some((
strings.format(StringKey::ApprovalApproved, &[&decision.label(cx)]),
Tone::Success,
)),
ApprovalStatus::Expired => {
Some((strings.text(StringKey::ApprovalExpired), Tone::Warning))
}
ApprovalStatus::Superseded { by } => Some((
strings.format(StringKey::ApprovalSuperseded, &[by]),
Tone::Neutral,
)),
}
}
}
impl Focusable for ApprovalPrompt {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ApprovalPrompt {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme().clone();
let pending = self.status.is_pending();
while self.always_focus.len() < self.always.len() {
self.always_focus.push(cx.focus_handle());
}
if pending && self.pending_focus {
self.pending_focus = false;
self.decline_focus.clone().focus(window, cx);
}
let outcome = self.outcome(cx);
let prompt = cx.entity().downgrade();
let decline = pending.then(|| {
let prompt = prompt.clone();
Button::new(self.ident.child("decline"))
.label(cx.strings().text(StringKey::ApprovalDecline))
.secondary()
.semantic_parent(self.ident.semantic_id())
.track_focus(&self.decline_focus)
.on_click(move |_window, cx| {
prompt.update(cx, |prompt, cx| prompt.decline(cx)).ok();
})
});
let approve = pending.then(|| {
let prompt = prompt.clone();
Button::new(self.ident.child("approve"))
.label(cx.strings().text(StringKey::ApprovalApproveOnce))
.variant(ButtonVariant::Primary)
.semantic_parent(self.ident.semantic_id())
.track_focus(&self.approve_focus)
.on_click(move |_window, cx| {
prompt
.update(cx, |prompt, cx| prompt.approve(ApprovalDecision::Once, cx))
.ok();
})
});
let always: Vec<_> = if pending {
self.always
.iter()
.zip(self.always_focus.iter())
.map(|(scope, handle)| {
let prompt = prompt.clone();
let chosen = scope.clone();
Button::new(self.ident.child("always").child(scope.name()))
.label(scope.label(cx))
.ghost()
.semantic_parent(self.ident.semantic_id())
.track_focus(handle)
.on_click(move |_window, cx| {
let chosen = chosen.clone();
prompt
.update(cx, |prompt, cx| {
prompt.approve(ApprovalDecision::Always(chosen), cx)
})
.ok();
})
})
.collect()
} else {
Vec::new()
};
let details = (!self.details.is_empty())
.then(|| DescriptionList::new(self.ident.child("detail")).items(self.details.clone()));
let spec = NodeSpec::new(self.ident.semantic_id(), Role::Form)
.text(self.action.clone())
.value(SharedString::new_static(self.status.name()))
.focus(&self.focus_handle);
div()
.column()
.w_full()
.gap_token(&theme, Space::Md)
.p_token(&theme, Space::Lg)
.radius(&theme, Radius::Card)
.frame(&theme, gpui_kit_theme::Surface::Raised, Elevation::Raised)
.track_focus(&self.focus_handle)
.when(pending, |element| {
element.on_key_down(cx.listener(Self::on_key))
})
.child(
text(&theme, TypeScale::Body, self.action.clone()).semantic_in(
cx,
NodeSpec::new(self.ident.child("action").semantic_id(), Role::Text)
.text(self.action.clone())
.parent(self.ident.semantic_id()),
),
)
.children(details)
.when_some(outcome, |element, (text, tone)| {
element
.child(div().child(StatusLine::new(text, tone).id(self.ident.child("outcome"))))
})
.when(pending, |element| {
element.child(
div()
.column()
.gap_token(&theme, Space::Sm)
.child(
div()
.row()
.gap_token(&theme, Space::Sm)
.children(decline)
.children(approve),
)
.when(!always.is_empty(), |element| {
element.child(
div()
.flex()
.flex_row()
.flex_wrap()
.gap_token(&theme, Space::Sm)
.children(always),
)
}),
)
})
.semantic_in(cx, spec)
}
}