Merge pull request #217 from Kuddev/review/notification-duration-20260919
feat(notifications): configure duration and bound retained cards
This commit is contained in:
22 files changed
+1053
-38
No files matched your search
@@ -147,6 +147,19 @@ The generated Lua configuration uses `require 'pebrel'` and
|
||||
`pebrel.config_builder()`. Invalid reloads retain the last valid configuration.
|
||||
See the [Lua configuration guide](docs/lua-configuration.md) for settings and examples.
|
||||
|
||||
In **Settings → Terminal → Alerts**, **Notification duration** selects **Use defaults**,
|
||||
5, 10, 30, or 90 seconds, or **Until dismissed**. The default preserves existing
|
||||
lifetimes: short toasts last 5 seconds, message banners 90 seconds, and update
|
||||
notices remain until dismissed. The saved preference applies to all newly shown
|
||||
or refreshed in-app cards, independently of the AI-toast visibility switch.
|
||||
Only the latest three cards are retained; newer cards replace older ones without
|
||||
performing their actions, including in persistent mode.
|
||||
Hiding a card never approves or rejects a request or changes task state; requests
|
||||
remain available in their terminals and updates in Settings. System notifications
|
||||
are unchanged. The persisted key is `notification_duration`, with values `default`,
|
||||
`5`, `10`, `30`, `90`, or `persistent`.
|
||||
See the [notification duration menu](docs/screenshots/notification-duration.png).
|
||||
|
||||
## Build
|
||||
|
||||
With the pinned Rust toolchain and the platform dependencies installed:
|
||||
|
||||
@@ -133,6 +133,16 @@ pebrel config check
|
||||
生成的 Lua 配置使用 `require 'pebrel'` 与 `pebrel.config_builder()`。
|
||||
重载失败时继续使用上一份有效配置。设置与示例见 [Lua 配置指南](docs/lua-configuration.md)。
|
||||
|
||||
在 **设置 → 终端 → 提醒** 中,**通知显示时长** 可选择 **保留原有时长**、
|
||||
5、10、30、90 秒,或 **保持常驻**。默认保留已有行为:短提示显示 5 秒,消息
|
||||
提醒显示 90 秒,更新通知保持常驻。该选项独立于是否显示 AI 提醒的开关,保存后
|
||||
对所有新显示或刷新的应用内卡片生效。包括常驻模式在内,仅保留最新三条卡片;
|
||||
新卡片替换更早的卡片时不会执行其操作。隐藏卡片不会同意或拒绝请求,也不会改变
|
||||
任务状态;请求仍可在对应终端中处理,更新仍可在设置中查看。系统通知不受影响。
|
||||
持久化键为 `notification_duration`,可取 `default`、`5`、`10`、`30`、`90`
|
||||
或 `persistent`。
|
||||
可查看[通知显示时长菜单示例](docs/screenshots/notification-duration.png)。
|
||||
|
||||
## 构建
|
||||
|
||||
安装项目固定的 Rust 工具链与对应平台依赖后运行:
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# Notification duration and retained cards
|
||||
|
||||
## Status
|
||||
|
||||
Implemented.
|
||||
|
||||
## Context
|
||||
|
||||
Users need to choose how long in-app notifications remain visible. Existing
|
||||
callers have different defaults, and hiding an approval request must never
|
||||
answer it. A persistent option also removes the expiry that previously limited
|
||||
how long hidden cards could accumulate.
|
||||
|
||||
## Evidence
|
||||
|
||||
The notification component owns card entities and their dismissal subscriptions.
|
||||
The application renders the newest three cards, but filtering the rendered
|
||||
vector does not remove older entities from the component's queue. A regression
|
||||
that inserts 128 persistent cards retained all 128 after advancing the test clock
|
||||
by one hour before the retention fix.
|
||||
|
||||
## Decision
|
||||
|
||||
`nebula_settings::NotificationDuration` owns accepted values, compatibility
|
||||
fallback and conversion to a lifetime. The settings UI uses shared persistence
|
||||
and refreshes the cached GPUI settings. Failed saves restore the saved selection
|
||||
and provide visible feedback. Existing cards keep their original deadlines.
|
||||
|
||||
The toast adapter disables the component's fixed timer and applies the chosen
|
||||
duration when a card is inserted or refreshed. Timers hold weak entity references;
|
||||
persistent cards create no expiry timer. Replacements receive a new entity, so a
|
||||
previous deadline cannot dismiss a replacement with the same logical identity.
|
||||
|
||||
After insertion, cards outside the latest three are retired through the
|
||||
component's subscribed `DismissEvent`. Its normal effect processing removes both
|
||||
the entity and its subscription. Retirement invokes no click, action or close
|
||||
callback, matching replacement's presentation-only semantics. No hidden history
|
||||
queue or second notification store is introduced. Requests remain in terminals,
|
||||
and updates remain available in Settings.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- Change the existing default globally: it would shorten persistent update
|
||||
notices or change short-toast compatibility for users who made no selection.
|
||||
- Retain hidden cards indefinitely in persistent mode: it introduces an
|
||||
unbounded backlog of entities, closures and subscriptions.
|
||||
- Treat expiry as an answer to a request: a display preference cannot grant or
|
||||
deny permission or change the underlying task.
|
||||
- Fork the component for configurable timers: a narrow adapter can use its
|
||||
existing entity and dismissal contracts without a dependency revision change.
|
||||
|
||||
## Consequences
|
||||
|
||||
Only the latest three cards survive notification effect processing. A burst may
|
||||
temporarily create more entities inside the current update cycle; this is not a
|
||||
hard process-memory limit. Cards already replaced or removed do not stay alive
|
||||
because of an expiry timer. Native system notifications remain independent.
|
||||
|
||||
## Validation
|
||||
|
||||
The new burst regression fails against the pre-fix adapter. Notification tests
|
||||
cover default and selected lifetimes, action independence, manual dismissal,
|
||||
refreshed identities, startup deferral, overflow release and replacement before
|
||||
pending removal events are delivered. The tests use the actual notification
|
||||
component and a controlled clock; platform-specific execution evidence belongs
|
||||
with the pull request's validation record.
|
||||
|
||||
## Supersedes
|
||||
|
||||
None.
|
||||
|
||||
## Revisit when
|
||||
|
||||
A notification history or another host bypasses the adapter. Such a feature
|
||||
needs its own bounded lifetime and must preserve presentation/domain separation.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
@@ -25,10 +25,19 @@
|
||||
"backup": "Backup"
|
||||
},
|
||||
"notifications": {
|
||||
"duration": "Notification duration",
|
||||
"duration_description": "Choose when in-app notifications close, or keep them until dismissed.",
|
||||
"duration_details": "Applies to new or refreshed in-app cards. Defaults: short toasts 5 seconds, banners 90 seconds, update notices until dismissed. Only the latest three cards are kept, including in persistent mode. Closing or replacing a card never answers a confirmation or changes task state. System notifications are unchanged.",
|
||||
"duration_default": "Use defaults",
|
||||
"duration_five": "5 seconds",
|
||||
"duration_ten": "10 seconds",
|
||||
"duration_thirty": "30 seconds",
|
||||
"duration_ninety": "90 seconds",
|
||||
"duration_persistent": "Until dismissed",
|
||||
"ai_messages": "AI message toasts",
|
||||
"ai_messages_description": "Show AI toasts in the lower-right corner. System notifications are unaffected.",
|
||||
"ai_messages_details": "Turning this off dismisses existing in-app AI toasts and hides new completion and confirmation toasts. System notifications, including Windows notifications, and tab status indicators are unchanged. Terminal output and confirmation requests in the terminal remain available.",
|
||||
"save_failed": "Could not save the AI toast setting: {error}"
|
||||
"save_failed": "Could not save the notification setting: {error}"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network proxy",
|
||||
|
||||
@@ -25,10 +25,19 @@
|
||||
"backup": "备份"
|
||||
},
|
||||
"notifications": {
|
||||
"duration": "通知显示时长",
|
||||
"duration_description": "选择应用内通知自动关闭的时间,或保持常驻。",
|
||||
"duration_details": "对新显示或刷新的应用内卡片生效。默认保留原有时长:短提示 5 秒,消息提醒 90 秒,更新通知常驻。包括常驻模式在内,仅保留最新三条卡片。关闭或替换卡片不会同意或拒绝确认,也不会改变任务状态;系统通知不受影响。",
|
||||
"duration_default": "保留原有时长",
|
||||
"duration_five": "5 秒",
|
||||
"duration_ten": "10 秒",
|
||||
"duration_thirty": "30 秒",
|
||||
"duration_ninety": "90 秒",
|
||||
"duration_persistent": "保持常驻",
|
||||
"ai_messages": "AI 消息弹窗",
|
||||
"ai_messages_description": "显示右下角的 AI 弹窗,不影响系统通知。",
|
||||
"ai_messages_details": "关闭后收起已有的应用内 AI 弹窗,并停止显示新的完成和确认弹窗。Windows 系统通知及标签状态提示仍保留,终端输出和终端内的确认请求不受影响。",
|
||||
"save_failed": "无法保存 AI 弹窗设置:{error}"
|
||||
"save_failed": "无法保存通知设置:{error}"
|
||||
},
|
||||
"network": {
|
||||
"title": "网络代理",
|
||||
|
||||
@@ -81,6 +81,8 @@ pub struct Settings {
|
||||
pub dim_inactive_panes: bool,
|
||||
/// Cached in-app toast preference, independent of native system notifications.
|
||||
pub ai_toasts: bool,
|
||||
/// Cached display lifetime; toast delivery and native notifications are independent.
|
||||
pub notification_duration: nebula_settings::NotificationDuration,
|
||||
/// 标签关闭按钮与标签插入动画都在渲染热路径读取,必须随全局设置驻留内存。
|
||||
pub tab_close_visible: bool,
|
||||
pub tab_reveal: nebula_settings::TabRevealName,
|
||||
@@ -275,6 +277,7 @@ impl Settings {
|
||||
.unwrap_or(raw.mouse.focus_follows_mouse),
|
||||
dim_inactive_panes: runtime.dim_inactive_panes,
|
||||
ai_toasts: runtime.ai_toasts,
|
||||
notification_duration: runtime.notification_duration,
|
||||
tab_close_visible: runtime.tab_close_visible,
|
||||
tab_reveal: runtime.tab_reveal,
|
||||
ghost: runtime.ghost,
|
||||
|
||||
@@ -58,6 +58,7 @@ mod keymap;
|
||||
mod launcher_actions;
|
||||
mod localization;
|
||||
mod navigation;
|
||||
mod notifications;
|
||||
mod shell_picker;
|
||||
#[cfg(all(test, feature = "gpui-test-support"))]
|
||||
mod shell_picker_tests;
|
||||
@@ -738,6 +739,7 @@ impl SettingsPane {
|
||||
"powerline" => flag!(powerline),
|
||||
"ghost" => flag!(ghost),
|
||||
"ai_toasts" => flag!(ai_toasts),
|
||||
"notification_duration" => pick!(notification_duration),
|
||||
"cjk_bold_regular" => flag!(cjk_bold_regular),
|
||||
"fetch" => flag!(fetch),
|
||||
"keep_session" => flag!(keep_session),
|
||||
@@ -1092,6 +1094,12 @@ impl SettingsPane {
|
||||
self.runtime.ai_toasts,
|
||||
cx,
|
||||
))
|
||||
.child(self.select_row(
|
||||
"notification_duration",
|
||||
language.text(crate::i18n::Message::SettingsNotificationsDuration),
|
||||
help("notification_duration", language),
|
||||
cx,
|
||||
))
|
||||
.child(self.select_row(
|
||||
"bell",
|
||||
language.pick("终端铃声", "Terminal bell"),
|
||||
|
||||
@@ -32,6 +32,10 @@ impl SettingsPane {
|
||||
if let SelectEvent::Confirm(Some(_)) = event {
|
||||
let row = entity.read(cx).selected_index(cx).map(|path| path.row);
|
||||
if let Some(value) = row.and_then(|row| values.get(row)) {
|
||||
if key == "notification_duration" {
|
||||
this.set_notification_duration(value, window, cx);
|
||||
return;
|
||||
}
|
||||
if key == "scrollback_lines" {
|
||||
this.commit_scrollback_lines(value, window, cx);
|
||||
} else {
|
||||
@@ -67,6 +71,13 @@ impl SettingsPane {
|
||||
cx,
|
||||
);
|
||||
add_select("theme", &THEME_VALUES, runtime.theme.prompt_name(), window, cx);
|
||||
add_select(
|
||||
"notification_duration",
|
||||
nebula_settings::NotificationDuration::VALUES,
|
||||
runtime.notification_duration.settings_value(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
// 选项顺序与文案照抄旧壳 `CURSOR_SHAPE_OPTIONS` / `cursor_shape_label`。
|
||||
add_select(
|
||||
"cursor_shape",
|
||||
|
||||
@@ -31,6 +31,14 @@ pub(super) fn localized_select_labels(
|
||||
language.text(crate::i18n::Message::SettingsQuickTerminalDedicated),
|
||||
language.text(crate::i18n::Message::SettingsQuickTerminalExisting),
|
||||
],
|
||||
"notification_duration" => vec![
|
||||
language.text(crate::i18n::Message::SettingsNotificationsDurationDefault),
|
||||
language.text(crate::i18n::Message::SettingsNotificationsDurationFive),
|
||||
language.text(crate::i18n::Message::SettingsNotificationsDurationTen),
|
||||
language.text(crate::i18n::Message::SettingsNotificationsDurationThirty),
|
||||
language.text(crate::i18n::Message::SettingsNotificationsDurationNinety),
|
||||
language.text(crate::i18n::Message::SettingsNotificationsDurationPersistent),
|
||||
],
|
||||
"cursor_shape" => vec![
|
||||
language.pick("条形(│)", "Bar (│)"),
|
||||
language.pick("下划线(_)", "Underscore (_)"),
|
||||
|
||||
@@ -28,7 +28,7 @@ pub(super) const SECTION_IDS: [&str; 10] = [
|
||||
pub(super) const SECTION_SEARCH_TERMS: [&str; 10] = [
|
||||
"application app 应用 update 更新 version 版本 github support 支持",
|
||||
"appearance 外观 theme 主题 custom 自定义 template 模板 import 导入 export 导出 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标 dim inactive panes 调暗非活动窗格 分屏变暗 scrollback scrolling speed history 回滚 滚动 速度 历史",
|
||||
"profiles 配置文件 shell terminal 终端 completion 补全 startup 启动 ai message notifications toast alerts bell 提醒 通知 弹窗 消息 右下角 ai消息通知 ai 消息通知 ai消息弹窗 ai 消息弹窗 铃声",
|
||||
"profiles 配置文件 shell terminal 终端 completion 补全 startup 启动 ai message notifications toast alerts bell 提醒 通知 弹窗 消息 右下角 ai消息通知 ai 消息通知 ai消息弹窗 ai 消息弹窗 铃声 duration timeout persistent auto-dismiss 时长 秒 常驻 自动关闭",
|
||||
"providers provider ai 供应商 模型 api",
|
||||
"ssh host 主机 remote 远程 connection 连接",
|
||||
"network 网络 proxy 代理 connectivity 连接",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
use super::*;
|
||||
|
||||
#[cfg(all(test, feature = "gpui-test-support"))]
|
||||
mod tests;
|
||||
|
||||
#[cfg(all(test, feature = "gpui-test-support"))]
|
||||
mod native_tests;
|
||||
|
||||
impl SettingsPane {
|
||||
pub(super) fn set_notification_duration(
|
||||
&mut self,
|
||||
value: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Err(error) = self.try_persist(&[("notification_duration", value.to_owned())], cx) {
|
||||
self.sync_select(
|
||||
"notification_duration",
|
||||
self.runtime.notification_duration.settings_value(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let language = crate::gpui_shell::config::ui_language(cx);
|
||||
super::super::toast::toast(
|
||||
window,
|
||||
cx,
|
||||
super::super::toast::ToastKind::Warning,
|
||||
language.format(
|
||||
crate::i18n::Message::SettingsNotificationsSaveFailed,
|
||||
&[("error", &error.to_string())],
|
||||
),
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Native appearance check using the real settings pane and Select control.
|
||||
//! Keyboard input goes directly to this test window, never to the desktop.
|
||||
|
||||
use super::*;
|
||||
use gpui::{Bounds, Keystroke, WindowBounds, WindowOptions, point};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a native desktop and an isolated PEBREL_NOTIFICATION_QA_DIR/config"]
|
||||
fn native_notification_duration_menu_keeps_all_choices_visible() {
|
||||
let output = PathBuf::from(std::env::var_os("PEBREL_NOTIFICATION_QA_DIR").expect("QA output"));
|
||||
assert!(output.is_absolute());
|
||||
assert_eq!(
|
||||
std::env::var_os("PEBREL_CONFIG_DIR").map(PathBuf::from),
|
||||
Some(output.join("config"))
|
||||
);
|
||||
let theme = std::env::var("PEBREL_NOTIFICATION_QA_THEME").unwrap_or_else(|_| "Nord".into());
|
||||
let theme = ThemeName::from_prompt_name(&theme).expect("built-in theme");
|
||||
let chinese =
|
||||
std::env::var("PEBREL_NOTIFICATION_QA_LANGUAGE").is_ok_and(|value| value == "zh-CN");
|
||||
let expected_language =
|
||||
if chinese { crate::display::UiLanguage::ZhCn } else { crate::display::UiLanguage::EnUs };
|
||||
let ready = output.join("ready.json");
|
||||
assert!(!ready.exists(), "use a fresh QA output directory");
|
||||
let result = Arc::new(Mutex::new(None));
|
||||
let after_run = result.clone();
|
||||
gpui_platform::application().with_assets(crate::gpui_shell::assets::NebulaAssets).run(
|
||||
move |cx| {
|
||||
gpui_component::init(cx);
|
||||
cx.set_global(crate::gpui_shell::config::Settings::load(theme));
|
||||
crate::gpui_shell::theme::apply_chrome_theme(cx);
|
||||
let mut pane = None;
|
||||
let handle = cx
|
||||
.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(Bounds::new(
|
||||
point(px(60.0), px(70.0)),
|
||||
gpui::size(px(900.0), px(600.0)),
|
||||
))),
|
||||
focus: false,
|
||||
show: true,
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| {
|
||||
let view = cx.new(|cx| SettingsPane::new(window, cx));
|
||||
view.update(cx, |pane, _| pane.active_section = 2);
|
||||
pane = Some(view.clone());
|
||||
cx.new(|cx| gpui_component::Root::new(view, window, cx))
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let pane = pane.unwrap();
|
||||
cx.spawn(async move |cx| {
|
||||
cx.background_executor().timer(Duration::from_millis(500)).await;
|
||||
let opened = cx
|
||||
.update_window(handle.into(), |_, window, cx| -> Result<(), String> {
|
||||
if crate::gpui_shell::config::ui_language(cx) != expected_language
|
||||
|| crate::gpui_shell::theme::effective_theme_name(cx) != theme
|
||||
|| cx.theme().is_dark()
|
||||
== crate::gpui_shell::theme::chrome_theme(theme).skin().is_light
|
||||
{
|
||||
return Err(
|
||||
"the live theme/language differs from the requested fixture".into(),
|
||||
);
|
||||
}
|
||||
let view = pane.read(cx);
|
||||
if view.runtime.notification_duration
|
||||
!= nebula_settings::NotificationDuration::ThirtySeconds
|
||||
|| view.runtime.ai_toasts
|
||||
{
|
||||
return Err(
|
||||
"the duration and delivery switch are not independently loaded"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let select = view
|
||||
.select_of("notification_duration")
|
||||
.ok_or("duration control missing")?;
|
||||
if select.read(cx).selected_index(cx).map(|path| path.row) != Some(3) {
|
||||
return Err(
|
||||
"duration selection does not reflect the persisted value".into()
|
||||
);
|
||||
}
|
||||
let focus = select.read(cx).focus_handle(cx);
|
||||
// The Alerts group follows Completion. Scroll the real settings
|
||||
// viewport before opening its menu; an offscreen anchor is not QA.
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::ScrollWheel(gpui::ScrollWheelEvent {
|
||||
position: point(px(500.0), px(400.0)),
|
||||
delta: gpui::ScrollDelta::Pixels(point(px(0.0), px(-1600.0))),
|
||||
touch_phase: gpui::TouchPhase::Moved,
|
||||
modifiers: Default::default(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
let _ = window.draw(cx);
|
||||
focus.focus(window, cx);
|
||||
let _ = window.draw(cx);
|
||||
window.dispatch_keystroke(Keystroke::parse("enter").unwrap(), cx);
|
||||
let _ = window.draw(cx);
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|result| result);
|
||||
if opened.is_ok() {
|
||||
cx.background_executor().timer(Duration::from_millis(250)).await;
|
||||
std::fs::write(
|
||||
&ready,
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"pid": std::process::id(), "theme": theme.prompt_name(),
|
||||
"language": if chinese { "zh-CN" } else { "en-US" },
|
||||
"selected_seconds": 30, "ai_toasts_enabled": false,
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
for _ in 0..100 {
|
||||
if output.join("capture-complete").exists() {
|
||||
break;
|
||||
}
|
||||
cx.background_executor().timer(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
*result.lock().unwrap() = Some(opened);
|
||||
drop(pane);
|
||||
let _ = cx.update_window(handle.into(), |_, window, _| window.remove_window());
|
||||
cx.update(|cx| cx.quit());
|
||||
})
|
||||
.detach();
|
||||
},
|
||||
);
|
||||
assert_eq!(*after_run.lock().unwrap(), Some(Ok(())));
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use super::*;
|
||||
use gpui::{Modifiers, TestAppContext};
|
||||
|
||||
#[gpui::test]
|
||||
fn duration_control_is_searchable_keyboard_accessible_and_keeps_the_delivery_switch_independent(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
cx.update(|cx| {
|
||||
gpui_component::init(cx);
|
||||
let mut settings = crate::gpui_shell::config::Settings::load(ThemeName::Nord);
|
||||
settings.ui_language = crate::display::UiLanguage::EnUs;
|
||||
settings.ai_toasts = false;
|
||||
cx.set_global(settings);
|
||||
});
|
||||
let mut pane = None;
|
||||
let (_, window) = cx.add_window_view(|window, cx| {
|
||||
let view = cx.new(|cx| SettingsPane::new(window, cx));
|
||||
view.update(cx, |pane, cx| {
|
||||
pane.runtime =
|
||||
RuntimeSettings::from_raw(&nebula_settings::RawSettings::from_text("ai_toasts=0"));
|
||||
pane.sync_select("notification_duration", "default", window, cx);
|
||||
});
|
||||
pane = Some(view.clone());
|
||||
gpui_component::Root::new(view, window, cx)
|
||||
});
|
||||
let pane = pane.unwrap();
|
||||
window.simulate_resize(gpui::size(px(1000.0), px(1100.0)));
|
||||
window.update(|window, cx| {
|
||||
pane.update(cx, |pane, cx| {
|
||||
pane.settings_search_input
|
||||
.update(cx, |input, cx| input.replace_all("自动关闭", window, cx));
|
||||
})
|
||||
});
|
||||
window.run_until_parked();
|
||||
window.update(|window, cx| {
|
||||
let _ = window.draw(cx);
|
||||
});
|
||||
assert_eq!(pane.read_with(window, |pane, _| pane.active_section), 2);
|
||||
let bounds =
|
||||
window.debug_bounds("settings-select-notification_duration").expect("duration select");
|
||||
assert_eq!(bounds.size.width, px(SETTINGS_SELECT_WIDTH));
|
||||
window.simulate_click(bounds.center(), Modifiers::default());
|
||||
window.run_until_parked();
|
||||
window.simulate_keystrokes("escape");
|
||||
window.run_until_parked();
|
||||
for key in ["enter", "escape"] {
|
||||
window.simulate_keystrokes(key);
|
||||
window.run_until_parked();
|
||||
}
|
||||
pane.read_with(window, |pane, cx| {
|
||||
assert!(!pane.runtime.ai_toasts, "duration must remain available while reminders are off");
|
||||
let select = pane.select_of("notification_duration").unwrap();
|
||||
assert_eq!(select.read(cx).selected_index(cx).unwrap().row, 0);
|
||||
assert_eq!(pane.setting_override("notification_duration"), Some((false, "default".into())));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_options_have_localized_labels_and_stable_persisted_values() {
|
||||
for language in [crate::display::UiLanguage::EnUs, crate::display::UiLanguage::ZhCn] {
|
||||
let labels = localized_select_labels(
|
||||
"notification_duration",
|
||||
nebula_settings::NotificationDuration::VALUES,
|
||||
language,
|
||||
);
|
||||
assert_eq!(labels.len(), 6);
|
||||
assert_eq!(
|
||||
labels.first().unwrap().as_ref(),
|
||||
language.text(crate::i18n::Message::SettingsNotificationsDurationDefault)
|
||||
);
|
||||
assert_eq!(
|
||||
labels.last().unwrap().as_ref(),
|
||||
language.text(crate::i18n::Message::SettingsNotificationsDurationPersistent)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// This test writes settings, so it is opt-in and refuses a nonempty fixture.
|
||||
/// Run it alone with both QA/config variables pointing to the same fresh directory.
|
||||
#[gpui::test]
|
||||
#[ignore = "requires a fresh, isolated PEBREL_NOTIFICATION_QA_DIR and matching PEBREL_CONFIG_DIR"]
|
||||
fn choosing_a_duration_persists_it_and_a_failed_save_restores_the_visible_selection(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let directory = std::path::PathBuf::from(
|
||||
std::env::var_os("PEBREL_NOTIFICATION_QA_DIR").expect("isolated QA directory"),
|
||||
);
|
||||
assert!(directory.is_absolute());
|
||||
assert_eq!(Some(directory.as_os_str()), std::env::var_os("PEBREL_CONFIG_DIR").as_deref());
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let path = directory.join("pebrel_settings.txt");
|
||||
assert!(!path.exists(), "never overwrite an existing configuration");
|
||||
std::fs::write(&path, "ai_toasts=0\nlanguage=en-US\ncustom_data=keep\n").unwrap();
|
||||
cx.update(|cx| {
|
||||
gpui_component::init(cx);
|
||||
cx.set_global(crate::gpui_shell::config::Settings::load(ThemeName::Nord));
|
||||
});
|
||||
let mut pane = None;
|
||||
let (_, window) = cx.add_window_view(|window, cx| {
|
||||
let view = cx.new(|cx| SettingsPane::new(window, cx));
|
||||
view.update(cx, |pane, _| pane.active_section = 2);
|
||||
pane = Some(view.clone());
|
||||
gpui_component::Root::new(view, window, cx)
|
||||
});
|
||||
let pane = pane.unwrap();
|
||||
window.simulate_resize(gpui::size(px(1000.0), px(1100.0)));
|
||||
window.run_until_parked();
|
||||
window.update(|window, cx| {
|
||||
let _ = window.draw(cx);
|
||||
});
|
||||
let bounds =
|
||||
window.debug_bounds("settings-select-notification_duration").expect("duration select");
|
||||
window.simulate_click(bounds.center(), Modifiers::default());
|
||||
window.run_until_parked();
|
||||
for key in ["down", "down", "down", "down", "down", "enter"] {
|
||||
window.simulate_keystrokes(key);
|
||||
window.run_until_parked();
|
||||
window.update(|window, cx| {
|
||||
let _ = window.draw(cx);
|
||||
});
|
||||
}
|
||||
assert_eq!(
|
||||
RuntimeSettings::load().notification_duration,
|
||||
nebula_settings::NotificationDuration::Persistent
|
||||
);
|
||||
assert!(!RuntimeSettings::load().ai_toasts);
|
||||
window.update(|_, cx| {
|
||||
assert_eq!(
|
||||
cx.global::<crate::gpui_shell::config::Settings>().notification_duration,
|
||||
nebula_settings::NotificationDuration::Persistent
|
||||
);
|
||||
});
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("custom_data=keep"));
|
||||
|
||||
// A directory in place of the settings file deterministically rejects a save,
|
||||
// without changing ACLs or touching the user's real configuration.
|
||||
let backup = directory.join("settings-before-error.txt");
|
||||
std::fs::rename(&path, &backup).unwrap();
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
let bounds = window.debug_bounds("settings-select-notification_duration").unwrap();
|
||||
window.simulate_click(bounds.center(), Modifiers::default());
|
||||
window.run_until_parked();
|
||||
for key in ["up", "up", "up", "up", "up", "enter"] {
|
||||
window.simulate_keystrokes(key);
|
||||
window.run_until_parked();
|
||||
window.update(|window, cx| {
|
||||
let _ = window.draw(cx);
|
||||
});
|
||||
}
|
||||
pane.read_with(window, |pane, cx| {
|
||||
assert_eq!(
|
||||
pane.runtime.notification_duration,
|
||||
nebula_settings::NotificationDuration::Persistent
|
||||
);
|
||||
assert_eq!(
|
||||
pane.select_of("notification_duration")
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.selected_index(cx)
|
||||
.unwrap()
|
||||
.row,
|
||||
5
|
||||
);
|
||||
});
|
||||
window.update(|window, cx| {
|
||||
let root = window.root::<gpui_component::Root>().flatten().unwrap();
|
||||
assert!(
|
||||
!root.read(cx).notification.read(cx).notifications().is_empty(),
|
||||
"save failure needs visible feedback"
|
||||
);
|
||||
});
|
||||
std::fs::remove_dir(&path).unwrap();
|
||||
std::fs::rename(&backup, &path).unwrap();
|
||||
assert_eq!(
|
||||
RuntimeSettings::load().notification_duration,
|
||||
nebula_settings::NotificationDuration::Persistent
|
||||
);
|
||||
}
|
||||
@@ -91,6 +91,10 @@ pub(super) fn help(key: &str, language: UiLanguage) -> SettingHelp {
|
||||
language.text(crate::i18n::Message::SettingsNotificationsAiMessagesDescription),
|
||||
Some(language.text(crate::i18n::Message::SettingsNotificationsAiMessagesDetails)),
|
||||
),
|
||||
"notification_duration" => (
|
||||
language.text(crate::i18n::Message::SettingsNotificationsDurationDescription),
|
||||
Some(language.text(crate::i18n::Message::SettingsNotificationsDurationDetails)),
|
||||
),
|
||||
"font_family" => (
|
||||
language.pick("按字体组顺序查找可用字形。", "Uses the font group in order to find available glyphs."),
|
||||
Some(language.pick("前面的字体缺少字形时,会继续尝试后面的字体。", "When a font lacks a glyph, the next font in the group is tried.")),
|
||||
@@ -188,6 +192,7 @@ mod tests {
|
||||
"startup_directory",
|
||||
"bell",
|
||||
"ai_toasts",
|
||||
"notification_duration",
|
||||
"font_family",
|
||||
"ghost",
|
||||
"accept",
|
||||
|
||||
@@ -137,6 +137,7 @@ fn localized_select_labels_keep_stable_value_cardinality() {
|
||||
("cursor_shape", &["beam", "underline", "block", "hollow"]),
|
||||
("tabs_position", &["sidebar", "top"]),
|
||||
("bell", &["off", "visual", "sound", "both"]),
|
||||
("notification_duration", nebula_settings::NotificationDuration::VALUES),
|
||||
];
|
||||
for (key, values) in cases {
|
||||
for language in crate::display::UiLanguage::ALL {
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
//! GPUI 壳的提示三层(裁定见 `display/toast.rs` 模块文档):
|
||||
//!
|
||||
//! - **toast**:已经结束的事实,没有待办动作(恢复成功、复制完成)。
|
||||
//! 走组件库 `NotificationList`(`Root` 自带渲染层),5s 自动消失。
|
||||
//! 走组件库 `NotificationList`(`Root` 自带渲染层),默认 5s 自动消失。
|
||||
//! - **消息栏**:值得驻留、要用户看见的事(配置错误、断路器、AI 回合完成)。
|
||||
//! 同一渲染层,停留 [`BANNER_TTL`] 后自动退场,带关闭按钮。**驻留不等于
|
||||
//! 永不消失**——理由见那个常量的文档。
|
||||
//! 同一渲染层,默认停留 [`BANNER_TTL`] 后自动退场,带关闭按钮。
|
||||
//! - **模态**:阻断性决策。调用点自建 `Dialog`,不经此模块。
|
||||
//!
|
||||
//! 通知显示时长可覆盖 toast、消息栏及更新卡片的默认值,也可保持常驻;
|
||||
//! 仅影响新显示或刷新的卡片,不改变系统通知、实际待处理状态或确认结果。
|
||||
//!
|
||||
//! 判据是「有没有待办动作」而不是严重度。toast 允许自动消失的前提:每条
|
||||
//! 同时落一行日志——toast 不承载唯一副本,错过那几秒的用户仍能在日志里
|
||||
//! 查到。
|
||||
//!
|
||||
//! 与旧壳合同的已知差异(组件库行为):停留 5s(旧壳 4.2s)、无同屏三条
|
||||
//! 上限(5s 自动退场,溢出概率低);重复冷却在本层补齐(600ms,同文本)。
|
||||
//! 与旧壳合同的已知差异:默认停留 5s(旧壳 4.2s);同屏保留最新三条,
|
||||
//! 重复冷却在本层补齐(600ms,同文本)。
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -29,6 +31,8 @@ pub use crate::display::ToastKind;
|
||||
/// 同一文本短时间连发只刷新语境,不叠一摞相同卡片(旧壳
|
||||
/// `DUPLICATE_COOLDOWN` 同值同义;只挡逐字相同的文本)。
|
||||
const DUPLICATE_COOLDOWN: Duration = Duration::from_millis(600);
|
||||
const TOAST_TTL: Duration = Duration::from_secs(5);
|
||||
const MAX_NOTIFICATIONS: usize = 3;
|
||||
|
||||
/// 组件库 Notification 默认固定 `w_112`,短短一句也会铺成近半个窗口。
|
||||
/// Nebula 的 toast 按内容收缩;过长消息到此上限后自然换行。
|
||||
@@ -53,7 +57,11 @@ static NEXT_AI_TOAST: AtomicU64 = AtomicU64::new(1);
|
||||
/// A component notification identity, not a separate queue or lifetime manager.
|
||||
struct AiToast;
|
||||
|
||||
/// Apply preference changes to existing in-app notices in every open window.
|
||||
#[cfg(all(test, feature = "gpui-test-support"))]
|
||||
mod duration_tests;
|
||||
|
||||
/// Apply the AI visibility switch to existing notices in every open window.
|
||||
/// Duration changes apply at insertion or refresh, not to already visible cards.
|
||||
/// Native system notifications have a separate delivery path and remain intact.
|
||||
pub(crate) fn init(cx: &mut App) {
|
||||
cx.observe_global::<super::config::Settings>(|cx| {
|
||||
@@ -122,18 +130,15 @@ fn note(kind: ToastKind, text: String) -> Notification {
|
||||
/// 到当前 effect cycle 结束,正常运行期仍同步推送。
|
||||
/// 允许少数业务通知复用 Nebula 的启动期 Root 守卫和右下角宿主;通知内容与
|
||||
/// 动作仍由业务模块定义,避免把更新等领域状态塞进通用 toast 层。
|
||||
pub(crate) fn push_notification(window: &mut Window, cx: &mut App, notification: Notification) {
|
||||
if window.root::<Root>().flatten().is_some() {
|
||||
window.push_notification(notification, cx);
|
||||
} else {
|
||||
window.defer(cx, move |window, cx| {
|
||||
if window.root::<Root>().flatten().is_some() {
|
||||
window.push_notification(notification, cx);
|
||||
} else {
|
||||
log::warn!("notification dropped because the window Root is unavailable");
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `default_timeout` preserves the caller's original lifetime in default mode;
|
||||
/// `None` means the card originally remained until dismissed.
|
||||
pub(crate) fn push_notification(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
notification: Notification,
|
||||
default_timeout: Option<Duration>,
|
||||
) {
|
||||
push_with_duration(window, cx, notification, default_timeout, false);
|
||||
}
|
||||
|
||||
/// Nebula 的通知层定位:右下角固定 20px 安全边距,最新一条在最下,旧的
|
||||
@@ -146,8 +151,8 @@ pub fn render_layer(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
|
||||
let mut items = list.read(cx).notifications();
|
||||
// 与旧壳同屏上限一致。保留队尾三条,顺序仍是旧→新,因此 v_flex
|
||||
// 底部锚定后最新项自然落在最下面。
|
||||
if items.len() > 3 {
|
||||
items.drain(..items.len() - 3);
|
||||
if items.len() > MAX_NOTIFICATIONS {
|
||||
items.drain(..items.len() - MAX_NOTIFICATIONS);
|
||||
}
|
||||
if items.is_empty() {
|
||||
return None;
|
||||
@@ -170,17 +175,17 @@ pub fn render_layer(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
|
||||
)
|
||||
}
|
||||
|
||||
/// 弹一条自动消失的提示(toast 层)。空文本与 600ms 内的重复文本静默丢弃。
|
||||
/// 弹一条短提示(默认自动消失)。空文本与 600ms 内的重复文本静默丢弃。
|
||||
pub fn toast(window: &mut Window, cx: &mut App, kind: ToastKind, text: impl Into<String>) {
|
||||
let text = text.into();
|
||||
if text.trim().is_empty() || is_duplicate(&text) {
|
||||
return;
|
||||
}
|
||||
log::info!("toast [{kind:?}]: {text}");
|
||||
push_notification(window, cx, note(kind, text));
|
||||
push_notification(window, cx, note(kind, text), Some(TOAST_TTL));
|
||||
}
|
||||
|
||||
/// 驻留一条消息(消息栏层):停留远长于 toast,但**有上限**,见
|
||||
/// 驻留一条消息(消息栏层):默认停留远长于 toast,但**有上限**,见
|
||||
/// [`BANNER_TTL`]。用于「值得驻留」的事——没什么值得驻留的请用 [`toast`]。
|
||||
///
|
||||
/// 这里也挂一个空的 `on_click`:组件库的点击包装会先 `dismiss` 再调回调,
|
||||
@@ -317,13 +322,23 @@ fn reply_to_confirmation(
|
||||
}
|
||||
|
||||
fn push_banner(window: &mut Window, cx: &mut App, notification: Notification, ai_toast: bool) {
|
||||
push_with_duration(window, cx, notification, Some(BANNER_TTL), ai_toast);
|
||||
}
|
||||
|
||||
fn push_with_duration(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
notification: Notification,
|
||||
default_timeout: Option<Duration>,
|
||||
ai_toast: bool,
|
||||
) {
|
||||
if ai_toast && !super::config::ai_toasts_enabled(cx) {
|
||||
return;
|
||||
}
|
||||
let note = notification.autohide(false);
|
||||
if window.root::<Root>().flatten().is_some() {
|
||||
window.push_notification(note, cx);
|
||||
schedule_banner_dismiss(window, cx);
|
||||
finish_notification_insertion(window, cx, default_timeout);
|
||||
} else {
|
||||
window.defer(cx, move |window, cx| {
|
||||
// The preference may change before the startup Root is installed.
|
||||
@@ -332,28 +347,45 @@ fn push_banner(window: &mut Window, cx: &mut App, notification: Notification, ai
|
||||
}
|
||||
if window.root::<Root>().flatten().is_some() {
|
||||
window.push_notification(note, cx);
|
||||
schedule_banner_dismiss(window, cx);
|
||||
finish_notification_insertion(window, cx, default_timeout);
|
||||
} else {
|
||||
log::warn!("banner dropped because the window Root is unavailable");
|
||||
log::warn!("notification dropped because the window Root is unavailable");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 给刚推入的驻留消息挂一个到点自尽的定时器。
|
||||
/// Release cards outside the visible stack, then schedule the inserted card's expiry.
|
||||
/// Persistent cards have no timer. Retiring a hidden card emits the component's
|
||||
/// removal event without invoking click, action or close callbacks. As with identity
|
||||
/// replacement, retirement discards presentation only; domain state stays with its owner.
|
||||
///
|
||||
/// 组件库把 autohide 做成 bool、5s 写死在 `NotificationList::push` 里,没有
|
||||
/// 自定义时长的入口;为这个改 fork 要动 rev pin,不值得。换个办法:push 之后
|
||||
/// 队尾那条就是我们刚推的,抓住它的**弱**引用,到点自己调 `dismiss`。用户
|
||||
/// 提前点掉时弱引用已经失效,定时器什么也不做,不会去误关后来的消息。
|
||||
fn schedule_banner_dismiss(window: &mut Window, cx: &mut App) {
|
||||
fn finish_notification_insertion(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
default_timeout: Option<Duration>,
|
||||
) {
|
||||
let Some(Some(root)) = window.root::<Root>() else { return };
|
||||
let list = root.read(cx).notification.clone();
|
||||
let Some(note) = list.read(cx).notifications().last().cloned() else { return };
|
||||
let notes = list.read(cx).notifications();
|
||||
for note in notes.iter().take(notes.len().saturating_sub(MAX_NOTIFICATIONS)) {
|
||||
note.update(cx, |_, cx| cx.emit(gpui::DismissEvent));
|
||||
}
|
||||
let timeout = cx
|
||||
.try_global::<super::config::Settings>()
|
||||
.map(|settings| settings.notification_duration)
|
||||
.unwrap_or_default()
|
||||
.timeout(default_timeout);
|
||||
let Some(timeout) = timeout else { return };
|
||||
let Some(note) = notes.last() else { return };
|
||||
let note = note.downgrade();
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
cx.background_executor().timer(BANNER_TTL).await;
|
||||
cx.background_executor().timer(timeout).await;
|
||||
let _ = cx.update(|window, cx| {
|
||||
if let Some(note) = note.upgrade() {
|
||||
note.update(cx, |note, cx| note.dismiss(window, cx));
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
use super::*;
|
||||
use gpui::{
|
||||
AppContext as _, BorrowAppContext as _, Context, EntityId, Render, TestAppContext,
|
||||
VisualTestContext,
|
||||
};
|
||||
use nebula_settings::NotificationDuration;
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
use crate::gpui_shell::config::Settings;
|
||||
use crate::gpui_shell::terminal::confirmation::BinaryConfirmation;
|
||||
|
||||
struct Empty;
|
||||
|
||||
impl Render for Empty {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl gpui::IntoElement {
|
||||
div()
|
||||
}
|
||||
}
|
||||
|
||||
fn open(duration: NotificationDuration, cx: &mut TestAppContext) -> VisualTestContext {
|
||||
cx.update(|cx| {
|
||||
gpui_component::init(cx);
|
||||
let mut settings = Settings::load(nebula_settings::ThemeName::Nord);
|
||||
settings.ai_toasts = true;
|
||||
settings.notification_duration = duration;
|
||||
cx.set_global(settings);
|
||||
init(cx);
|
||||
});
|
||||
let (_, window) = cx.add_window_view(|window, cx| Root::new(cx.new(|_| Empty), window, cx));
|
||||
window.clone()
|
||||
}
|
||||
|
||||
fn ids(cx: &mut VisualTestContext) -> Vec<EntityId> {
|
||||
cx.update(|window, cx| window.notifications(cx).iter().map(|note| note.entity_id()).collect())
|
||||
}
|
||||
|
||||
fn advance(cx: &mut VisualTestContext, duration: Duration) {
|
||||
cx.run_until_parked();
|
||||
cx.background_executor.advance_clock(duration);
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn settle_dismissal(cx: &mut VisualTestContext) {
|
||||
advance(cx, Duration::from_millis(250));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn every_timed_mode_expires_at_its_selected_duration_without_activating_actions(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
for duration in [
|
||||
NotificationDuration::FiveSeconds,
|
||||
NotificationDuration::TenSeconds,
|
||||
NotificationDuration::ThirtySeconds,
|
||||
NotificationDuration::NinetySeconds,
|
||||
] {
|
||||
// Exercise every kind without overflowing the three visible slots.
|
||||
for action_cards in [false, true] {
|
||||
let mut window = open(duration, cx);
|
||||
let actions = Rc::new(Cell::new(0));
|
||||
let invoked = actions.clone();
|
||||
let update_action = actions.clone();
|
||||
window.update(|window, cx| {
|
||||
if action_cards {
|
||||
push_notification(
|
||||
window,
|
||||
cx,
|
||||
note(ToastKind::Info, "Update-style action fixture".into())
|
||||
.on_click(move |_, _, _| update_action.set(update_action.get() + 1)),
|
||||
None,
|
||||
);
|
||||
push_banner(
|
||||
window,
|
||||
cx,
|
||||
note(ToastKind::Info, "Timed fixture".into())
|
||||
.id::<AiToast>()
|
||||
.on_click(move |_, _, _| invoked.set(invoked.get() + 1)),
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
toast(window, cx, ToastKind::Success, format!("Timed toast {duration:?}"));
|
||||
banner(window, cx, ToastKind::Warning, "Timed non-AI banner");
|
||||
}
|
||||
});
|
||||
advance(&mut window, duration.timeout(None).unwrap() - Duration::from_millis(1));
|
||||
assert_eq!(ids(&mut window).len(), 2, "every card kind uses the selected lifetime");
|
||||
advance(&mut window, Duration::from_millis(1));
|
||||
settle_dismissal(&mut window);
|
||||
assert!(ids(&mut window).is_empty());
|
||||
assert_eq!(actions.get(), 0, "autohide must not perform the card's action");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn persistent_mode_has_no_expiry_and_retains_manual_dismissal(cx: &mut TestAppContext) {
|
||||
for action_cards in [false, true] {
|
||||
let mut window = open(NotificationDuration::Persistent, cx);
|
||||
window.update(|window, cx| {
|
||||
if action_cards {
|
||||
push_notification(
|
||||
window,
|
||||
cx,
|
||||
note(ToastKind::Info, "Persistent update fixture".into()),
|
||||
None,
|
||||
);
|
||||
confirmation_for_pane(
|
||||
window,
|
||||
cx,
|
||||
"Persistent confirmation".into(),
|
||||
8201,
|
||||
BinaryConfirmation { id: 8301, question: "Continue?".into() },
|
||||
);
|
||||
} else {
|
||||
toast(window, cx, ToastKind::Success, "Persistent ordinary toast");
|
||||
banner(window, cx, ToastKind::Warning, "Persistent non-AI banner");
|
||||
}
|
||||
});
|
||||
advance(&mut window, Duration::from_secs(3600));
|
||||
assert_eq!(ids(&mut window).len(), 2);
|
||||
window.update(|window, cx| {
|
||||
for notification in window.notifications(cx).iter() {
|
||||
notification.update(cx, |notification, cx| notification.dismiss(window, cx));
|
||||
}
|
||||
});
|
||||
settle_dismissal(&mut window);
|
||||
assert!(ids(&mut window).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn refreshed_confirmation_gets_a_new_deadline_even_if_the_old_entity_is_retained(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let mut window = open(NotificationDuration::FiveSeconds, cx);
|
||||
let push = |window: &mut Window, cx: &mut App| {
|
||||
confirmation_for_pane(
|
||||
window,
|
||||
cx,
|
||||
"Refreshed confirmation".into(),
|
||||
8202,
|
||||
BinaryConfirmation { id: 8302, question: "Continue?".into() },
|
||||
);
|
||||
};
|
||||
let old = window.update(|window, cx| {
|
||||
push(window, cx);
|
||||
window.notifications(cx)[0].clone()
|
||||
});
|
||||
advance(&mut window, Duration::from_secs(3));
|
||||
window.update(push);
|
||||
let refreshed = ids(&mut window);
|
||||
assert_eq!(refreshed.len(), 1);
|
||||
assert_ne!(refreshed[0], old.entity_id());
|
||||
advance(&mut window, Duration::from_secs(2));
|
||||
settle_dismissal(&mut window);
|
||||
assert_eq!(ids(&mut window), refreshed, "the original deadline cannot close the replacement");
|
||||
advance(&mut window, Duration::from_secs(3));
|
||||
settle_dismissal(&mut window);
|
||||
assert!(ids(&mut window).is_empty());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_refreshed_persistent_card_cannot_be_closed_by_its_predecessors_timer(cx: &mut TestAppContext) {
|
||||
let mut window = open(NotificationDuration::FiveSeconds, cx);
|
||||
let push = |window: &mut Window, cx: &mut App| {
|
||||
confirmation_for_pane(
|
||||
window,
|
||||
cx,
|
||||
"Mode switch fixture".into(),
|
||||
8203,
|
||||
BinaryConfirmation { id: 8303, question: "Continue?".into() },
|
||||
);
|
||||
};
|
||||
window.update(push);
|
||||
advance(&mut window, Duration::from_secs(3));
|
||||
window.update(|window, cx| {
|
||||
cx.update_global::<Settings, _>(|settings, _| {
|
||||
settings.notification_duration = NotificationDuration::Persistent
|
||||
});
|
||||
push(window, cx);
|
||||
});
|
||||
let refreshed = ids(&mut window);
|
||||
advance(&mut window, Duration::from_secs(3600));
|
||||
assert_eq!(ids(&mut window), refreshed);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn default_mode_preserves_short_toasts_banners_and_persistent_update_notices(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
for ai_banner in [false, true] {
|
||||
let mut window = open(NotificationDuration::Default, cx);
|
||||
let persistent = window.update(|window, cx| {
|
||||
push_notification(
|
||||
window,
|
||||
cx,
|
||||
note(ToastKind::Info, "Default update fixture".into()),
|
||||
None,
|
||||
);
|
||||
let persistent = window.notifications(cx)[0].entity_id();
|
||||
if ai_banner {
|
||||
confirmation_for_pane(
|
||||
window,
|
||||
cx,
|
||||
"Default confirmation fixture".into(),
|
||||
8204,
|
||||
BinaryConfirmation { id: 8304, question: "Continue?".into() },
|
||||
);
|
||||
} else {
|
||||
banner(window, cx, ToastKind::Warning, "Default non-AI configuration fixture");
|
||||
}
|
||||
toast(
|
||||
window,
|
||||
cx,
|
||||
ToastKind::Success,
|
||||
format!("Default ordinary copy fixture {ai_banner}"),
|
||||
);
|
||||
persistent
|
||||
});
|
||||
assert_eq!(ids(&mut window).len(), 3);
|
||||
advance(&mut window, Duration::from_secs(5));
|
||||
settle_dismissal(&mut window);
|
||||
assert_eq!(ids(&mut window).len(), 2);
|
||||
advance(&mut window, Duration::from_secs(85));
|
||||
settle_dismissal(&mut window);
|
||||
assert_eq!(ids(&mut window), vec![persistent]);
|
||||
advance(&mut window, Duration::from_secs(3600));
|
||||
assert_eq!(ids(&mut window), vec![persistent]);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn duration_still_applies_to_non_ai_cards_when_ai_visibility_is_off(cx: &mut TestAppContext) {
|
||||
let mut window = open(NotificationDuration::TenSeconds, cx);
|
||||
window.update(|window, cx| {
|
||||
cx.update_global::<Settings, _>(|settings, _| settings.ai_toasts = false);
|
||||
toast(window, cx, ToastKind::Success, "Independent duration toast");
|
||||
banner(window, cx, ToastKind::Warning, "Independent duration banner");
|
||||
push_notification(
|
||||
window,
|
||||
cx,
|
||||
note(ToastKind::Info, "Independent update fixture".into()),
|
||||
None,
|
||||
);
|
||||
confirmation_for_pane(
|
||||
window,
|
||||
cx,
|
||||
"Hidden AI fixture".into(),
|
||||
8205,
|
||||
BinaryConfirmation { id: 8305, question: "Continue?".into() },
|
||||
);
|
||||
});
|
||||
assert_eq!(ids(&mut window).len(), 3);
|
||||
advance(&mut window, Duration::from_secs(10));
|
||||
settle_dismissal(&mut window);
|
||||
assert!(ids(&mut window).is_empty());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_duration_change_does_not_reschedule_already_visible_cards(cx: &mut TestAppContext) {
|
||||
let mut window = open(NotificationDuration::FiveSeconds, cx);
|
||||
window.update(|window, cx| banner(window, cx, ToastKind::Info, "Already visible fixture"));
|
||||
advance(&mut window, Duration::from_secs(2));
|
||||
let persistent = window.update(|window, cx| {
|
||||
cx.update_global::<Settings, _>(|settings, _| {
|
||||
settings.notification_duration = NotificationDuration::Persistent
|
||||
});
|
||||
banner(window, cx, ToastKind::Info, "New persistent fixture");
|
||||
window.notifications(cx).last().unwrap().entity_id()
|
||||
});
|
||||
advance(&mut window, Duration::from_secs(3));
|
||||
settle_dismissal(&mut window);
|
||||
assert_eq!(ids(&mut window), vec![persistent]);
|
||||
advance(&mut window, Duration::from_secs(3600));
|
||||
assert_eq!(ids(&mut window), vec![persistent]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn startup_notifications_use_the_latest_duration_after_the_root_is_installed(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let _ = open(NotificationDuration::FiveSeconds, cx);
|
||||
let (_, window) = cx.add_window_view(|window, cx| {
|
||||
toast(window, cx, ToastKind::Success, "Deferred startup duration fixture");
|
||||
banner(window, cx, ToastKind::Warning, "Deferred startup banner fixture");
|
||||
cx.update_global::<Settings, _>(|settings, _| {
|
||||
settings.notification_duration = NotificationDuration::Persistent
|
||||
});
|
||||
Root::new(cx.new(|_| Empty), window, cx)
|
||||
});
|
||||
let mut window = window.clone();
|
||||
advance(&mut window, Duration::from_secs(3600));
|
||||
assert_eq!(ids(&mut window).len(), 2);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn persistent_notification_bursts_release_hidden_cards_without_running_actions(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let mut window = open(NotificationDuration::Persistent, cx);
|
||||
let actions = Rc::new(Cell::new(0));
|
||||
let notices = window.update(|window, cx| {
|
||||
(0..128)
|
||||
.map(|index| {
|
||||
let invoked = actions.clone();
|
||||
push_notification(
|
||||
window,
|
||||
cx,
|
||||
note(ToastKind::Info, format!("Persistent burst {index}"))
|
||||
.on_click(move |_, _, _| invoked.set(invoked.get() + 1)),
|
||||
None,
|
||||
);
|
||||
window.notifications(cx).last().unwrap().downgrade()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
advance(&mut window, Duration::from_secs(3600));
|
||||
let expected =
|
||||
notices.iter().rev().take(3).rev().map(|note| note.entity_id()).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&mut window), expected, "hidden persistent cards must not form a backlog");
|
||||
assert!(notices[..125].iter().all(|note| note.upgrade().is_none()));
|
||||
assert_eq!(actions.get(), 0);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn overflow_removal_cannot_dismiss_a_refreshed_confirmation(cx: &mut TestAppContext) {
|
||||
let mut window = open(NotificationDuration::Persistent, cx);
|
||||
let (old, expected) = window.update(|window, cx| {
|
||||
let push = |window: &mut Window, cx: &mut App| {
|
||||
confirmation_for_pane(
|
||||
window,
|
||||
cx,
|
||||
"Overflow confirmation".into(),
|
||||
8206,
|
||||
BinaryConfirmation { id: 8306, question: "Continue?".into() },
|
||||
);
|
||||
};
|
||||
push(window, cx);
|
||||
let old = window.notifications(cx)[0].clone();
|
||||
for index in 0..3 {
|
||||
banner(window, cx, ToastKind::Info, format!("Newer card {index}"));
|
||||
}
|
||||
// The old entity's removal event has not been delivered yet.
|
||||
push(window, cx);
|
||||
let notes = window.notifications(cx);
|
||||
let expected =
|
||||
notes.iter().rev().take(3).rev().map(|note| note.entity_id()).collect::<Vec<_>>();
|
||||
assert_ne!(old.entity_id(), *expected.last().unwrap());
|
||||
(old, expected)
|
||||
});
|
||||
advance(&mut window, Duration::from_secs(3600));
|
||||
assert_eq!(ids(&mut window), expected);
|
||||
drop(old);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const UPDATE_DIALOG_STATUS_HEIGHT: f32 = 280.0;
|
||||
|
||||
struct UpdateNotification;
|
||||
|
||||
/// 自动检查只在右下角提示,不抢终端焦点;更新是待办,因此保持到用户处理。
|
||||
/// 自动检查只在右下角提示,不抢终端焦点;默认常驻,也遵循通知时长设置。
|
||||
pub(crate) fn show_update_notification(
|
||||
result: crate::update_check::UpdateCheckResult,
|
||||
window: &mut Window,
|
||||
@@ -42,7 +42,6 @@ pub(crate) fn show_update_notification(
|
||||
let notification = Notification::warning(message)
|
||||
.id::<UpdateNotification>()
|
||||
.title(title)
|
||||
.autohide(false)
|
||||
.w_auto()
|
||||
.min_w(px(300.0))
|
||||
.max_w(px(440.0))
|
||||
@@ -60,7 +59,7 @@ pub(crate) fn show_update_notification(
|
||||
result.latest,
|
||||
result.current
|
||||
);
|
||||
crate::gpui_shell::toast::push_notification(window, cx, notification);
|
||||
crate::gpui_shell::toast::push_notification(window, cx, notification, None);
|
||||
}
|
||||
|
||||
fn start_update_download(
|
||||
@@ -169,7 +168,6 @@ fn show_download_outcome_notification(
|
||||
notification = notification
|
||||
.id::<UpdateNotification>()
|
||||
.title(title)
|
||||
.autohide(false)
|
||||
.w_auto()
|
||||
.min_w(px(320.0))
|
||||
.max_w(px(460.0))
|
||||
@@ -182,7 +180,7 @@ fn show_download_outcome_notification(
|
||||
}),
|
||||
)
|
||||
});
|
||||
crate::gpui_shell::toast::push_notification(window, cx, notification);
|
||||
crate::gpui_shell::toast::push_notification(window, cx, notification, None);
|
||||
}
|
||||
|
||||
/// 关闭、Esc 与遮罩点击均走“3 天后提醒”;这正是新 Dialog 的取消合同。
|
||||
|
||||
@@ -24,6 +24,8 @@ pub use custom_theme::{
|
||||
meets_wcag_aa, wcag_contrast_ratio,
|
||||
};
|
||||
mod language;
|
||||
mod notifications;
|
||||
pub use notifications::NotificationDuration;
|
||||
mod quick_terminal;
|
||||
mod scrolling;
|
||||
pub use scrolling::{
|
||||
@@ -1003,6 +1005,8 @@ pub struct RuntimeSettings {
|
||||
/// AI message toasts inside the application. System notifications and
|
||||
/// terminal/tab state are independent. Default on for existing users.
|
||||
pub ai_toasts: bool,
|
||||
/// Display lifetime for in-app cards; default mode retains each kind's lifetime.
|
||||
pub notification_duration: NotificationDuration,
|
||||
/// 新会话欢迎屏 fastfetch(默认关:启动速度优先于观感,旧壳裁定)。
|
||||
pub fetch: bool,
|
||||
/// Check GitHub Releases after startup. Manual checks remain available
|
||||
@@ -1177,6 +1181,10 @@ impl RuntimeSettings {
|
||||
.unwrap_or_default(),
|
||||
bell: raw.value("bell").and_then(BellModeName::from_settings).unwrap_or_default(),
|
||||
ai_toasts: raw.bool_on("ai_toasts").unwrap_or(true),
|
||||
notification_duration: raw
|
||||
.value("notification_duration")
|
||||
.and_then(NotificationDuration::from_settings)
|
||||
.unwrap_or_default(),
|
||||
fetch: raw.bool_on("fetch").unwrap_or(false),
|
||||
auto_check_updates: raw.bool_on("auto_check_updates").unwrap_or(true),
|
||||
auto_download_updates: raw.bool_on("auto_download_updates").unwrap_or(false),
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Shared in-app notification lifetime, independent of delivery and approvals.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum NotificationDuration {
|
||||
#[default]
|
||||
Default,
|
||||
FiveSeconds,
|
||||
TenSeconds,
|
||||
ThirtySeconds,
|
||||
NinetySeconds,
|
||||
Persistent,
|
||||
}
|
||||
|
||||
impl NotificationDuration {
|
||||
pub const VALUES: &'static [&'static str] = &["default", "5", "10", "30", "90", "persistent"];
|
||||
|
||||
pub fn from_settings(value: &str) -> Option<Self> {
|
||||
match value.trim() {
|
||||
value if value.eq_ignore_ascii_case("default") => Some(Self::Default),
|
||||
"5" => Some(Self::FiveSeconds),
|
||||
"10" => Some(Self::TenSeconds),
|
||||
"30" => Some(Self::ThirtySeconds),
|
||||
"90" => Some(Self::NinetySeconds),
|
||||
value if value.eq_ignore_ascii_case("persistent") => Some(Self::Persistent),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "default",
|
||||
Self::FiveSeconds => "5",
|
||||
Self::TenSeconds => "10",
|
||||
Self::ThirtySeconds => "30",
|
||||
Self::NinetySeconds => "90",
|
||||
Self::Persistent => "persistent",
|
||||
}
|
||||
}
|
||||
|
||||
/// Retain each caller's previous lifetime unless the user overrides it.
|
||||
/// `None` is an originally persistent notification, such as an update action.
|
||||
pub fn timeout(self, default: Option<Duration>) -> Option<Duration> {
|
||||
match self {
|
||||
Self::Default => default,
|
||||
Self::FiveSeconds => Some(Duration::from_secs(5)),
|
||||
Self::TenSeconds => Some(Duration::from_secs(10)),
|
||||
Self::ThirtySeconds => Some(Duration::from_secs(30)),
|
||||
Self::NinetySeconds => Some(Duration::from_secs(90)),
|
||||
Self::Persistent => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{RawSettings, RuntimeSettings, apply_updates};
|
||||
|
||||
#[test]
|
||||
fn missing_or_invalid_duration_preserves_each_notifications_existing_lifetime() {
|
||||
assert_eq!(
|
||||
RuntimeSettings::from_raw(&RawSettings::from_text("")).notification_duration,
|
||||
NotificationDuration::Default
|
||||
);
|
||||
for value in ["", "invalid", "-1", "0", "1", "999999999999999999999"] {
|
||||
let settings = RuntimeSettings::from_raw(&RawSettings::from_text(&format!(
|
||||
"notification_duration={value}\n"
|
||||
)));
|
||||
assert_eq!(settings.notification_duration, NotificationDuration::Default);
|
||||
for default in [Some(Duration::from_secs(5)), Some(Duration::from_secs(90)), None] {
|
||||
assert_eq!(settings.notification_duration.timeout(default), default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_duration_round_trips_without_changing_the_delivery_switch_or_other_data() {
|
||||
for value in NotificationDuration::VALUES {
|
||||
let saved = apply_updates(
|
||||
"ai_toasts=0\ncustom_data=keep\n",
|
||||
&[("notification_duration", (*value).to_owned())],
|
||||
);
|
||||
let settings = RuntimeSettings::from_raw(&RawSettings::from_text(&saved));
|
||||
assert_eq!(settings.notification_duration.settings_value(), *value);
|
||||
assert!(!settings.ai_toasts);
|
||||
assert!(saved.contains("custom_data=keep"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_and_persistent_modes_have_explicit_lifetimes() {
|
||||
for (value, seconds) in [("5", 5), ("10", 10), ("30", 30), ("90", 90)] {
|
||||
assert_eq!(
|
||||
NotificationDuration::from_settings(value).unwrap().timeout(None),
|
||||
Some(Duration::from_secs(seconds))
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
NotificationDuration::from_settings(" PERSISTENT ")
|
||||
.unwrap()
|
||||
.timeout(Some(Duration::from_secs(5))),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
NotificationDuration::from_settings(" DEFAULT "),
|
||||
Some(NotificationDuration::Default)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ const RESET_KEYS: &[&str] = &[
|
||||
"vcs_display",
|
||||
"bell",
|
||||
"ai_toasts",
|
||||
"notification_duration",
|
||||
"fetch",
|
||||
"auto_check_updates",
|
||||
"auto_download_updates",
|
||||
@@ -156,6 +157,16 @@ mod tests {
|
||||
assert!(RuntimeSettings::from_raw(&RawSettings::from_text(&restored)).ai_toasts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resetting_preferences_restores_the_default_notification_duration() {
|
||||
let original = "notification_duration=persistent\ncustom_data=keep\n";
|
||||
let restored = default_settings_text(original);
|
||||
let runtime = RuntimeSettings::from_raw(&RawSettings::from_text(&restored));
|
||||
assert_eq!(runtime.notification_duration, crate::NotificationDuration::Default);
|
||||
assert!(restored.contains("custom_data=keep"));
|
||||
assert!(!restored.contains("notification_duration="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_removes_all_overrides_and_keeps_user_data() {
|
||||
let text = "# preferences\r\n THEME = Nord\r\ncopy_on_select=1\nkeybind=ctrl+x:Copy\nFONT_SIZE=30\nexecutor=custom\nblur=acrylic\nopacity=0.65\nbackground=#101216\ntheme_foreground=#d6dae6\ncustom_theme=my-night\nssh_hosts=saved-host\nai_provider=custom\nfuture_setting=keep\n";
|
||||
|
||||
Reference in new issue
Block a user