T10b feedback: error views with 404, 403, maintenance and offline, error boundary, drawn results, confirmation card, saving, sync and connection lines, countdown and timer with rolling digits

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 16:19:41 -04:00
1 parent f895424a08
commit 866335912a
24 files changed
+1806 -173

No files matched your search

+295
View File
@@ -0,0 +1,295 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, AnyElement, App, Div, ElementId, FontWeight, IntoElement,
ParentElement, RenderOnce, SharedString, Styled, Window, div, prelude::*,
};
use smallvec::SmallVec;
use crate::{
buttons::Button,
forms::Run,
motion,
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize, Radius, TextSize},
typography::tabular,
};
/// The words and buttons under a state's head.
pub(super) struct Words {
title: SharedString,
pub(super) body: Option<SharedString>,
detail: Option<SharedString>,
pub(super) actions: SmallVec<[AnyElement; 2]>,
}
impl Words {
pub(super) fn new(title: impl Into<SharedString>) -> Self {
Self {
title: title.into(),
body: None,
detail: None,
actions: SmallVec::new(),
}
}
}
/// `block`, centered and no wider than prose; a flex row sets its width before its text wraps.
fn measure(block: Div, cx: &App) -> Div {
div()
.w_full()
.flex()
.justify_center()
.child(block.flex_1().min_w_0().max_w(cx.theme().prose_width()))
}
/// A head, a title, a line, a detail and actions, centered; it rises in once.
pub(super) fn state_view(
id: ElementId,
head: AnyElement,
words: Words,
cx: &App,
) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
div()
.w_full()
.flex()
.flex_col()
.items_center()
.gap_4()
.px_6()
.py_8()
.child(head)
.child(measure(
div()
.text_center()
.child(
div()
.text_size(theme.text_size(TextSize::Lg))
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child(words.title),
)
.when_some(words.body, |text, body| {
text.child(
div()
.mt_1()
.text_size(theme.text_size(TextSize::Base))
.text_color(colors.fg_muted)
.child(body),
)
}),
cx,
))
.when_some(words.detail, |state, detail| {
state.child(measure(
div()
.px_3()
.py_2()
.rounded(theme.radius(Radius::Md))
.bg(colors.sunken)
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(detail),
cx,
))
})
.when(!words.actions.is_empty(), |state| {
state.child(div().flex().gap_2().children(words.actions))
})
.with_animation(
id,
Animation::new(motion::duration(motion::SLOW, cx)).with_easing(motion::ease_out_cubic),
|state, t| state.opacity(t).mt(motion::NUDGE * 2.0 * (1.0 - t)),
)
}
/// An icon on a soft disc, the head of an empty or broken view.
pub(super) fn disc(icon: IconName, cx: &App) -> AnyElement {
let theme = cx.theme();
div()
.flex()
.items_center()
.justify_center()
.size(theme.icon_size(IconSize::Xxl) + theme.icon_size(IconSize::Md))
.rounded_full()
.bg(theme.colors.sunken)
.child(
Icon::new(icon)
.size(IconSize::Lg)
.color(theme.colors.fg_subtle),
)
.into_any_element()
}
macro_rules! words_builders {
() => {
pub fn body(mut self, text: impl Into<SharedString>) -> Self {
self.words.body = Some(text.into());
self
}
pub fn action(mut self, action: impl IntoElement) -> Self {
self.words.actions.push(action.into_any_element());
self
}
};
}
pub(super) use words_builders;
/// What a view shows with nothing in it: an icon on a soft disc, a title, a line of help and actions. It rises in when it appears.
#[derive(IntoElement)]
pub struct EmptyState {
id: ElementId,
icon: IconName,
words: Words,
}
impl EmptyState {
pub fn new(id: impl Into<ElementId>, icon: IconName, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
icon,
words: Words::new(title),
}
}
words_builders!();
}
impl RenderOnce for EmptyState {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
state_view(self.id, disc(self.icon, cx), self.words, cx)
}
}
enum Head {
Code(SharedString),
Icon(IconName),
}
/// A view that could not show what was asked: a status code or an icon, what happened, and a way on.
#[derive(IntoElement)]
pub struct ErrorView {
id: ElementId,
head: Head,
words: Words,
}
impl ErrorView {
pub fn new(id: impl Into<ElementId>, icon: IconName, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
head: Head::Icon(icon),
words: Words::new(title),
}
}
/// Headed by a status code, such as 404, instead of an icon.
pub fn with_code(
id: impl Into<ElementId>,
code: impl Into<SharedString>,
title: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
head: Head::Code(code.into()),
words: Words::new(title),
}
}
pub fn not_found(id: impl Into<ElementId>) -> Self {
Self::with_code(id, "404", "Page not found")
.body("The link may be broken, or the page has moved.")
}
pub fn no_permission(id: impl Into<ElementId>) -> Self {
Self::with_code(id, "403", "You don't have access")
.body("Ask an owner to share it with you.")
}
pub fn maintenance(id: impl Into<ElementId>) -> Self {
Self::new(id, IconName::Wrench, "Down for maintenance").body("We'll be back shortly.")
}
pub fn offline(id: impl Into<ElementId>) -> Self {
Self::new(id, IconName::WifiOff, "You're offline")
.body("Check your connection. Your work stays on this device.")
}
/// The error itself, in a quiet mono box.
pub fn detail(mut self, text: impl Into<SharedString>) -> Self {
self.words.detail = Some(text.into());
self
}
words_builders!();
}
impl RenderOnce for ErrorView {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let head = match self.head {
Head::Icon(icon) => disc(icon, cx),
Head::Code(code) => {
let theme = cx.theme();
tabular(div())
.text_size(theme.text_size(TextSize::Display))
.font_weight(FontWeight::LIGHT)
.text_color(theme.colors.fg_subtle)
.child(code)
.into_any_element()
}
};
state_view(self.id, head, self.words, cx)
}
}
/// Shows its content, or an `ErrorView` of the error it holds, logged once per error. It never catches panics.
#[derive(IntoElement)]
pub struct ErrorBoundary {
id: ElementId,
content: anyhow::Result<AnyElement>,
on_retry: Option<Run>,
}
impl ErrorBoundary {
pub fn new<E: IntoElement>(id: impl Into<ElementId>, content: anyhow::Result<E>) -> Self {
Self {
id: id.into(),
content: content.map(IntoElement::into_any_element),
on_retry: None,
}
}
pub fn on_retry(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_retry = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ErrorBoundary {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let error = match self.content {
Ok(content) => return content,
Err(error) => error,
};
let chain = format!("{error:#}");
let logged = window.use_keyed_state((self.id.clone(), "logged"), cx, |_, _| None::<String>);
if logged.read(cx).as_deref() != Some(chain.as_str()) {
log::error!("error boundary {:?}: {chain}", self.id);
logged.update(cx, |logged, _| *logged = Some(chain.clone()));
}
let retry = (self.id.clone(), "retry");
ErrorView::new(self.id, IconName::CircleAlert, "Something went wrong")
.body(error.to_string())
.detail(chain)
.when_some(self.on_retry, |view, run| {
view.action(
Button::new(retry, "Try again").on_click(move |_, window, cx| run(window, cx)),
)
})
.into_any_element()
}
}