Skip to main content

ui/components/notification/
alert_modal.rs

1use crate::component_prelude::*;
2use crate::prelude::*;
3use crate::{Checkbox, ListBulletItem, ToggleState};
4use gpui::Action;
5use gpui::FocusHandle;
6use gpui::IntoElement;
7use gpui::Stateful;
8use smallvec::{SmallVec, smallvec};
9use theme::ActiveTheme;
10
11type ActionHandler = Box<dyn FnOnce(Stateful<Div>) -> Stateful<Div>>;
12
13#[derive(IntoElement, RegisterComponent)]
14pub struct AlertModal {
15    id: ElementId,
16    header: Option<AnyElement>,
17    children: SmallVec<[AnyElement; 2]>,
18    footer: Option<AnyElement>,
19    title: Option<SharedString>,
20    primary_action: Option<SharedString>,
21    dismiss_label: Option<SharedString>,
22    width: Option<DefiniteLength>,
23    key_context: Option<String>,
24    action_handlers: Vec<ActionHandler>,
25    focus_handle: Option<FocusHandle>,
26    destructive: bool,
27}
28
29impl AlertModal {
30    pub fn new(id: impl Into<ElementId>) -> Self {
31        Self {
32            id: id.into(),
33            header: None,
34            children: smallvec![],
35            footer: None,
36            title: None,
37            primary_action: None,
38            dismiss_label: None,
39            width: None,
40            key_context: None,
41            action_handlers: Vec::new(),
42            focus_handle: None,
43            destructive: false,
44        }
45    }
46
47    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
48        self.title = Some(title.into());
49        self
50    }
51
52    pub fn header(mut self, header: impl IntoElement) -> Self {
53        self.header = Some(header.into_any_element());
54        self
55    }
56
57    pub fn footer(mut self, footer: impl IntoElement) -> Self {
58        self.footer = Some(footer.into_any_element());
59        self
60    }
61
62    pub fn primary_action(mut self, primary_action: impl Into<SharedString>) -> Self {
63        self.primary_action = Some(primary_action.into());
64        self
65    }
66
67    pub fn dismiss_label(mut self, dismiss_label: impl Into<SharedString>) -> Self {
68        self.dismiss_label = Some(dismiss_label.into());
69        self
70    }
71
72    pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
73        self.width = Some(width.into());
74        self
75    }
76
77    pub fn key_context(mut self, key_context: impl Into<String>) -> Self {
78        self.key_context = Some(key_context.into());
79        self
80    }
81
82    pub fn on_action<A: Action>(
83        mut self,
84        listener: impl Fn(&A, &mut Window, &mut App) + 'static,
85    ) -> Self {
86        self.action_handlers
87            .push(Box::new(move |div| div.on_action(listener)));
88        self
89    }
90
91    pub fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self {
92        self.focus_handle = Some(focus_handle.clone());
93        self
94    }
95
96    /// Marks the primary action as destructive, rendering it with the danger button style
97    /// instead of the default primary style.
98    pub fn destructive(mut self, destructive: bool) -> Self {
99        self.destructive = destructive;
100        self
101    }
102}
103
104impl RenderOnce for AlertModal {
105    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
106        let width = self.width.unwrap_or_else(|| px(440.).into());
107        let has_default_footer = self.primary_action.is_some() || self.dismiss_label.is_some();
108
109        let destructive = self.destructive;
110
111        let mut modal = v_flex()
112            .when_some(self.key_context, |this, key_context| {
113                this.key_context(key_context.as_str())
114            })
115            .when_some(self.focus_handle, |this, focus_handle| {
116                this.track_focus(&focus_handle)
117            })
118            .id(self.id)
119            .w(width)
120            .bg(semantic::elevated_surface(cx))
121            .border_1()
122            .border_color(semantic::border(cx))
123            .rounded_lg()
124            .shadow_level(Shadow::Xl)
125            .overflow_hidden();
126
127        for handler in self.action_handlers {
128            modal = handler(modal);
129        }
130
131        if let Some(header) = self.header {
132            modal = modal.child(header);
133        } else if let Some(title) = self.title {
134            modal = modal.child(
135                v_flex()
136                    .pt_3()
137                    .pr_3()
138                    .pl_3()
139                    .pb_1()
140                    .child(Headline::new(title).size(HeadlineSize::Small)),
141            );
142        }
143
144        if !self.children.is_empty() {
145            modal = modal.child(
146                v_flex()
147                    .p_3()
148                    .text_ui(cx)
149                    .text_color(Color::Muted.color(cx))
150                    .gap_1()
151                    .children(self.children),
152            );
153        }
154
155        if let Some(footer) = self.footer {
156            modal = modal.child(footer);
157        } else if has_default_footer {
158            let primary_action = self.primary_action.unwrap_or_else(|| "Ok".into());
159            let dismiss_label = self.dismiss_label.unwrap_or_else(|| "Cancel".into());
160
161            modal = modal.child(
162                h_flex()
163                    .p_3()
164                    .items_center()
165                    .justify_end()
166                    .gap(DynamicSpacing::Base12.rems(cx))
167                    .border_t_1()
168                    .border_color(semantic::border_muted(cx))
169                    .child(Button::new(dismiss_label.clone(), dismiss_label).color(Color::Muted))
170                    .child({
171                        let primary = Button::new(primary_action.clone(), primary_action);
172                        if destructive {
173                            primary.danger()
174                        } else {
175                            primary.primary()
176                        }
177                    }),
178            );
179        }
180
181        modal
182    }
183}
184
185impl ParentElement for AlertModal {
186    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
187        self.children.extend(elements)
188    }
189}
190
191impl Component for AlertModal {
192    fn scope() -> ComponentScope {
193        ComponentScope::Notification
194    }
195
196    fn status() -> ComponentStatus {
197        ComponentStatus::WorkInProgress
198    }
199
200    fn description() -> Option<&'static str> {
201        Some("A modal dialog that presents an alert message with primary and dismiss actions.")
202    }
203
204    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
205        Some(
206            v_flex()
207                .gap_6()
208                .p_4()
209                .children(vec![
210                    example_group(vec![single_example(
211                        "Basic Alert",
212                        AlertModal::new("simple-modal")
213                            .title("Do you want to leave the current call?")
214                            .child(
215                                "The current window will be closed, and connections to any shared projects will be terminated."
216                            )
217                            .primary_action("Leave Call")
218                            .dismiss_label("Cancel")
219                            .into_any_element(),
220                    )]),
221                    example_group(vec![single_example(
222                        "Destructive Confirm",
223                        AlertModal::new("destructive-modal")
224                            .title("Delete this project?")
225                            .child("This action cannot be undone.")
226                            .primary_action("Delete")
227                            .dismiss_label("Cancel")
228                            .destructive(true)
229                            .into_any_element(),
230                    )]),
231                    example_group(vec![single_example(
232                        "Custom Header",
233                        AlertModal::new("custom-header-modal")
234                            .header(
235                                v_flex()
236                                    .p_3()
237                                    .bg(cx.theme().colors().background)
238                                    .gap_1()
239                                    .child(
240                                        h_flex()
241                                            .gap_1()
242                                            .child(Icon::new(IconName::Warning).color(Color::Warning))
243                                            .child(Headline::new("Unrecognized Workspace").size(HeadlineSize::Small))
244                                    )
245                                    .child(
246                                        h_flex()
247                                            .pl(IconSize::default().rems() + rems(0.5))
248                                            .child(Label::new("~/projects/my-project").color(Color::Muted))
249                                    )
250                            )
251                            .child(
252                                "Untrusted workspaces are opened in Restricted Mode to protect your system.
253Review .app/settings.json for any extensions or commands configured by this project.",
254                            )
255                            .child(
256                                v_flex()
257                                    .mt_1()
258                                    .child(Label::new("Restricted mode prevents:").color(Color::Muted))
259                                    .child(ListBulletItem::new("Project settings from being applied"))
260                                    .child(ListBulletItem::new("Language servers from running"))
261                                    .child(ListBulletItem::new("MCP integrations from installing"))
262                            )
263                            .footer(
264                                h_flex()
265                                    .p_3()
266                                    .justify_between()
267                                    .child(
268                                        Checkbox::new("trust-parent", ToggleState::Unselected)
269                                            .label("Trust all projects in parent directory")
270                                    )
271                                    .child(
272                                        h_flex()
273                                            .gap_1()
274                                            .child(Button::new("restricted", "Stay in Restricted Mode").color(Color::Muted))
275                                            .child(Button::new("trust", "Trust and Continue").style(ButtonStyle::Filled))
276                                    )
277                            )
278                            .width(rems(40.))
279                            .into_any_element(),
280                    )]),
281                ])
282                .into_any_element(),
283        )
284    }
285}