gpui_kit/display/failure_panel.rs
1//! A region of a window whose contents the host could not produce.
2//!
3//! # This is not an error boundary, and is not called one
4//!
5//! A JavaScript error boundary catches an exception thrown while a subtree
6//! renders and swaps in a fallback. Nothing here can do that, and the name
7//! `ErrorBoundary` would promise it. What was established about GPUI at the
8//! pinned revision:
9//!
10//! - `Render::render` and `RenderOnce::render` return `impl IntoElement`.
11//! There is no fallible render, no `Result`, and therefore no failure a
12//! parent could observe as a value coming back from a child.
13//! - GPUI calls `panic::catch_unwind` in exactly one place, its own
14//! `#[gpui::test]` harness. Nothing on the platform draw path catches
15//! anything, so a panic in `render` unwinds straight out of `Window::draw`.
16//! - It cannot simply be wrapped, either. A draw holds an element-arena scope
17//! guard whose exit hands back a clear token the caller owes, the arena
18//! itself hands out raw pointers into a bump allocation, and the window
19//! asserts its rendered-entity stack is empty around every draw. Unwinding
20//! past that leaves the window's own bookkeeping in a state the next frame
21//! does not expect. On top of which `&mut Window` and `&mut App` are not
22//! unwind-safe, so reaching for `catch_unwind` would mean asserting they
23//! were — which is precisely the claim the panic just disproved.
24//!
25//! So a render panic is not catchable here in any way worth shipping, and this
26//! component does not pretend to catch one. It is for the ordinary case that
27//! actually happens: **the host already holds a failure value**. It asked for
28//! something and got an `Err`, a refusal, a timeout, a document it could not
29//! parse. [`FailurePanel::from_result`] is the whole seam.
30//!
31//! What it guarantees is the part that matters either way: the failure stays
32//! on screen in the host's own words, a retry belongs to the host, and a panel
33//! that failed is never drawn as a panel that is empty.
34
35use std::rc::Rc;
36
37use gpui::{
38 App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
39 prelude::FluentBuilder, px,
40};
41use gpui_kit_assets::{Icon, icon};
42use gpui_kit_semantics::{NodeSpec, Role, Semantic};
43use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, TypeScale};
44
45use crate::controls::button::Button;
46use crate::foundation::{Ident, Sizable, StyledExt};
47use crate::strings::{ActiveStrings, StringKey};
48
49type RetryHandler = Rc<dyn Fn(&mut Window, &mut App)>;
50
51/// A panel that states what went wrong instead of what it was going to show.
52#[derive(IntoElement)]
53pub struct FailurePanel {
54 ident: Ident,
55 title: Option<SharedString>,
56 /// The host's own words. Never authored here, and never rewritten.
57 reason: SharedString,
58 detail: Option<SharedString>,
59 attempts: Option<usize>,
60 retrying: bool,
61 on_retry: Option<RetryHandler>,
62}
63
64impl std::fmt::Debug for FailurePanel {
65 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 formatter
67 .debug_struct("FailurePanel")
68 .field("ident", &self.ident)
69 .field("title", &self.title)
70 .field("reason", &self.reason)
71 .field("attempts", &self.attempts)
72 .field("retrying", &self.retrying)
73 .field("has_handler", &self.on_retry.is_some())
74 .finish()
75 }
76}
77
78impl FailurePanel {
79 pub fn new(ident: impl Into<Ident>, reason: impl Into<SharedString>) -> Self {
80 Self {
81 ident: ident.into(),
82 title: None,
83 reason: reason.into(),
84 detail: None,
85 attempts: None,
86 retrying: false,
87 on_retry: None,
88 }
89 }
90
91 /// The panel for a failure the host is already holding, or `None` when it
92 /// is holding a value instead.
93 ///
94 /// This is the seam the whole component exists for: the caller renders its
95 /// content in the `Ok` arm and this in the `Err` arm, and the failure it
96 /// shows is the one the host actually has rather than one this crate
97 /// caught.
98 pub fn from_result<T, E: std::fmt::Display>(
99 ident: impl Into<Ident>,
100 result: &Result<T, E>,
101 ) -> Option<Self> {
102 match result {
103 Ok(_) => None,
104 Err(error) => Some(Self::new(ident, error.to_string())),
105 }
106 }
107
108 /// What the panel was going to be, so the reader knows which part of the
109 /// window is missing rather than only that something is.
110 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
111 self.title = Some(title.into());
112 self
113 }
114
115 pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
116 self.detail = Some(detail.into());
117 self
118 }
119
120 /// How many times this has been tried. A retry that keeps failing is a
121 /// different fact from a first failure, and hiding the count would let a
122 /// reader keep pressing a control that has never once worked.
123 pub fn attempts(mut self, attempts: usize) -> Self {
124 self.attempts = Some(attempts);
125 self
126 }
127
128 /// Whether a retry is in flight. While it is, the failure stays on screen:
129 /// nothing has replaced it yet.
130 pub fn retrying(mut self, retrying: bool) -> Self {
131 self.retrying = retrying;
132 self
133 }
134
135 /// Reports that the typist asked for another attempt.
136 ///
137 /// Retrying belongs to the host — this panel has no idea what produced the
138 /// failure — so it reports and does nothing. A panel with no handler shows
139 /// no retry control at all rather than a dead one.
140 pub fn on_retry(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
141 self.on_retry = Some(Rc::new(handler));
142 self
143 }
144}
145
146impl RenderOnce for FailurePanel {
147 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
148 let theme = cx.theme().clone();
149 let title = self
150 .title
151 .clone()
152 .unwrap_or_else(|| cx.strings().text(StringKey::FailureTitle));
153
154 let retry = self.on_retry.clone().map(|handler| {
155 Button::new(self.ident.child("retry"))
156 .label(cx.strings().text(StringKey::TryAgain))
157 .secondary()
158 .control_size(ControlSize::Sm)
159 .semantic_parent(self.ident.semantic_id())
160 // A retry already in flight refuses another one rather than
161 // stacking attempts nobody asked for.
162 .loading(self.retrying)
163 .on_click(move |window, cx| handler(window, cx))
164 });
165
166 let attempts = self.attempts.filter(|count| *count > 1).map(|count| {
167 let wording = cx
168 .strings()
169 .format(StringKey::FailureAttempts, &[&count.to_string()]);
170 div()
171 .type_scale(&theme, TypeScale::Caption)
172 .text_color(theme.colors.text_faint)
173 .child(wording.clone())
174 .semantic_in(
175 cx,
176 NodeSpec::new(self.ident.child("attempts").semantic_id(), Role::Text)
177 .parent(self.ident.semantic_id())
178 .text(wording)
179 .value(count.to_string()),
180 )
181 });
182
183 let status = self.retrying.then(|| {
184 let wording = cx.strings().text(StringKey::FailureRetrying);
185 div()
186 .type_scale(&theme, TypeScale::Caption)
187 .text_color(theme.colors.text_muted)
188 .child(wording.clone())
189 .semantic_in(
190 cx,
191 NodeSpec::new(self.ident.child("retrying").semantic_id(), Role::Status)
192 .parent(self.ident.semantic_id())
193 .text(wording)
194 .busy(true),
195 )
196 });
197
198 let reason_ident = self.ident.child("reason");
199
200 div()
201 .column()
202 .w_full()
203 .gap_token(&theme, Space::Sm)
204 .p_token(&theme, Space::Lg)
205 .radius(&theme, Radius::Card)
206 // A failure reports itself by bleeding its colour into the pixels
207 // around the panel, which says the same thing an outline said and
208 // says it without a line.
209 .bg(theme.colors.panel)
210 .glow(&theme, theme.colors.danger)
211 .child(
212 div()
213 .row()
214 .gap_token(&theme, Space::Sm)
215 .child(
216 icon(Icon::Danger)
217 .size(px(theme.control.md.icon_size))
218 .text_color(theme.colors.danger),
219 )
220 .child(
221 div()
222 .type_scale(&theme, TypeScale::Label)
223 .text_color(theme.colors.text)
224 .child(title.clone()),
225 ),
226 )
227 // The reason is the host's sentence, shown word for word and given
228 // a node of its own so a test can prove it survived.
229 .child(
230 div()
231 .type_scale(&theme, TypeScale::Body)
232 .text_color(theme.colors.text_muted)
233 .child(self.reason.clone())
234 .semantic_in(
235 cx,
236 NodeSpec::new(reason_ident.semantic_id(), Role::Text)
237 .parent(self.ident.semantic_id())
238 .text(self.reason.clone()),
239 ),
240 )
241 .when_some(self.detail.clone(), |element, detail| {
242 element.child(
243 div()
244 .type_scale(&theme, TypeScale::Caption)
245 .text_color(theme.colors.text_faint)
246 .child(detail),
247 )
248 })
249 .children(attempts)
250 .children(status)
251 .children(retry.map(|control| div().row().child(control)))
252 .semantic_in(
253 cx,
254 NodeSpec::new(self.ident.semantic_id(), Role::Region)
255 .text(title)
256 // `failed` and never `empty`: a panel that could not be
257 // produced is not a panel with nothing in it.
258 .value("failed")
259 .invalid(true)
260 .busy(self.retrying),
261 )
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn a_held_value_produces_no_panel() {
271 let held: Result<u8, String> = Ok(7);
272 assert!(FailurePanel::from_result("panel", &held).is_none());
273 }
274
275 #[test]
276 fn a_held_failure_carries_the_hosts_own_words() {
277 let held: Result<u8, String> = Err("the index is still building".into());
278 let panel = FailurePanel::from_result("panel", &held).expect("a failure panel");
279 assert_eq!(panel.reason, "the index is still building");
280 }
281}