1use gpui_kit::{
2 App, AppContext as _, Context, ElementId, Entity, EventEmitter, IntoElement,
3 ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, Subscription, Window,
4 assets::IconName,
5 base::{Align, Selectable, StyledExt as _},
6 div,
7 prelude::FluentBuilder as _,
8 px, rems,
9};
10
11use crate::{
12 ActiveTheme as _, Button, ButtonSize, DropdownMenu, Icon, MenuEntry, MenuEvent, MenuItem,
13 MenuState,
14 collapse::{self, Need},
15 menu::{MenuLook, OpenSlot, label_block, line_slot, row_line},
16};
17
18const MIN_LABEL_CHARS: usize = 6;
23
24const LEARN_MORE: &str = "learn-more";
26
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum PermissionTone {
32 #[default]
34 Normal,
35 Warning,
37}
38
39#[derive(Clone)]
56#[non_exhaustive]
57pub struct PermissionMode {
58 id: SharedString,
59 label: SharedString,
60 description: Option<SharedString>,
61 icon: Option<Icon>,
62 tone: PermissionTone,
63}
64
65impl PermissionMode {
66 pub const AUTO: &'static str = "auto";
68 pub const MANUAL: &'static str = "manual";
70 pub const PLAN: &'static str = "plan";
72 pub const BYPASS: &'static str = "bypass";
74
75 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
77 Self {
78 id: id.into(),
79 label: label.into(),
80 description: None,
81 icon: None,
82 tone: PermissionTone::Normal,
83 }
84 }
85
86 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
88 self.description = Some(description.into());
89 self
90 }
91
92 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
94 self.icon = Some(icon.into());
95 self
96 }
97
98 pub fn tone(mut self, tone: PermissionTone) -> Self {
101 self.tone = tone;
102 self
103 }
104
105 pub fn tone_of(&self) -> PermissionTone {
107 self.tone
108 }
109
110 pub fn id(&self) -> &SharedString {
112 &self.id
113 }
114
115 pub fn label(&self) -> &SharedString {
117 &self.label
118 }
119
120 pub fn description_text(&self) -> Option<&SharedString> {
122 self.description.as_ref()
123 }
124
125 pub fn icon_of(&self) -> Option<&Icon> {
127 self.icon.as_ref()
128 }
129
130 pub fn defaults() -> Vec<Self> {
132 vec![
133 Self::new(Self::AUTO, "Auto")
134 .description("Run safe actions, ask before risky ones")
135 .icon(IconName::Gauge),
136 Self::new(Self::MANUAL, "Manual")
137 .description("Ask before every action")
138 .icon(IconName::Hand),
139 Self::new(Self::PLAN, "Plan")
140 .description("Read only: propose a plan and wait")
141 .icon(IconName::ClipboardList),
142 Self::new(Self::BYPASS, "Bypass all")
143 .description("Run everything without asking")
144 .icon(IconName::ShieldAlert)
145 .tone(PermissionTone::Warning),
146 ]
147 }
148}
149
150#[derive(Clone, Debug, PartialEq, Eq)]
152#[non_exhaustive]
153pub enum PermissionEvent {
154 Changed(SharedString),
156 LearnMore,
158}
159
160pub struct PermissionState {
166 modes: Vec<PermissionMode>,
167 selected: SharedString,
168 menu: Entity<MenuState>,
169 _menu_events: Subscription,
170}
171
172impl EventEmitter<PermissionEvent> for PermissionState {}
173
174impl PermissionState {
175 pub fn new(cx: &mut Context<Self>) -> Self {
177 let menu = cx.new(MenuState::new);
178 let subscription = cx.subscribe(&menu, |this, _, event: &MenuEvent, cx| {
179 if let MenuEvent::Activated(key) = event {
180 if key.as_ref() == LEARN_MORE {
181 cx.emit(PermissionEvent::LearnMore);
182 } else {
183 this.set_selected(key.clone(), cx);
184 }
185 }
186 });
187 Self {
188 modes: PermissionMode::defaults(),
189 selected: PermissionMode::AUTO.into(),
190 menu,
191 _menu_events: subscription,
192 }
193 }
194
195 pub fn with_modes(mut self, modes: impl IntoIterator<Item = PermissionMode>) -> Self {
198 self.modes = modes.into_iter().collect();
199 if !self.modes.iter().any(|mode| mode.id == self.selected)
200 && let Some(first) = self.modes.first()
201 {
202 self.selected = first.id.clone();
203 }
204 self
205 }
206
207 pub fn with_selected(mut self, id: impl Into<SharedString>) -> Self {
209 let id = id.into();
210 if self.modes.iter().any(|mode| mode.id == id) {
211 self.selected = id;
212 }
213 self
214 }
215
216 pub fn modes(&self) -> &[PermissionMode] {
218 &self.modes
219 }
220
221 pub fn selected(&self) -> &SharedString {
223 &self.selected
224 }
225
226 pub fn selected_mode(&self) -> Option<&PermissionMode> {
228 self.modes.iter().find(|mode| mode.id == self.selected)
229 }
230
231 pub fn set_selected(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) {
234 let id = id.into();
235 if id == self.selected || !self.modes.iter().any(|mode| mode.id == id) {
236 return;
237 }
238 self.selected = id.clone();
239 cx.emit(PermissionEvent::Changed(id));
240 cx.notify();
241 }
242
243 pub fn set_modes(
246 &mut self,
247 modes: impl IntoIterator<Item = PermissionMode>,
248 cx: &mut Context<Self>,
249 ) {
250 self.modes = modes.into_iter().collect();
251 if !self.modes.iter().any(|mode| mode.id == self.selected)
252 && let Some(first) = self.modes.first()
253 {
254 self.selected = first.id.clone();
255 cx.emit(PermissionEvent::Changed(self.selected.clone()));
256 }
257 cx.notify();
258 }
259
260 pub fn menu(&self) -> &Entity<MenuState> {
262 &self.menu
263 }
264}
265
266type TriggerBuilder = Box<dyn FnOnce(bool) -> gpui_kit::AnyElement>;
267
268#[derive(IntoElement)]
291#[non_exhaustive]
292pub struct PermissionMenu {
293 id: ElementId,
294 state: Entity<PermissionState>,
295 trigger: Option<TriggerBuilder>,
296 learn_more: bool,
297 align: Align,
298 icon_only: bool,
299 style: StyleRefinement,
300}
301
302impl PermissionMenu {
303 pub fn new(id: impl Into<ElementId>, state: &Entity<PermissionState>) -> Self {
305 Self {
306 id: id.into(),
307 state: state.clone(),
308 trigger: None,
309 learn_more: true,
310 align: Align::Start,
311 icon_only: false,
312 style: StyleRefinement::default(),
313 }
314 }
315
316 pub fn trigger(mut self, trigger: impl Selectable + IntoElement + 'static) -> Self {
318 self.trigger = Some(Box::new(move |open| trigger.open(open).into_any_element()));
319 self
320 }
321
322 pub fn learn_more(mut self, learn_more: bool) -> Self {
325 self.learn_more = learn_more;
326 self
327 }
328
329 pub fn align(mut self, align: Align) -> Self {
332 self.align = align;
333 self
334 }
335
336 pub fn icon_only(mut self, icon_only: bool) -> Self {
340 self.icon_only = icon_only;
341 self
342 }
343
344 pub(crate) fn need(&self, window: &Window, cx: &App) -> Need {
347 let theme = cx.theme();
348 let padding = theme.metrics.control_padding_sm;
349 let rem = window.rem_size();
350 let gap = rems(0.375).to_pixels(rem);
351 let (label, has_icon) = match self.state.read(cx).selected_mode() {
352 Some(mode) => (mode.label.clone(), mode.icon.is_some()),
353 None => ("Permission".into(), false),
354 };
355 let label = collapse::text_width(&label, window)
356 .min(collapse::min_label_width(window, MIN_LABEL_CHARS));
357 let icon = rems(1.).to_pixels(rem);
358 let lead = if has_icon { icon + gap } else { px(0.) };
359 Need::flexible(
360 padding * 2. + lead + label,
361 has_icon.then_some(theme.metrics.control_sm),
362 )
363 }
364}
365
366impl Styled for PermissionMenu {
367 fn style(&mut self) -> &mut StyleRefinement {
368 &mut self.style
369 }
370}
371
372fn entries(state: &Entity<PermissionState>, learn_more: bool, cx: &App) -> Vec<MenuEntry> {
374 let state = state.read(cx);
375 let mut entries = Vec::new();
376 if learn_more {
377 entries.push(
378 MenuItem::new(LEARN_MORE, "Learn more")
379 .render(|_, window, cx| {
380 let look = MenuLook::of(cx.theme(), window.rem_size());
381 let link = cx.theme().link;
382 row_line()
383 .justify_between()
384 .items_center()
385 .child(
386 div()
387 .text_color(look.muted_foreground)
388 .child("Permission mode"),
389 )
390 .child(div().text_color(link).child("Learn more"))
391 })
392 .into(),
393 );
394 entries.push(MenuEntry::Separator);
395 }
396 entries.extend(state.modes.iter().map(|mode| {
397 let selected = mode.id == state.selected;
398 let (label, description, icon, tone) = (
399 mode.label.clone(),
400 mode.description.clone(),
401 mode.icon.clone(),
402 mode.tone,
403 );
404 MenuItem::new(mode.id.clone(), mode.label.clone())
405 .render(move |_, window, cx| {
406 let look = MenuLook::of(cx.theme(), window.rem_size());
407 let warning = cx.theme().warning;
408 let icon_color = match tone {
409 PermissionTone::Warning => warning,
410 PermissionTone::Normal => look.muted_foreground,
411 };
412 let icon = icon
413 .clone()
414 .map(|icon| line_slot(&look).child(icon.size_4().text_color(icon_color)));
415 let mut block = label_block(label.clone(), &[], description.clone(), &look);
416 if tone == PermissionTone::Warning {
417 block = div()
418 .flex_1()
419 .min_w_0()
420 .text_color(warning)
421 .child(block)
422 .into_any_element();
423 }
424 row_line()
425 .children(icon)
426 .child(block)
427 .when(selected, |this| {
428 this.child(
429 line_slot(&look).child(
430 Icon::from(IconName::Check)
431 .size_4()
432 .text_color(look.indicator),
433 ),
434 )
435 })
436 })
437 .into()
438 }));
439 entries
440}
441
442impl RenderOnce for PermissionMenu {
443 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
444 let menu_state = self.state.read(cx).menu.clone();
445 let learn_more = self.learn_more;
446 let state = self.state.clone();
447 let need = self.need(window, cx);
448 let menu = DropdownMenu::new(self.id.clone(), &menu_state)
449 .align(self.align)
450 .min_w(match (self.icon_only, need.icon_only) {
451 (true, Some(icon_only)) => icon_only,
452 _ => need.min,
453 })
454 .refine_style(&self.style)
455 .items(move |_, cx| entries(&state, learn_more, cx));
456 match self.trigger {
457 Some(build) => menu.trigger(OpenSlot::built(build)).into_any_element(),
458 None => {
459 let button = Button::new(ElementId::NamedChild(self.id.into(), "trigger".into()))
460 .ghost()
461 .size(ButtonSize::Sm)
462 .flex_shrink(1.)
463 .accessibility_label("Permission mode");
464 let theme = cx.theme();
465 let (warning, muted) = (theme.warning, theme.muted_foreground());
466 let button = match self.state.read(cx).selected_mode().cloned() {
467 Some(mode) => {
468 let color = match mode.tone {
469 PermissionTone::Warning => warning,
470 PermissionTone::Normal => muted,
471 };
472 match mode.icon.clone() {
473 Some(icon) if self.icon_only => button
474 .icon(icon)
475 .text_color(color)
476 .tooltip(mode.label.clone()),
477 icon => {
478 let keep = collapse::text_width(&mode.label, window)
479 .min(collapse::min_label_width(window, MIN_LABEL_CHARS));
480 button
481 .when_some(icon, |button, icon| button.icon(icon))
482 .child(
483 div()
484 .min_w(keep)
485 .flex_shrink(1.)
486 .overflow_hidden()
487 .whitespace_nowrap()
488 .text_ellipsis()
489 .child(mode.label.clone()),
490 )
491 .text_color(color)
492 }
493 }
494 }
495 None => button.label("Permission").text_color(muted),
496 };
497 menu.trigger(button).into_any_element()
498 }
499 }
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn only_the_mode_that_runs_without_asking_is_a_warning_by_default() {
509 let tones: Vec<_> = PermissionMode::defaults()
510 .iter()
511 .map(|mode| (mode.id().to_string(), mode.tone_of()))
512 .collect();
513 for (id, tone) in tones {
514 let want = if id == PermissionMode::BYPASS {
515 PermissionTone::Warning
516 } else {
517 PermissionTone::Normal
518 };
519 assert_eq!(tone, want, "{id}");
520 }
521 assert_eq!(
522 PermissionMode::new("x", "X").tone_of(),
523 PermissionTone::Normal
524 );
525 }
526}