ui/components/notification/
alert_modal.rs1use 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 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}