Skip to main content

purple_ssh/app/
status_state.rs

1use std::collections::VecDeque;
2use std::time::Instant;
3
4/// Status/toast-owned state grouped off the `App` god-struct. Contains the
5/// footer status message, the active toast and the toast queue. Pure state
6/// container plus the routing helpers that only touch these three fields.
7/// `tick_status` stays on `App` because it must read `syncing_providers` to
8/// suppress expiry during in-flight provider syncs.
9#[derive(Default)]
10pub struct StatusCenter {
11    pub(in crate::app) status: Option<StatusMessage>,
12    pub(in crate::app) toast: Option<StatusMessage>,
13    pub(in crate::app) toast_queue: VecDeque<StatusMessage>,
14}
15
16impl StatusCenter {
17    pub fn status(&self) -> Option<&StatusMessage> {
18        self.status.as_ref()
19    }
20
21    pub fn status_mut(&mut self) -> Option<&mut StatusMessage> {
22        self.status.as_mut()
23    }
24
25    pub fn toast(&self) -> Option<&StatusMessage> {
26        self.toast.as_ref()
27    }
28
29    pub fn toast_mut(&mut self) -> Option<&mut StatusMessage> {
30        self.toast.as_mut()
31    }
32
33    pub fn take_status(&mut self) -> Option<StatusMessage> {
34        self.status.take()
35    }
36
37    pub fn restore_status(&mut self, message: Option<StatusMessage>) {
38        self.status = message;
39    }
40
41    pub fn set_toast_message(&mut self, message: Option<StatusMessage>) {
42        self.toast = message;
43    }
44
45    /// Drop the active toast and queued entries whose body matches the
46    /// predicate. The active slot is refilled from the head of the queue.
47    pub fn drop_toasts_where(&mut self, mut matches: impl FnMut(&StatusMessage) -> bool) {
48        if self.toast.as_ref().is_some_and(&mut matches) {
49            self.toast = self.toast_queue.pop_front();
50        }
51        self.toast_queue.retain(|t| !matches(t));
52    }
53
54    pub fn set_status(&mut self, text: impl Into<String>, is_error: bool) {
55        let class = if is_error {
56            MessageClass::Error
57        } else {
58            MessageClass::Success
59        };
60        // Errors are sticky by default so the user cannot miss them.
61        let sticky = matches!(class, MessageClass::Error);
62        let msg = StatusMessage {
63            text: text.into(),
64            class,
65            tick_count: 0,
66            sticky,
67            created_at: std::time::Instant::now(),
68        };
69        if msg.is_toast() {
70            self.push_toast(msg);
71        } else {
72            log::debug!("footer <- {:?}: {}", msg.class, msg.text);
73            self.status = Some(msg);
74        }
75    }
76
77    /// Push a toast message. Success replaces any active toast immediately
78    /// (last-write-wins). Warning and Error promote over an active Success
79    /// toast and queue only behind another Warning or Error, so a user-
80    /// initiated guard ("Demo mode. X disabled.") is never starved by a
81    /// background hint that held the slot. The queue is capped at
82    /// `TOAST_QUEUE_MAX` to bound memory.
83    pub(crate) fn push_toast(&mut self, msg: StatusMessage) {
84        log::debug!("toast <- {:?}: {}", msg.class, msg.text);
85        if msg.class == MessageClass::Success {
86            self.toast = Some(msg);
87            self.toast_queue.clear();
88            return;
89        }
90        // Warning + Error path.
91        let active_blocks = self
92            .toast
93            .as_ref()
94            .is_some_and(|t| t.class != MessageClass::Success);
95        if active_blocks {
96            if self.toast_queue.len() >= crate::ui::design::TOAST_QUEUE_MAX {
97                if let Some(dropped) = self.toast_queue.front() {
98                    log::debug!("toast queue full, dropping: {}", dropped.text);
99                }
100                self.toast_queue.pop_front();
101            }
102            self.toast_queue.push_back(msg);
103        } else {
104            if let Some(ref dropped) = self.toast {
105                log::debug!(
106                    "toast promoted: replacing Success '{}' with {:?}",
107                    dropped.text,
108                    msg.class
109                );
110            }
111            self.toast = Some(msg);
112        }
113    }
114
115    /// Set an Info-class status message that displays in the footer only.
116    pub fn set_info_status(&mut self, text: impl Into<String>) {
117        let text = text.into();
118        log::debug!("footer <- Info: {}", text);
119        self.status = Some(StatusMessage {
120            text,
121            class: MessageClass::Info,
122            tick_count: 0,
123            sticky: false,
124            created_at: std::time::Instant::now(),
125        });
126    }
127
128    /// Like `notify` but skips the write when a sticky message is active.
129    /// Use for background/timer events (ping expiry, sync ticks) that must
130    /// not clobber an in-progress or critical sticky message.
131    /// Routes to Info (footer) by default, Error toast if is_error.
132    pub fn set_background_status(&mut self, text: impl Into<String>, is_error: bool) {
133        if is_error {
134            let msg = StatusMessage {
135                text: text.into(),
136                class: MessageClass::Error,
137                tick_count: 0,
138                sticky: true,
139                created_at: std::time::Instant::now(),
140            };
141            self.push_toast(msg);
142            return;
143        }
144        let text = text.into();
145        if self.status.as_ref().is_some_and(|s| s.sticky) {
146            log::debug!(
147                "[purple] background status suppressed (sticky active, dropped: {})",
148                text
149            );
150            return;
151        }
152        log::debug!("footer <- Info: {}", text);
153        self.status = Some(StatusMessage {
154            text,
155            class: MessageClass::Info,
156            tick_count: 0,
157            sticky: false,
158            created_at: std::time::Instant::now(),
159        });
160    }
161
162    /// Sticky messages always go to the footer (`self.status`), even when the
163    /// class is Error. The `sticky` flag overrides the normal toast routing
164    /// because sticky messages (Vault SSH signing summaries, progress spinners)
165    /// must remain visible in the footer until explicitly replaced.
166    pub fn set_sticky_status(&mut self, text: impl Into<String>, is_error: bool) {
167        let text = text.into();
168        let class = if is_error {
169            MessageClass::Error
170        } else {
171            MessageClass::Progress
172        };
173        log::debug!("footer <- sticky {:?}: {}", class, text);
174        self.status = Some(StatusMessage {
175            text,
176            class,
177            tick_count: 0,
178            sticky: true,
179            created_at: std::time::Instant::now(),
180        });
181    }
182
183    /// Clear the footer status unconditionally. Use when a new user action
184    /// makes the prior status stale (e.g. starting a ping run that will
185    /// post its own progress, or entering search mode). The sticky-aware
186    /// variant is `clear_sticky_status`.
187    pub(crate) fn clear_status(&mut self) {
188        if let Some(s) = &self.status {
189            log::debug!("footer <- clear: {}", s.text);
190        }
191        self.status = None;
192    }
193
194    /// Drop the sticky footer status (set by `set_sticky_status` /
195    /// `notify_progress`). Long-running operations call this when they
196    /// finish so the "Pushing X..." line does not linger after the
197    /// transient success/partial toast lands on top of it.
198    pub fn clear_sticky_status(&mut self) {
199        if let Some(s) = &self.status {
200            if s.sticky {
201                log::debug!("footer <- clear sticky: {}", s.text);
202                self.status = None;
203            }
204        }
205    }
206
207    /// Tick the toast message timer. Uses wall-clock time via `created_at`
208    /// so expiry is independent of the tick rate. Called every tick; the
209    /// actual check is `created_at.elapsed() > timeout_ms()`.
210    pub fn tick_toast(&mut self) {
211        if let Some(ref toast) = self.toast {
212            if toast.sticky {
213                return;
214            }
215            let timeout_ms = toast.timeout_ms();
216            if timeout_ms != u64::MAX && toast.created_at.elapsed().as_millis() as u64 > timeout_ms
217            {
218                log::debug!("toast expired: {}", toast.text);
219                self.toast = self.toast_queue.pop_front();
220            }
221        }
222    }
223}
224
225/// Classification of status messages for routing to toast overlay vs footer.
226///
227/// Five levels: Success / Info / Warning / Error / Progress. Severity rises
228/// from Info to Error. Toast vs footer routing follows attention-urgency:
229/// Success, Warning and Error draw the eye via toast; Info and Progress
230/// sit in the footer for passive consumption.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum MessageClass {
233    /// User action succeeded (copy, sort, delete). Toast, length-proportional timeout.
234    /// Color: green `\u{2713}`.
235    Success,
236    /// Background event (sync complete, config reload). Footer, length-proportional timeout.
237    /// Color: muted.
238    Info,
239    /// Caution or degraded state (stale hosts, deprecated config,
240    /// validation failure, empty-state notice). Toast, length-proportional
241    /// timeout (longer than Success). Auto-expires.
242    /// Color: yellow `\u{26A0}`.
243    Warning,
244    /// Error condition requiring acknowledgement. Toast, **sticky by default**
245    /// so the user cannot miss it. Cleared by next user action.
246    /// Color: red `\u{2716}`.
247    Error,
248    /// Long-running operation with spinner. Footer, sticky.
249    /// Color: muted with spinner.
250    Progress,
251}
252
253/// Status message displayed as toast overlay or in the footer.
254#[derive(Debug, Clone)]
255pub struct StatusMessage {
256    pub text: String,
257    pub class: MessageClass,
258    /// Retained for backward compatibility with tests that inspect it.
259    /// Expiry logic uses `created_at` (wall-clock) instead.
260    #[allow(dead_code)]
261    pub tick_count: u32,
262    /// When true the message never auto-expires and `notify_background`
263    /// will not overwrite it. Cleared by `notify` or `notify_progress`.
264    pub sticky: bool,
265    /// Wall-clock instant when the message was created. Used by the drain
266    /// bar renderer for smooth (frame-rate-independent) animation instead
267    /// of the discrete `tick_count`.
268    pub created_at: Instant,
269}
270
271impl StatusMessage {
272    /// Backward compat: is this an error- or warning-class message?
273    pub fn is_error(&self) -> bool {
274        matches!(self.class, MessageClass::Error | MessageClass::Warning)
275    }
276
277    /// Timeout in milliseconds for this message class.
278    ///
279    /// Length-proportional: shorter messages clear faster, longer messages
280    /// stay visible longer to give the user time to read. The minimum keeps
281    /// 1-word messages on screen long enough to register; the per-word
282    /// component scales with reading time. Errors and Progress are sticky
283    /// (return `u64::MAX`).
284    ///
285    /// All timing is in wall-clock milliseconds, independent of the tick
286    /// rate. Both `tick_toast` (expiry) and `render_toast` (drain bar)
287    /// compare `created_at.elapsed()` against this value.
288    pub fn timeout_ms(&self) -> u64 {
289        let words = self
290            .text
291            .split_whitespace()
292            .count()
293            .min(crate::ui::design::WORD_CAP) as u64;
294        let proportional = words.saturating_mul(crate::ui::design::MS_PER_WORD);
295        let min_ms = match self.class {
296            MessageClass::Success | MessageClass::Info => crate::ui::design::TIMEOUT_MIN_MS,
297            MessageClass::Warning => crate::ui::design::TIMEOUT_MIN_WARNING_MS,
298            MessageClass::Error | MessageClass::Progress => return u64::MAX,
299        };
300        min_ms.max(proportional)
301    }
302
303    /// Should this message render as a toast overlay?
304    pub fn is_toast(&self) -> bool {
305        matches!(
306            self.class,
307            MessageClass::Success | MessageClass::Warning | MessageClass::Error
308        )
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    fn msg(text: &str, class: MessageClass, sticky: bool) -> StatusMessage {
317        StatusMessage {
318            text: text.to_string(),
319            class,
320            tick_count: 0,
321            sticky,
322            created_at: std::time::Instant::now(),
323        }
324    }
325
326    #[test]
327    fn default_is_quiet() {
328        let s = StatusCenter::default();
329        assert!(s.status.is_none());
330        assert!(s.toast.is_none());
331        assert!(s.toast_queue.is_empty());
332    }
333
334    #[test]
335    fn test_set_status_info_populates_status_field() {
336        let mut s = StatusCenter::default();
337        // Info class is routed to the footer, not a toast.
338        s.set_info_status("hello");
339        assert!(s.status.is_some());
340        assert_eq!(s.status.as_ref().unwrap().text, "hello");
341        assert!(s.toast.is_none());
342    }
343
344    #[test]
345    fn test_set_status_error_is_routed_to_sticky_toast() {
346        let mut s = StatusCenter::default();
347        s.set_status("boom", true);
348        // Errors are toasts and sticky, so they live in `toast`.
349        assert!(s.toast.is_some());
350        let toast = s.toast.as_ref().unwrap();
351        assert_eq!(toast.class, MessageClass::Error);
352        assert!(toast.sticky);
353    }
354
355    #[test]
356    fn test_set_sticky_status_writes_footer_and_marks_sticky() {
357        let mut s = StatusCenter::default();
358        s.set_sticky_status("signing cert", false);
359        let footer = s.status.as_ref().expect("footer status set");
360        assert_eq!(footer.text, "signing cert");
361        assert_eq!(footer.class, MessageClass::Progress);
362        assert!(
363            footer.sticky,
364            "sticky progress message must stay until replaced"
365        );
366        // Sticky messages never go to the toast slot.
367        assert!(s.toast.is_none());
368    }
369
370    #[test]
371    fn tick_toast_advances_queue_once_active_expires() {
372        let mut s = StatusCenter::default();
373        // First warning occupies the active toast slot.
374        s.push_toast(msg("first", MessageClass::Warning, false));
375        // Second warning queues because the slot is taken.
376        s.push_toast(msg("second", MessageClass::Warning, false));
377        assert_eq!(s.toast.as_ref().unwrap().text, "first");
378        assert_eq!(s.toast_queue.len(), 1);
379
380        // Force the active toast into the expired state by rewinding
381        // created_at past its wall-clock timeout.
382        let expired_at = std::time::Instant::now()
383            .checked_sub(std::time::Duration::from_secs(60))
384            .expect("instant subtraction");
385        if let Some(active) = s.toast.as_mut() {
386            active.created_at = expired_at;
387        }
388        s.tick_toast();
389        // Queue drains into the active slot.
390        assert_eq!(s.toast.as_ref().unwrap().text, "second");
391        assert!(s.toast_queue.is_empty());
392    }
393
394    #[test]
395    fn tick_toast_does_not_expire_sticky_toast() {
396        let mut s = StatusCenter::default();
397        s.push_toast(msg("stay", MessageClass::Error, true));
398        let expired_at = std::time::Instant::now()
399            .checked_sub(std::time::Duration::from_secs(3600))
400            .expect("instant subtraction");
401        if let Some(active) = s.toast.as_mut() {
402            active.created_at = expired_at;
403        }
404        s.tick_toast();
405        assert!(s.toast.is_some(), "sticky toast must not expire");
406    }
407
408    #[test]
409    fn clear_status_drops_active_footer_status() {
410        let mut s = StatusCenter::default();
411        s.set_info_status("syncing aws");
412        assert!(s.status.is_some());
413        s.clear_status();
414        assert!(s.status.is_none());
415    }
416
417    #[test]
418    fn clear_status_also_drops_sticky_footer_status() {
419        // Sticky is intentional: callers that want sticky-aware semantics
420        // use `clear_sticky_status`. This variant is the heavy hammer.
421        let mut s = StatusCenter::default();
422        s.set_sticky_status("signing cert", false);
423        assert!(s.status.as_ref().is_some_and(|m| m.sticky));
424        s.clear_status();
425        assert!(s.status.is_none());
426    }
427
428    #[test]
429    fn clear_status_on_empty_is_noop() {
430        let mut s = StatusCenter::default();
431        s.clear_status();
432        assert!(s.status.is_none());
433        assert!(s.toast.is_none());
434    }
435
436    #[test]
437    fn clear_status_does_not_touch_active_toast() {
438        let mut s = StatusCenter::default();
439        s.set_info_status("info");
440        s.push_toast(msg("warn", MessageClass::Warning, false));
441        s.clear_status();
442        assert!(s.status.is_none(), "footer cleared");
443        assert!(s.toast.is_some(), "toast slot untouched");
444    }
445}