Skip to main content

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}