diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-06 17:41:44 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-06 17:42:38 +0300 |
| commit | 4001fd2ec8ff05a982da18744b8cbde9df3ab530 (patch) | |
| tree | f8dbf984cff9a4b025e86481d3f70c89322bd79f /src/app/mod.rs | |
| parent | 048d8a29195d580014b24ee3fc7e89ac6bdfee8a (diff) | |
feat: improve toast ergonomics
refs #486
Diffstat (limited to 'src/app/mod.rs')
| -rw-r--r-- | src/app/mod.rs | 185 |
1 files changed, 185 insertions, 0 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index a9408736..6a387c9a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -97,6 +97,7 @@ pub struct App { pub(crate) config_diagnostic_deadline: Option<Instant>, pub(crate) toast_deadline: Option<Instant>, pub(crate) copy_feedback_deadline: Option<Instant>, + pub(crate) last_api_notification_at: Option<Instant>, pub(crate) last_git_remote_status_refresh: Instant, pub(crate) git_refresh_in_flight: bool, pub(crate) git_refresh_due_after_in_flight: bool, @@ -479,6 +480,7 @@ impl App { update_dismissed: false, config_diagnostic, toast: None, + pending_agent_notifications: std::collections::HashMap::new(), copy_feedback: None, outer_terminal_focus: None, prefix_code, @@ -568,6 +570,7 @@ impl App { config_diagnostic_deadline: None, toast_deadline: None, copy_feedback_deadline: None, + last_api_notification_at: None, state, terminal_runtimes: restored_terminal_runtimes, event_tx, @@ -1263,6 +1266,7 @@ impl App { kind: crate::app::state::ToastKind::UpdateInstalled, title: "reloaded config".to_string(), context: "using config.toml".to_string(), + position: None, target: None, }); } @@ -1274,6 +1278,7 @@ impl App { kind: crate::app::state::ToastKind::UpdateInstalled, title: "reloaded config".to_string(), context: "with warnings".to_string(), + position: None, target: None, }); } @@ -1730,12 +1735,26 @@ mod tests { } #[test] + fn clipboard_feedback_can_be_disabled() { + let mut app = test_app(); + app.state.toast_config.clipboard.enabled = false; + + app.handle_internal_event(AppEvent::ClipboardWrite { + content: b"copied".to_vec(), + }); + + assert!(app.state.copy_feedback.is_none()); + assert!(app.copy_feedback_deadline.is_none()); + } + + #[test] fn clipboard_feedback_does_not_replace_notification_toast() { let mut app = test_app(); app.state.toast = Some(crate::app::state::ToastNotification { kind: crate::app::state::ToastKind::NeedsAttention, title: "pi needs attention".to_string(), context: "background · 2".to_string(), + position: None, target: None, }); let original_toast = app.state.toast.clone(); @@ -1755,6 +1774,172 @@ mod tests { } #[test] + fn notification_show_api_creates_herdr_toast_with_position() { + let mut app = test_app(); + app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + + let response = + app.handle_api_request_after_internal_events_drained(crate::api::schema::Request { + id: "notify".into(), + method: crate::api::schema::Method::NotificationShow( + crate::api::schema::NotificationShowParams { + title: "build failed".into(), + body: Some("api workspace".into()), + position: Some(crate::config::ToastHerdrPosition::TopLeft), + sound: crate::api::schema::NotificationShowSound::None, + }, + ), + }); + + let parsed: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + crate::api::schema::ResponseResult::NotificationShow { + shown: true, + reason: crate::api::schema::NotificationShowReason::Shown, + } + ); + let toast = app.state.toast.as_ref().expect("api toast"); + assert_eq!(toast.title, "build failed"); + assert_eq!(toast.context, "api workspace"); + assert_eq!( + toast.position, + Some(crate::config::ToastHerdrPosition::TopLeft) + ); + assert!(app.toast_deadline.is_some()); + } + + #[test] + fn notification_show_api_herdr_toast_expires() { + let mut app = test_app(); + app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + + let response = + app.handle_api_request_after_internal_events_drained(crate::api::schema::Request { + id: "notify".into(), + method: crate::api::schema::Method::NotificationShow( + crate::api::schema::NotificationShowParams { + title: "build failed".into(), + body: None, + position: None, + sound: crate::api::schema::NotificationShowSound::None, + }, + ), + }); + + let parsed: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + crate::api::schema::ResponseResult::NotificationShow { + shown: true, + reason: crate::api::schema::NotificationShowReason::Shown, + } + ); + let deadline = app.toast_deadline.expect("api toast deadline"); + assert!(app.handle_scheduled_tasks(deadline, false)); + assert!(app.state.toast.is_none()); + assert!(app.toast_deadline.is_none()); + } + + #[test] + fn notification_show_api_respects_off_delivery() { + let mut app = test_app(); + app.state.toast_config.delivery = crate::config::ToastDelivery::Off; + + let response = + app.handle_api_request_after_internal_events_drained(crate::api::schema::Request { + id: "notify".into(), + method: crate::api::schema::Method::NotificationShow( + crate::api::schema::NotificationShowParams { + title: "build failed".into(), + body: None, + position: None, + sound: crate::api::schema::NotificationShowSound::None, + }, + ), + }); + + let parsed: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + crate::api::schema::ResponseResult::NotificationShow { + shown: false, + reason: crate::api::schema::NotificationShowReason::Disabled, + } + ); + assert!(app.state.toast.is_none()); + } + + #[test] + fn notification_show_api_does_not_replace_existing_toast() { + let mut app = test_app(); + app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + app.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::NeedsAttention, + title: "pi needs attention".to_string(), + context: "background · 2".to_string(), + position: None, + target: None, + }); + + let response = + app.handle_api_request_after_internal_events_drained(crate::api::schema::Request { + id: "notify".into(), + method: crate::api::schema::Method::NotificationShow( + crate::api::schema::NotificationShowParams { + title: "build failed".into(), + body: None, + position: None, + sound: crate::api::schema::NotificationShowSound::None, + }, + ), + }); + + let parsed: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + crate::api::schema::ResponseResult::NotificationShow { + shown: false, + reason: crate::api::schema::NotificationShowReason::Busy, + } + ); + assert_eq!( + app.state.toast.as_ref().map(|toast| toast.title.as_str()), + Some("pi needs attention") + ); + } + + #[test] + fn notification_show_api_is_rate_limited() { + let mut app = test_app(); + app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + app.mark_api_notification_shown(Instant::now()); + + let response = + app.handle_api_request_after_internal_events_drained(crate::api::schema::Request { + id: "notify".into(), + method: crate::api::schema::Method::NotificationShow( + crate::api::schema::NotificationShowParams { + title: "build failed".into(), + body: None, + position: None, + sound: crate::api::schema::NotificationShowSound::None, + }, + ), + }); + + let parsed: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + crate::api::schema::ResponseResult::NotificationShow { + shown: false, + reason: crate::api::schema::NotificationShowReason::RateLimited, + } + ); + assert!(app.state.toast.is_none()); + } + + #[test] fn internal_event_drain_limits_work_per_tick() { let mut app = test_app(); for i in 0..=APP_EVENT_DRAIN_LIMIT { |
