feat: host toasts and alerts in the shell

This commit is contained in:
2026-11-10 18:33:56 +07:00
parent a6b6cfd5a4
commit 67f34ead74
7 changed files with 182 additions and 60 deletions
+3 -1
View File
@@ -17,7 +17,7 @@ gpui = { workspace = true, features = ["test-support"] }
[features]
default = ["full"]
full = ["dock", "resize_handle", "resizable", "shell", "window_controls"]
full = ["dock", "resize_handle", "resizable", "shell", "window_controls", "toast", "alert"]
dock = [
"resize_handle",
"dep:gpui-native-core",
@@ -37,4 +37,6 @@ shell = [
"dep:gpui-native-data",
"dep:gpui-native-icons",
]
toast = ["shell", "gpui-native-core/toast"]
alert = ["shell", "gpui-native-core/alert"]
window_controls = ["dep:gpui-native-icons"]
+58 -2
View File
@@ -6,8 +6,12 @@ use gpui::{
AnyElement, App, AppContext as _, Context, ElementId, Entity, InteractiveElement as _,
IntoElement, ParentElement as _, Pixels, RenderOnce, Styled as _, Window, div, px,
};
#[cfg(feature = "alert")]
use gpui_native_core::alert::{Alert, Alerts, AlertsView};
use gpui_native_core::button::{Button, ButtonVariant};
use gpui_native_core::common::{Sizable as _, h_flex, v_flex};
#[cfg(feature = "toast")]
use gpui_native_core::toast::{Toast, Toasts, ToastsView};
use gpui_native_data::sidebar::Sidebar;
use gpui_native_icons::IconRole;
use gpui_native_theme::{
@@ -24,16 +28,24 @@ pub struct LayoutState {
right_visible: bool,
left_width: Pixels,
right_width: Pixels,
#[cfg(feature = "toast")]
toasts: Entity<Toasts>,
#[cfg(feature = "alert")]
alerts: Entity<Alerts>,
}
impl LayoutState {
/// Creates the state: left sidebar visible, right sidebar hidden.
pub fn new(cx: &mut App) -> Entity<Self> {
cx.new(|_| Self {
cx.new(|_cx| Self {
left_visible: true,
right_visible: false,
left_width: SIDEBAR_DEFAULT_WIDTH,
right_width: SIDEBAR_DEFAULT_WIDTH,
#[cfg(feature = "toast")]
toasts: _cx.new(|_| Toasts::default()),
#[cfg(feature = "alert")]
alerts: _cx.new(|_| Alerts::default()),
})
}
@@ -90,6 +102,42 @@ impl LayoutState {
cx.notify();
}
}
/// Raises a toast in the shell's overlay. Returns its id.
#[cfg(feature = "toast")]
pub fn push_toast(&mut self, toast: Toast, cx: &mut Context<Self>) -> usize {
self.toasts.update(cx, |toasts, cx| toasts.push(toast, cx))
}
/// Dismisses one toast by id.
#[cfg(feature = "toast")]
pub fn dismiss_toast(&mut self, id: usize, cx: &mut Context<Self>) {
self.toasts.update(cx, |toasts, cx| toasts.dismiss(id, cx));
}
/// Dismisses the toast on screen.
#[cfg(feature = "toast")]
pub fn clear_toasts(&mut self, cx: &mut Context<Self>) {
self.toasts.update(cx, |toasts, cx| toasts.clear(cx));
}
/// Pushes an alert onto the shell's stack. Returns its id.
#[cfg(feature = "alert")]
pub fn push_alert(&mut self, alert: Alert, cx: &mut Context<Self>) -> usize {
self.alerts.update(cx, |alerts, cx| alerts.push(alert, cx))
}
/// Dismisses one alert by id, running its dismiss handler.
#[cfg(feature = "alert")]
pub fn dismiss_alert(&mut self, id: usize, cx: &mut Context<Self>) {
self.alerts.update(cx, |alerts, cx| alerts.dismiss(id, cx));
}
/// Dismisses every alert, running each entry's `on_dismiss` handler.
#[cfg(feature = "alert")]
pub fn clear_alerts(&mut self, cx: &mut Context<Self>) {
self.alerts.update(cx, |alerts, cx| alerts.clear(cx));
}
}
/// Which sidebar a region or toggle belongs to.
@@ -245,8 +293,16 @@ impl RenderOnce for Shell {
))
});
// Wraps the layout in a window frame.
// Wraps the layout in a window frame
frame(layout, window, cx)
.when(cfg!(feature = "toast"), |this| {
let toasts = state.read(cx).toasts.clone();
this.child(ToastsView::new((id.clone(), "toasts"), toasts))
})
.when(cfg!(feature = "alert"), |this| {
let alerts = state.read(cx).alerts.clone();
this.child(AlertsView::new((id, "alerts"), alerts))
})
}
}
+85
View File
@@ -237,3 +237,88 @@ fn the_right_seam_grows_the_right_sidebar_leftwards() {
let region = bounds(&mut visual, "test-right").expect("right region missing");
assert_eq!(region.size.width, grown);
}
/// Toasts and alerts live on the shell's [`LayoutState`]; pushing one must
/// show up in the rendered shell with no observing or manual overlay.
#[cfg(feature = "toast")]
mod toasts {
use super::*;
use gpui_native_core::toast::{Toast, ToastAction};
use std::time::Duration;
#[test]
fn a_pushed_toast_renders_in_the_shell() {
let mut cx = TestAppContext::single();
let (state, mut visual) = layout_window(&mut cx);
let id = cx.update(|cx| {
state.update(cx, |state, cx| {
state.push_toast(
Toast::new("Draft deleted").action(ToastAction::new("Undo", |_| {
// A real app restores the draft here.
})),
cx,
)
})
});
// No redraw, no observation: the shell repaints its own overlay.
cx.run_until_parked();
assert!(
bounds(&mut visual, "toast-pill").is_some(),
"the pushed toast must appear in the shell's overlay"
);
cx.update(|cx| {
state.update(cx, |state, cx| state.dismiss_toast(id, cx));
});
cx.run_until_parked();
// The fade-out keeps the pill mounted briefly.
assert!(
bounds(&mut visual, "toast-pill").is_some(),
"the dismissed toast must stay mounted through its fade-out"
);
cx.executor().advance_clock(Duration::from_secs(2));
cx.run_until_parked();
assert!(
bounds(&mut visual, "toast-pill").is_none(),
"the dismissed toast must leave the overlay once faded"
);
}
}
#[cfg(feature = "alert")]
mod alerts {
use super::*;
use gpui_native_core::alert::{Alert, AlertSeverity};
#[test]
fn a_pushed_alert_renders_in_the_shell() {
let mut cx = TestAppContext::single();
let (state, mut visual) = layout_window(&mut cx);
cx.update(|cx| {
state.update(cx, |state, cx| {
state.push_alert(
Alert::new("shell-alert")
.title("Backed up")
.severity(AlertSeverity::Info),
cx,
);
})
});
cx.run_until_parked();
assert!(
bounds(&mut visual, "alert-entry").is_some(),
"the pushed alert must appear in the shell's overlay"
);
cx.update(|cx| {
state.update(cx, |state, cx| state.clear_alerts(cx));
});
cx.run_until_parked();
assert!(
bounds(&mut visual, "alert-entry").is_none(),
"cleared alerts must leave the overlay"
);
}
}