Skip to main content

lattice_notify/
lib.rs

1//! Notifications: telling the user about work that has no buffer
2//! (NOTIF.1a).
3//!
4//! Design:
5//! [`../../../docs/dev/architecture/notifications.md`], which defers the
6//! specification itself to `design.md` §5.9.9 and records why the gate
7//! opened. In short: `C-c g f` (fetch) fires from any buffer, returns
8//! immediately with an optimistic echo, and on completion says
9//! **nothing** — success is invisible and failure reaches `*messages*`
10//! only. The echo area cannot fix that; it is one transient line
11//! written at *fire* time, with no completion event and no persistence.
12//!
13//! **Three surfaces, three questions**, which is what keeps them from
14//! competing: the headerline answers "what is the buffer I am looking
15//! at doing?", `*messages*` answers "what happened earlier?", and a
16//! notification answers "the thing I started has finished — wherever I
17//! am now". A fetch has no buffer, so it is the third.
18//!
19//! This crate is the **data layer**: the notification, the store, and
20//! expiry. Rendering is NOTIF.1b/c (both peers, one patch); magit is
21//! the first consumer, NOTIF.1d.
22
23pub mod mode;
24pub mod options;
25
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::{Arc, Mutex};
28use std::time::Duration;
29
30/// How loudly a notification reads, and how long it lingers.
31///
32/// Deliberately the same three levels `EchoLevel` already carries —
33/// two vocabularies for "how bad is this" would drift, and a consumer
34/// mapping between them is a bug waiting to happen.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum NotificationLevel {
37    #[default]
38    Info,
39    /// Work the user started finished cleanly.
40    ///
41    /// **A state, not a severity.** It is Info in every respect that
42    /// measures how bad something is — timeout, the `*messages*` tee —
43    /// and differs only in how it *reads*: a green check, so a finished
44    /// push is distinguishable at a glance from a neutral note. The
45    /// "same three levels as `EchoLevel`" rule is about severity, and
46    /// this adds none.
47    Success,
48    Warn,
49    Error,
50}
51
52impl NotificationLevel {
53    /// How long a notification of this level stays up by default.
54    ///
55    /// **Errors linger and warnings linger a little.** An error you
56    /// blink past is an error you will hit again; the whole point of
57    /// the subsystem is that a failed fetch stops being invisible. The
58    /// numbers are defaults — a caller may override, and
59    /// `notifications.default-timeout` will govern them once config
60    /// lands.
61    pub fn default_timeout(self) -> Duration {
62        match self {
63            NotificationLevel::Info | NotificationLevel::Success => Duration::from_secs(4),
64            NotificationLevel::Warn => Duration::from_secs(8),
65            NotificationLevel::Error => Duration::from_secs(16),
66        }
67    }
68
69    /// How much longer than an info notification this level lasts.
70    ///
71    /// One knob times a fixed ratio rather than three independent
72    /// options: raising the base must not leave errors relatively
73    /// SHORTER than the successes around them, and three knobs make
74    /// that misconfiguration reachable.
75    pub fn timeout_multiplier(self) -> u64 {
76        match self {
77            NotificationLevel::Info | NotificationLevel::Success => 1,
78            NotificationLevel::Warn => 2,
79            NotificationLevel::Error => 4,
80        }
81    }
82
83    pub fn label(self) -> &'static str {
84        match self {
85            NotificationLevel::Info => "info",
86            NotificationLevel::Success => "success",
87            NotificationLevel::Warn => "warn",
88            NotificationLevel::Error => "error",
89        }
90    }
91
92    /// The icon that leads this level's row, for the current palette.
93    ///
94    /// **One function both renderers and the `*notifications*` buffer
95    /// call**, so the three surfaces cannot drift apart. Nerd Fonts v3
96    /// glyphs when `ui.nerd_fonts` is on; otherwise a BMP fallback that
97    /// renders in every monospace font — the same shapes the diagnostic
98    /// gutter falls back to (`● ▲`), so "warning" looks the same
99    /// everywhere. Both palettes are one cell wide, so toggling the
100    /// option cannot shift the text that follows.
101    pub fn glyph(self, nerd_fonts: bool) -> &'static str {
102        match (self, nerd_fonts) {
103            // nf-fa-circle_info / circle_check / triangle_exclamation /
104            // circle_xmark.
105            (NotificationLevel::Info, true) => "\u{f05a}",
106            (NotificationLevel::Success, true) => "\u{f058}",
107            (NotificationLevel::Warn, true) => "\u{f071}",
108            (NotificationLevel::Error, true) => "\u{f057}",
109            (NotificationLevel::Info, false) => "\u{25cf}",
110            (NotificationLevel::Success, false) => "\u{2713}",
111            (NotificationLevel::Warn, false) => "\u{25b2}",
112            (NotificationLevel::Error, false) => "\u{2717}",
113        }
114    }
115}
116
117/// Identity for a posted notification — the handle a consumer keeps to
118/// replace or dismiss it.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
120pub struct NotificationId(pub u64);
121
122/// Something a notification offers to do about itself.
123///
124/// §5.9.9 specifies buttons; there is no focusable widget here and one
125/// is not wanted — the corner stays a pure *signal*, and
126/// `:notifications` is where you act (see `notifications-mode`). So an
127/// action is a label plus the [`lattice_grammar::Effect`] `<CR>`
128/// applies there.
129///
130/// A typed effect rather than an action *name*: a name has to resolve
131/// at fire time and can fail then — silently, on a key the user pressed
132/// deliberately. There is nothing here to resolve.
133#[derive(Debug, Clone)]
134pub struct NotificationAction {
135    /// Shown under the notification in the buffer.
136    pub label: String,
137    pub effect: lattice_grammar::Effect,
138}
139
140/// One notification.
141///
142/// Not `PartialEq`: it carries an [`Effect`], which is not. Tests
143/// compare the fields they care about, which is sharper anyway.
144#[derive(Debug, Clone)]
145pub struct Notification {
146    pub id: NotificationId,
147    pub level: NotificationLevel,
148    /// NC.2: where the work happened — a repository name, say. Drawn
149    /// in its own column ahead of `text`, so notifications from two
150    /// repositories cannot read the same.
151    pub scope: Option<String>,
152    /// One line. Longer text belongs in `*messages*`, which this tees
153    /// to — a corner popup that needs scrolling is the wrong surface.
154    pub text: String,
155    /// `None` means "stays until dismissed". Used by an operation that
156    /// is still running: it posts with no timeout and *replaces* itself
157    /// on completion with one that has a timeout.
158    pub timeout: Option<Duration>,
159    /// What `<CR>` on this row in `*notifications*` runs. The first is
160    /// the default; the corner shows none of them, because a corner
161    /// popup you have to aim at is worse than one you read.
162    pub actions: Vec<NotificationAction>,
163}
164
165/// The live notifications, newest last.
166///
167/// Window-scoped rather than buffer-scoped, and that is the point: a
168/// notification exists precisely because you have moved on from
169/// whatever started the work. Tying it to a buffer would hide it in the
170/// buffer you are not looking at.
171#[derive(Default)]
172pub struct NotificationStore {
173    inner: Mutex<Vec<Notification>>,
174    next_id: AtomicU64,
175    /// Bumped on every change, so a renderer can skip repainting the
176    /// layer when nothing moved. Paramount goal #1: an idle
177    /// notification must cost nothing per frame.
178    version: AtomicU64,
179    /// How a timed-out notification gets removed AND repainted.
180    ///
181    /// `None` until [`install`] wires it (a store built in a test has
182    /// no editor to wake), in which case a timeout is recorded on the
183    /// notification but never fires — which is what a headless store
184    /// should do rather than silently spawning tasks nobody drains.
185    expiry: Mutex<Option<ExpiryChannel>>,
186    /// Notifications whose clock is already running. Keeps
187    /// [`Self::arm_visible`] idempotent — it runs after every mutation,
188    /// and without this a busy store would spawn a fresh sleep per
189    /// mutation per notification.
190    armed: Mutex<std::collections::HashSet<NotificationId>>,
191    /// NOTIF.1e: read per call, not snapshotted, so a `:set
192    /// notifications.max-visible` takes effect on the next post rather
193    /// than on restart.
194    config: Mutex<Option<Arc<lattice_config::ConfigRegistry>>>,
195}
196
197/// The wake-baked sender + the runtime that sleeps on it.
198///
199/// **Deliberately the inbound primitive rather than a bare tick
200/// callback.** An expiry is the textbook case of the bug
201/// `CLAUDE.md` names: a `TickCallback` alone would remove the
202/// notification and then sit there until the user happened to press a
203/// key, so a popup would linger past its timeout and vanish the moment
204/// you typed — which reads as a rendering bug rather than a missing
205/// wake. `InboundBus::send` bakes the wake in, so it cannot be
206/// forgotten here.
207struct ExpiryChannel {
208    bus: lattice_mode::inbound::InboundBus<NotifyInbound>,
209    runtime: tokio::runtime::Handle,
210}
211
212/// What arrives on the notification subsystem's inbound bus.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum NotifyInbound {
215    /// This notification's timeout elapsed.
216    Expire(NotificationId),
217    /// The store changed and the corner needs redrawing.
218    ///
219    /// Carries no payload and its handler does nothing: the **send** is
220    /// the entire point, because `InboundBus::send` wakes the editor and
221    /// the actor's `async_landed` arm republishes render state off that
222    /// wake. Without it a store mutated from an off-thread producer sat
223    /// invisible until the user next pressed a key.
224    Changed,
225}
226
227pub type NotificationStoreHandle = Arc<NotificationStore>;
228
229/// How many notifications a corner shows at once (§5.9.9's default).
230/// The rest queue; [`NotificationStore::queued`] reports how many.
231pub const MAX_VISIBLE: usize = 3;
232
233impl NotificationStore {
234    pub fn new() -> Self {
235        Self::default()
236    }
237
238    /// How many the corner shows at once — `notifications.max-visible`.
239    pub fn max_visible(&self) -> usize {
240        self.config
241            .lock()
242            .ok()
243            .and_then(|c| c.as_ref().cloned())
244            .and_then(|c| c.get_typed::<options::NotificationsMaxVisible>())
245            .map(|v| (*v).max(0) as usize)
246            .unwrap_or(MAX_VISIBLE)
247    }
248
249    /// This level's timeout, scaled from `notifications.timeout`.
250    fn timeout_for(&self, level: NotificationLevel) -> Duration {
251        let base = self
252            .config
253            .lock()
254            .ok()
255            .and_then(|c| c.as_ref().cloned())
256            .and_then(|c| c.get_typed::<options::NotificationsTimeout>())
257            .map(|v| (*v).max(1) as u64);
258        match base {
259            Some(secs) => Duration::from_secs(secs * level.timeout_multiplier()),
260            None => level.default_timeout(),
261        }
262    }
263
264    /// Whether icons use the Nerd Fonts palette — `ui.nerd_fonts`.
265    ///
266    /// Read by name: the option is declared in `lattice-host`, which
267    /// this crate sits below. A store with no config uses the fallback
268    /// palette, which renders everywhere.
269    pub fn nerd_fonts(&self) -> bool {
270        self.config
271            .lock()
272            .ok()
273            .and_then(|c| c.as_ref().cloned())
274            .and_then(|c| c.get_bool_by_name("ui.nerd_fonts"))
275            .unwrap_or(false)
276    }
277
278    /// Give the store its config. Called by [`install`]; without one it
279    /// falls back to the compiled defaults.
280    pub fn set_config(&self, config: Arc<lattice_config::ConfigRegistry>) {
281        if let Ok(mut slot) = self.config.lock() {
282            *slot = Some(config);
283        }
284    }
285
286    /// Post a notification and return its id.
287    pub fn post(&self, level: NotificationLevel, text: impl Into<String>) -> NotificationId {
288        self.post_with(level, text, Some(self.timeout_for(level)))
289    }
290
291    /// Post with an explicit timeout — `None` for "until replaced or
292    /// dismissed".
293    pub fn post_with(
294        &self,
295        level: NotificationLevel,
296        text: impl Into<String>,
297        timeout: Option<Duration>,
298    ) -> NotificationId {
299        self.post_full(level, None, text.into(), timeout)
300    }
301
302    /// NC.2: post with a scope — where the work happened — at the
303    /// level's own timeout.
304    pub fn post_scoped(
305        &self,
306        level: NotificationLevel,
307        scope: Option<String>,
308        text: impl Into<String>,
309    ) -> NotificationId {
310        self.post_full(level, scope, text.into(), Some(self.timeout_for(level)))
311    }
312
313    fn post_full(
314        &self,
315        level: NotificationLevel,
316        scope: Option<String>,
317        text: String,
318        timeout: Option<Duration>,
319    ) -> NotificationId {
320        let id = NotificationId(self.next_id.fetch_add(1, Ordering::Relaxed));
321        // The record carries the scope too: `*messages*` is read long
322        // after the corner has cleared, with no column to lean on.
323        let tee = match &scope {
324            Some(scope) => format!("{scope}: {text}"),
325            None => text.clone(),
326        };
327        let notification = Notification {
328            id,
329            level,
330            scope,
331            text,
332            timeout,
333            actions: Vec::new(),
334        };
335        if let Ok(mut v) = self.inner.lock() {
336            v.push(notification);
337        }
338        self.bump_and_wake();
339        // NOTIF.1e: tee to `*messages*`. Three surfaces, three
340        // questions — a notification is the signal and `*messages*` is
341        // the record, so one you missed (or that `max-visible = 0`
342        // silenced) is still findable afterwards. Done HERE rather than
343        // per consumer so it cannot be forgotten by one, and at
344        // notification level, which `MessagesLayer` maps straight
345        // through.
346        match level {
347            NotificationLevel::Info | NotificationLevel::Success => {
348                tracing::info!(target: "lattice_notify", "{tee}")
349            }
350            NotificationLevel::Warn => tracing::warn!(target: "lattice_notify", "{tee}"),
351            NotificationLevel::Error => tracing::error!(target: "lattice_notify", "{tee}"),
352        }
353        self.arm_visible();
354        id
355    }
356
357    /// Bump the render version **and wake the editor**, in one call.
358    ///
359    /// Deliberately not separable, for the same reason `finish_task`
360    /// fuses its log and its publish: every producer here runs off the
361    /// actor thread, so a mutation that bumped the version but woke
362    /// nobody is invisible until the user happens to press a key. That
363    /// was the shipped behaviour — `post` bumped and armed a timeout,
364    /// and the ONLY thing that ever reached the inbound bus was the
365    /// expiry. A `magit push` finishing while you sat idle drew
366    /// nothing, its 4-second clock ran down unseen, and the wake that
367    /// finally arrived was the one that removed it.
368    ///
369    /// Fusing them here rather than at each call site means the failure
370    /// mode requires actively bypassing this method rather than merely
371    /// forgetting a line.
372    ///
373    /// Re-entrancy is bounded: `dismiss` is itself called from the
374    /// inbound handler, so it sends `Changed` back onto the bus — but
375    /// the handler for `Changed` does nothing and a second `dismiss` of
376    /// an absent id does not bump, so this settles after one extra
377    /// no-op drain rather than looping.
378    fn bump_and_wake(&self) {
379        self.version.fetch_add(1, Ordering::Release);
380        let Ok(guard) = self.expiry.lock() else {
381            return;
382        };
383        // No channel is a test or headless harness: bump, wake nobody.
384        if let Some(channel) = guard.as_ref() {
385            let _ = channel.bus.send(NotifyInbound::Changed);
386        }
387    }
388
389    /// Start the clock on every **visible** notification that has a
390    /// timeout and is not already counting down.
391    ///
392    /// **A queued notification's clock does not start until it becomes
393    /// visible**, and that is a correctness requirement rather than a
394    /// refinement: without it, an early notification in a burst runs
395    /// out its timeout while sitting behind [`MAX_VISIBLE`] and is
396    /// dismissed having never been seen. A notification nobody saw is
397    /// the bug this subsystem exists to remove, reached from the other
398    /// end.
399    ///
400    /// Called after every mutation, so a removal promotes the next one
401    /// and arms it in the same step.
402    ///
403    /// `armed` is what keeps this idempotent — it runs on every
404    /// mutation, and without it a busy store would spawn a fresh sleep
405    /// per mutation per notification.
406    ///
407    /// A store with no channel (a test, a headless harness) schedules
408    /// nothing, which beats spawning tasks whose sends nobody drains.
409    fn arm_visible(&self) {
410        let max_visible = self.max_visible();
411        let to_arm: Vec<(NotificationId, Duration)> = {
412            let Ok(v) = self.inner.lock() else {
413                return;
414            };
415            let Ok(mut armed) = self.armed.lock() else {
416                return;
417            };
418            // Forget what is gone, or `armed` grows for the session.
419            armed.retain(|id| v.iter().any(|n| n.id == *id));
420            v.iter()
421                .take(max_visible)
422                .filter_map(|n| n.timeout.map(|t| (n.id, t)))
423                .filter(|(id, _)| armed.insert(*id))
424                .collect()
425        };
426        if to_arm.is_empty() {
427            return;
428        }
429        let Ok(guard) = self.expiry.lock() else {
430            return;
431        };
432        let Some(channel) = guard.as_ref() else {
433            return;
434        };
435        for (id, timeout) in to_arm {
436            let bus = channel.bus.clone();
437            // Detached, never a blocking wait: expiry must not hold the
438            // actor thread, and several may count down at once.
439            channel.runtime.spawn(async move {
440                tokio::time::sleep(timeout).await;
441                // A closed bus means the editor is gone; nothing to wake.
442                let _ = bus.send(NotifyInbound::Expire(id));
443            });
444        }
445    }
446
447    /// Post with an action attached — what `<CR>` runs on that row in
448    /// `*notifications*`.
449    ///
450    /// The failure case is the one that needs it: a notification is one
451    /// line and git's stderr is not, so the notification says what
452    /// broke and the action goes to where the rest is.
453    pub fn post_with_action(
454        &self,
455        level: NotificationLevel,
456        text: impl Into<String>,
457        action: NotificationAction,
458    ) -> NotificationId {
459        let id = self.post(level, text);
460        if let Ok(mut v) = self.inner.lock()
461            && let Some(slot) = v.iter_mut().find(|n| n.id == id)
462        {
463            slot.actions.push(action);
464        }
465        self.bump_and_wake();
466        id
467    }
468
469    /// Replace `id`'s content **in place**, keeping its position in the
470    /// stack.
471    ///
472    /// This is what a long operation uses: post "fetching…" with no
473    /// timeout, then replace with "fetched" on completion. Replacing
474    /// rather than dismiss-and-post keeps the row from jumping to the
475    /// bottom of the stack at the moment the user looks at it — and
476    /// keeps two notifications for one operation from ever being
477    /// visible at once.
478    ///
479    /// Returns `false` if the notification is already gone (expired or
480    /// dismissed), in which case the caller's completion is posted
481    /// fresh by [`Self::replace_or_post`].
482    pub fn replace(
483        &self,
484        id: NotificationId,
485        level: NotificationLevel,
486        text: impl Into<String>,
487        timeout: Option<Duration>,
488    ) -> bool {
489        let Ok(mut v) = self.inner.lock() else {
490            return false;
491        };
492        let Some(slot) = v.iter_mut().find(|n| n.id == id) else {
493            return false;
494        };
495        slot.level = level;
496        slot.text = text.into();
497        slot.timeout = timeout;
498        drop(v);
499        self.bump_and_wake();
500        // Re-arm: a notification posted with no timeout ("fetching…")
501        // and replaced by one that has a timeout ("fetched") must
502        // actually expire. Without this it would stay up forever, which
503        // is the failure the no-timeout state exists to avoid in the
504        // other direction. The `armed` entry is cleared first, or the
505        // idempotence check would refuse the new clock.
506        if let Ok(mut armed) = self.armed.lock() {
507            armed.remove(&id);
508        }
509        self.arm_visible();
510        true
511    }
512
513    /// [`Self::replace`], falling back to a fresh post when the
514    /// original is gone.
515    ///
516    /// The fallback is not defensive padding: a long fetch can outlive
517    /// its own "started" notification's timeout, and silently dropping
518    /// the completion would reintroduce exactly the invisible-success
519    /// bug the subsystem exists to fix.
520    pub fn replace_or_post(
521        &self,
522        id: NotificationId,
523        level: NotificationLevel,
524        text: impl Into<String>,
525        timeout: Option<Duration>,
526    ) -> NotificationId {
527        let text = text.into();
528        if self.replace(id, level, text.clone(), timeout) {
529            id
530        } else {
531            self.post_with(level, text, timeout)
532        }
533    }
534
535    /// Remove one notification. Idempotent — dismissing something
536    /// already gone is not an error, because expiry and an explicit
537    /// dismiss race by construction.
538    pub fn dismiss(&self, id: NotificationId) -> bool {
539        let Ok(mut v) = self.inner.lock() else {
540            return false;
541        };
542        let before = v.len();
543        v.retain(|n| n.id != id);
544        let removed = v.len() != before;
545        drop(v);
546        if removed {
547            self.bump_and_wake();
548            // A removal frees a slot — promote whatever was queued and
549            // start its clock in the same step.
550            self.arm_visible();
551        }
552        removed
553    }
554
555    /// Remove everything. `:notifications-clear`'s body.
556    pub fn dismiss_all(&self) -> usize {
557        let Ok(mut v) = self.inner.lock() else {
558            return 0;
559        };
560        let n = v.len();
561        v.clear();
562        drop(v);
563        if n > 0 {
564            self.bump_and_wake();
565            // Nothing left to promote, but `armed` must be cleared or
566            // it would hold ids that no longer exist.
567            self.arm_visible();
568        }
569        n
570    }
571
572    /// Every live notification, oldest first — including ones queued
573    /// behind [`MAX_VISIBLE`].
574    ///
575    /// Renderers want [`Self::visible`]; this is for tests and for
576    /// `:notifications`, which should show what is waiting.
577    pub fn all(&self) -> Vec<Notification> {
578        self.inner.lock().map(|v| v.clone()).unwrap_or_default()
579    }
580
581    /// The notifications a renderer should paint, **oldest first**, at
582    /// most [`MAX_VISIBLE`] of them.
583    ///
584    /// §5.9.9: "excess queued". The ones shown are the **oldest**, and
585    /// that ordering is a correctness requirement rather than a
586    /// preference. Showing the newest instead — which this did on its
587    /// first pass — means an early notification in a burst can run out
588    /// its timeout while invisible and be dismissed having never been
589    /// seen. A notification nobody saw is the bug this subsystem
590    /// exists to remove, arrived at from the other end.
591    ///
592    /// Its companion guarantee is in [`Self::schedule_expiry`]: a
593    /// queued notification's clock does not start until it becomes
594    /// visible.
595    pub fn visible(&self) -> Vec<Notification> {
596        let Ok(v) = self.inner.lock() else {
597            return Vec::new();
598        };
599        v.iter().take(self.max_visible()).cloned().collect()
600    }
601
602    /// How many are waiting behind the visible ones. A renderer shows
603    /// this as a "+N more" line rather than dropping them silently.
604    pub fn queued(&self) -> usize {
605        self.inner
606            .lock()
607            .map(|v| v.len().saturating_sub(self.max_visible()))
608            .unwrap_or(0)
609    }
610
611    pub fn is_empty(&self) -> bool {
612        self.inner.lock().map(|v| v.is_empty()).unwrap_or(true)
613    }
614
615    /// Bumped on every change. A renderer that finds it unchanged has
616    /// nothing to repaint.
617    pub fn version(&self) -> u64 {
618        self.version.load(Ordering::Acquire)
619    }
620
621    /// Give the store the channel expiry rides on. Called by
622    /// [`install`]; a store without one records timeouts but never
623    /// fires them.
624    pub fn set_expiry_channel(
625        &self,
626        bus: lattice_mode::inbound::InboundBus<NotifyInbound>,
627        runtime: tokio::runtime::Handle,
628    ) {
629        if let Ok(mut slot) = self.expiry.lock() {
630            *slot = Some(ExpiryChannel { bus, runtime });
631        }
632    }
633}
634
635/// NOTIF.1f: the `*notifications*` buffer's text, and the row→id map
636/// that goes with it.
637///
638/// **The corner is a signal; this buffer is where you act.** A
639/// notification is not focusable and never will be — aiming at a corner
640/// popup is worse than reading one — so `<CR>` on a row here is what
641/// runs an action, and everything-is-a-buffer means that needs no
642/// bespoke widget and no new global chord.
643///
644/// Returns the text and, parallel to its rows, which notification each
645/// line belongs to. The map is returned rather than re-parsed for the
646/// reason `magit-remote-mode` learned: re-reading the rendered line
647/// makes a heading decode as a record.
648pub fn render_buffer(store: &NotificationStore) -> (String, Vec<Option<NotificationId>>) {
649    let all = store.all();
650    if all.is_empty() {
651        return ("No notifications.\n".to_string(), vec![None]);
652    }
653    let visible = store.max_visible();
654    let nerd_fonts = store.nerd_fonts();
655    let mut out = String::new();
656    let mut rows: Vec<Option<NotificationId>> = Vec::new();
657    for (i, n) in all.iter().enumerate() {
658        // The marker is the level, not a bullet: scanning for the one
659        // that failed is the reason to open this buffer.
660        let marker = n.level.glyph(nerd_fonts);
661        // Queued ones are shown, dimmed by a marker rather than hidden:
662        // "+N more" in the corner tells you they exist, and this is
663        // where you find out what they are.
664        let queued = if i >= visible { " (queued)" } else { "" };
665        let scope = n
666            .scope
667            .as_deref()
668            .map(|s| format!("{s} {SCOPE_SEPARATOR} "))
669            .unwrap_or_default();
670        out.push_str(&format!("  {marker} {scope}{}{queued}\n", n.text));
671        rows.push(Some(n.id));
672        for action in &n.actions {
673            out.push_str(&format!("      <CR>  {}\n", action.label));
674            // An action row belongs to its notification, so `<CR>`
675            // works whether the cursor is on the text or the action.
676            rows.push(Some(n.id));
677        }
678    }
679    (out, rows)
680}
681
682/// Between a notification's scope and its text, on every surface.
683pub const SCOPE_SEPARATOR: &str = "\u{b7}";
684
685/// NC.2: how a finished background task reads.
686///
687/// Pure, so the wording is tested without a bus. The label is an
688/// imperative phrase naming its object ("push main → origin/main"); the
689/// outcome is appended here, in one place, so every producer reads the
690/// same way:
691///
692/// | Outcome | Level | Text |
693/// |---|---|---|
694/// | succeeded | Success | `label — summary`, or just `label` |
695/// | stopped | Warn | `label stopped — message` |
696/// | failed | Error | `label failed — message` |
697///
698/// Success needs no "finished" — the check says it — and the icon does
699/// not stand alone for the other two: the word is what `*messages*`
700/// keeps, and what a reader without colour sees.
701pub fn task_notification(
702    label: &str,
703    outcome: &lattice_protocol::event::TaskOutcome,
704) -> (NotificationLevel, String) {
705    use lattice_protocol::event::TaskOutcome;
706    let with = |head: String, tail: &str| {
707        if tail.is_empty() {
708            head
709        } else {
710            format!("{head} \u{2014} {tail}")
711        }
712    };
713    match outcome {
714        TaskOutcome::Succeeded { summary } => {
715            (NotificationLevel::Success, with(label.to_string(), summary))
716        }
717        TaskOutcome::Stopped { message } => (
718            NotificationLevel::Warn,
719            with(format!("{label} stopped"), message),
720        ),
721        TaskOutcome::Failed { message } => (
722            NotificationLevel::Error,
723            with(format!("{label} failed"), message),
724        ),
725    }
726}
727
728/// Which notification the cursor is on, from [`render_buffer`]'s map.
729pub fn notification_at(rows: &[Option<NotificationId>], line: u32) -> Option<NotificationId> {
730    rows.get(line as usize).copied().flatten()
731}
732
733/// Wire the subsystem: register the store as a service, and give it the
734/// inbound bus expiry rides on.
735///
736/// **The expiry path is the reason this is not just a store.** A
737/// notification that vanishes only when the user next presses a key is
738/// MG.41g: turn `Event::BackgroundTaskFinished` into a notification.
739///
740/// The decoupling seam. Producers of async work — magit's git
741/// invocations today, LSP or a plugin's task tomorrow — publish the
742/// event and never mention notifications; this is the single place
743/// that decides a completion becomes a visible notification, and at
744/// which level.
745///
746/// Before this the notification handle was threaded into each spawner
747/// as an `Option`, which is opt-in and therefore already forgotten in
748/// five of magit's ten spawners. Moving the decision here means a new
749/// async producer gets completion reporting by publishing one event,
750/// with no host change and no dependency on this crate.
751fn install_background_task_subscriber(
752    boot: &mut impl lattice_mode::SubsystemBoot,
753    store: NotificationStoreHandle,
754) {
755    use lattice_protocol::event::{Event, EventKind};
756    use lattice_runtime::{EventFilter, SubscriptionTarget};
757
758    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
759    boot.event_bus().subscribe(
760        EventFilter::kind(EventKind::BackgroundTaskFinished),
761        SubscriptionTarget::Channel(tx),
762    );
763    boot.runtime_handle().spawn(async move {
764        while let Some(event) = rx.recv().await {
765            let Event::BackgroundTaskFinished {
766                scope,
767                label,
768                outcome,
769                ..
770            } = event
771            else {
772                continue;
773            };
774            // `post` wakes the editor via `bump_and_wake`, so the
775            // notification reaches the screen without a keypress.
776            //
777            // This comment previously asserted that as though it were
778            // already true; it was not, and being written down is a
779            // large part of why nobody checked. `post` bumped the
780            // version and woke nobody, so every completion reported
781            // through here — magit's pushes among them — was drawn only
782            // if the user happened to press a key inside its timeout.
783            // Pinned now by
784            // `a_posted_notification_wakes_the_editor_with_no_keystroke`.
785            let (level, text) = task_notification(&label, &outcome);
786            store.post_scoped(level, scope, text);
787        }
788    });
789}
790
791/// worse than one that never vanishes — it reads as a rendering bug.
792/// `InboundBus::send` bakes the wake in, so the drain runs
793/// off-keystroke and the layer repaints on its own.
794///
795/// The handler returns no `Effect`: there is nothing for the grammar to
796/// apply. Removing the notification and waking the editor IS the
797/// outcome, and the renderer reads the store directly.
798pub fn install(boot: &mut impl lattice_mode::SubsystemBoot) {
799    let store: NotificationStoreHandle = Arc::new(NotificationStore::new());
800    boot.register_service::<NotificationStoreHandle>(store.clone());
801    install_background_task_subscriber(boot, store.clone());
802
803    if let Some(config) = boot.service::<Arc<lattice_config::ConfigRegistry>>() {
804        store.set_config((*config).clone());
805    }
806
807    boot.register_service::<mode::RowMapHandle>(Arc::new(mode::RowMap::default()));
808    boot.commands_mut().register_ex_command(
809        "notifications",
810        "Open the `*notifications*` buffer — live and queued notifications, \
811         where <CR> runs a notification's action and `d` dismisses it.",
812        lattice_grammar::ExCommandSpec {
813            latency_class: lattice_grammar::LatencyClass::Display,
814            accepts_bang: false,
815            accepts_range: false,
816            parse_args: Arc::new(|_line: &str, _bang: bool| Ok(lattice_grammar::Args::None)),
817            apply: Arc::new(|_ctx| {
818                Ok(lattice_grammar::Effect::OpenSyntheticBuffer {
819                    name: mode::BUFFER_NAME.to_string(),
820                    mode_id: mode::NotificationsMode::mode_id().as_str().to_string(),
821                    content: None,
822                    cursor: None,
823                    activate_minor: None,
824                })
825            }),
826            args_schema: Vec::new(),
827            surface_form: lattice_grammar::SurfaceForm::Keyword,
828        },
829    );
830    let _ = boot.modes_mut().register(mode::NotificationsMode);
831
832    let for_handler = store.clone();
833    let bus = boot.inbound::<NotifyInbound, _>(move |item| {
834        match item {
835            NotifyInbound::Expire(id) => {
836                for_handler.dismiss(id);
837            }
838            // Nothing to do, and that is correct: the store was already
839            // mutated synchronously by whoever sent this. The value was
840            // delivered by `InboundBus::send` waking the editor, which
841            // is what gets the corner repainted off-keystroke.
842            NotifyInbound::Changed => {}
843        }
844        Vec::new()
845    });
846    install_palette_refresh(boot, store.clone(), bus.clone());
847    store.set_expiry_channel(bus, boot.runtime_handle().clone());
848}
849
850/// Re-render an open `*notifications*` buffer when `ui.nerd_fonts`
851/// flips, so its icons follow the palette like every other glyph
852/// surface. The corner needs no help — it reads the option per frame.
853///
854/// Refreshes in place through the document handle and never opens the
855/// buffer: flipping an option must not move focus. Matched by name for
856/// the reason `lattice-dashboard` gives — the option lives in
857/// `lattice-host`, above this crate.
858fn install_palette_refresh(
859    boot: &mut impl lattice_mode::SubsystemBoot,
860    store: NotificationStoreHandle,
861    wake: lattice_mode::inbound::InboundBus<NotifyInbound>,
862) {
863    use lattice_protocol::event::{Event, EventKind};
864    use lattice_runtime::{EventFilter, SubscriptionTarget};
865
866    let buffers = boot.buffer_store().clone();
867    let rows = boot.service::<mode::RowMapHandle>().map(|r| (*r).clone());
868    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
869    boot.event_bus().subscribe(
870        EventFilter::kind(EventKind::OptionChanged),
871        SubscriptionTarget::Channel(tx),
872    );
873    boot.runtime_handle().spawn(async move {
874        while let Some(event) = rx.recv().await {
875            let Event::OptionChanged { name, .. } = event else {
876                continue;
877            };
878            if name != "ui.nerd_fonts" {
879                continue;
880            }
881            let Some(handle) = buffers
882                .find_by_name(mode::BUFFER_NAME)
883                .and_then(|id| buffers.handle_for(id))
884            else {
885                continue;
886            };
887            mode::rerender(&store, rows.as_ref(), &handle).await;
888            // The edit landed off-keystroke; wake the editor so it paints.
889            let _ = wake.send(NotifyInbound::Changed);
890        }
891    });
892}
893
894#[cfg(test)]
895mod tests {
896    use super::*;
897
898    #[test]
899    fn a_posted_notification_is_visible_and_has_a_level_appropriate_timeout() {
900        let s = NotificationStore::new();
901        let id = s.post(NotificationLevel::Error, "fetch failed");
902        let live = s.visible();
903        assert_eq!(live.len(), 1);
904        assert_eq!(live[0].id, id);
905        assert_eq!(live[0].text, "fetch failed");
906        assert_eq!(
907            live[0].timeout,
908            Some(Duration::from_secs(16)),
909            "4s base × the error multiplier"
910        );
911    }
912
913    /// An error you blink past is an error you will hit again — the
914    /// whole reason the subsystem exists is that a failed fetch stops
915    /// being invisible.
916    #[test]
917    fn errors_linger_longer_than_info() {
918        assert!(
919            NotificationLevel::Error.default_timeout() > NotificationLevel::Warn.default_timeout()
920        );
921        assert!(
922            NotificationLevel::Warn.default_timeout() > NotificationLevel::Info.default_timeout()
923        );
924    }
925
926    /// Success is a state, not a severity: it reads differently but
927    /// lingers exactly as long as info. Otherwise raising the timeout
928    /// would change the two differently, and a finished push would
929    /// behave unlike the note beside it.
930    #[test]
931    fn success_times_out_like_info() {
932        assert_eq!(
933            NotificationLevel::Success.default_timeout(),
934            NotificationLevel::Info.default_timeout()
935        );
936        assert_eq!(
937            NotificationLevel::Success.timeout_multiplier(),
938            NotificationLevel::Info.timeout_multiplier()
939        );
940    }
941
942    const LEVELS: [NotificationLevel; 4] = [
943        NotificationLevel::Info,
944        NotificationLevel::Success,
945        NotificationLevel::Warn,
946        NotificationLevel::Error,
947    ];
948
949    /// The icon is what tells the rows apart. Two levels sharing one
950    /// in either palette would make them look the same again.
951    #[test]
952    fn every_level_has_its_own_icon_in_both_palettes() {
953        for nerd in [false, true] {
954            let glyphs: std::collections::HashSet<&str> =
955                LEVELS.iter().map(|l| l.glyph(nerd)).collect();
956            assert_eq!(glyphs.len(), LEVELS.len(), "nerd_fonts={nerd}");
957        }
958    }
959
960    /// One cell in both palettes, so toggling `ui.nerd_fonts` cannot
961    /// shift the text after the icon.
962    #[test]
963    fn every_icon_is_one_char_in_both_palettes() {
964        for level in LEVELS {
965            for nerd in [false, true] {
966                assert_eq!(
967                    level.glyph(nerd).chars().count(),
968                    1,
969                    "{level:?} nerd_fonts={nerd}"
970                );
971            }
972        }
973    }
974
975    /// The fallback palette is the default and must render in any
976    /// monospace font, so it may not reach into the Private Use Area
977    /// that only a patched font fills. The Nerd palette must.
978    #[test]
979    fn only_the_nerd_palette_uses_private_use_glyphs() {
980        let pua = |g: &str| g.chars().all(|c| ('\u{e000}'..='\u{f8ff}').contains(&c));
981        for level in LEVELS {
982            assert!(!pua(level.glyph(false)), "{level:?}");
983            assert!(pua(level.glyph(true)), "{level:?}");
984        }
985    }
986
987    fn outcome_ok(summary: &str) -> lattice_protocol::event::TaskOutcome {
988        lattice_protocol::event::TaskOutcome::Succeeded {
989            summary: summary.into(),
990        }
991    }
992
993    /// NC.2: the outcome is worded in one place, so every producer's
994    /// completion reads the same way.
995    #[test]
996    fn a_task_outcome_sets_the_level_and_the_wording() {
997        use lattice_protocol::event::TaskOutcome;
998        assert_eq!(
999            task_notification("push main", &outcome_ok("main → origin/main")),
1000            (
1001                NotificationLevel::Success,
1002                "push main \u{2014} main → origin/main".to_string()
1003            )
1004        );
1005        assert_eq!(
1006            task_notification(
1007                "rebase onto main",
1008                &TaskOutcome::Stopped {
1009                    message: "stopped at 3f2a1c for edit".into()
1010                }
1011            ),
1012            (
1013                NotificationLevel::Warn,
1014                "rebase onto main stopped \u{2014} stopped at 3f2a1c for edit".to_string()
1015            )
1016        );
1017        assert_eq!(
1018            task_notification(
1019                "merge feature",
1020                &TaskOutcome::Failed {
1021                    message: "CONFLICT (content): Merge conflict in a.rs".into()
1022                }
1023            ),
1024            (
1025                NotificationLevel::Error,
1026                "merge feature failed \u{2014} CONFLICT (content): Merge conflict in a.rs"
1027                    .to_string()
1028            )
1029        );
1030    }
1031
1032    /// An empty summary is not padded with "finished": the check
1033    /// already says it, and a dangling dash reads as a missing value.
1034    #[test]
1035    fn an_empty_summary_leaves_the_label_alone() {
1036        assert_eq!(
1037            task_notification("stage a.rs", &outcome_ok("")).1,
1038            "stage a.rs"
1039        );
1040    }
1041
1042    /// The scope is kept apart from the text, so a renderer can give it
1043    /// its own column, and the buffer and the record both carry it.
1044    #[test]
1045    fn a_scoped_notification_keeps_its_scope_everywhere() {
1046        let s = NotificationStore::new();
1047        s.post_scoped(
1048            NotificationLevel::Success,
1049            Some("lattice".into()),
1050            "push main",
1051        );
1052        s.post_scoped(
1053            NotificationLevel::Success,
1054            Some("dotfiles".into()),
1055            "push main",
1056        );
1057        let live = s.visible();
1058        assert_eq!(live[0].scope.as_deref(), Some("lattice"));
1059        assert_eq!(
1060            live[0].text, "push main",
1061            "the scope is not baked into the text"
1062        );
1063        let (text, _) = render_buffer(&s);
1064        let lines: Vec<&str> = text.lines().collect();
1065        assert!(lines[0].contains("lattice \u{b7} push main"), "{text}");
1066        assert!(lines[1].contains("dotfiles \u{b7} push main"), "{text}");
1067        assert_ne!(lines[0], lines[1], "two repositories never read the same");
1068    }
1069
1070    #[test]
1071    fn ids_are_unique_across_posts() {
1072        let s = NotificationStore::new();
1073        let a = s.post(NotificationLevel::Info, "one");
1074        let b = s.post(NotificationLevel::Info, "two");
1075        assert_ne!(a, b);
1076        assert_eq!(s.visible().len(), 2);
1077    }
1078
1079    /// The shape a long operation uses: "fetching…" with no timeout,
1080    /// replaced by the outcome. Replacing rather than
1081    /// dismiss-and-repost keeps the row from jumping to the bottom of
1082    /// the stack at the moment the user looks at it.
1083    #[test]
1084    fn a_replacement_keeps_its_place_in_the_stack() {
1085        let s = NotificationStore::new();
1086        let first = s.post(NotificationLevel::Info, "first");
1087        let running = s.post_with(NotificationLevel::Info, "fetching…", None);
1088        let last = s.post(NotificationLevel::Info, "last");
1089
1090        assert!(s.replace(
1091            running,
1092            NotificationLevel::Info,
1093            "fetched",
1094            Some(Duration::from_secs(4))
1095        ));
1096
1097        let live = s.visible();
1098        assert_eq!(
1099            live.iter().map(|n| n.id).collect::<Vec<_>>(),
1100            vec![first, running, last],
1101            "order is unchanged"
1102        );
1103        assert_eq!(live[1].text, "fetched");
1104        assert_eq!(live[1].timeout, Some(Duration::from_secs(4)));
1105    }
1106
1107    /// A long fetch can outlive its own "started" notification.
1108    /// Dropping the completion there would put back exactly the
1109    /// invisible-success bug this subsystem exists to remove.
1110    #[test]
1111    fn a_completion_still_shows_when_its_start_already_went_away() {
1112        let s = NotificationStore::new();
1113        let running = s.post_with(NotificationLevel::Info, "fetching…", None);
1114        s.dismiss(running);
1115        assert!(s.is_empty());
1116
1117        let id = s.replace_or_post(running, NotificationLevel::Info, "fetched", None);
1118        assert_ne!(id, running, "a fresh notification, not the dead one");
1119        assert_eq!(s.visible().len(), 1);
1120        assert_eq!(s.visible()[0].text, "fetched");
1121    }
1122
1123    #[test]
1124    fn replacing_a_live_notification_reuses_its_id() {
1125        let s = NotificationStore::new();
1126        let running = s.post_with(NotificationLevel::Info, "pushing…", None);
1127        let id = s.replace_or_post(running, NotificationLevel::Error, "push failed", None);
1128        assert_eq!(id, running);
1129        assert_eq!(s.visible().len(), 1, "one notification, not two");
1130        assert_eq!(s.visible()[0].level, NotificationLevel::Error);
1131    }
1132
1133    /// Expiry and an explicit dismiss race by construction, so
1134    /// dismissing something already gone must not be an error.
1135    #[test]
1136    fn dismissing_twice_is_not_an_error() {
1137        let s = NotificationStore::new();
1138        let id = s.post(NotificationLevel::Info, "x");
1139        assert!(s.dismiss(id));
1140        assert!(!s.dismiss(id));
1141        assert!(s.is_empty());
1142    }
1143
1144    #[test]
1145    fn dismiss_all_reports_how_many_it_removed() {
1146        let s = NotificationStore::new();
1147        s.post(NotificationLevel::Info, "a");
1148        s.post(NotificationLevel::Warn, "b");
1149        assert_eq!(s.dismiss_all(), 2);
1150        assert_eq!(s.dismiss_all(), 0);
1151    }
1152
1153    /// Paramount goal #1: a renderer must be able to skip the layer
1154    /// entirely when nothing moved, so every mutation — and only a
1155    /// mutation — has to bump the version.
1156    #[test]
1157    fn every_change_bumps_the_version_and_a_no_op_does_not() {
1158        let s = NotificationStore::new();
1159        let v0 = s.version();
1160
1161        let id = s.post(NotificationLevel::Info, "a");
1162        let v1 = s.version();
1163        assert!(v1 > v0, "a post is a change");
1164
1165        assert!(s.replace(id, NotificationLevel::Warn, "b", None));
1166        let v2 = s.version();
1167        assert!(v2 > v1, "a replace is a change");
1168
1169        assert!(!s.replace(NotificationId(999), NotificationLevel::Info, "x", None));
1170        assert_eq!(
1171            s.version(),
1172            v2,
1173            "a replace that found nothing changed nothing"
1174        );
1175
1176        assert!(!s.dismiss(NotificationId(999)));
1177        assert_eq!(
1178            s.version(),
1179            v2,
1180            "a dismiss that found nothing changed nothing"
1181        );
1182
1183        assert_eq!(s.dismiss_all(), 1);
1184        assert!(s.version() > v2);
1185
1186        let v3 = s.version();
1187        assert_eq!(s.dismiss_all(), 0);
1188        assert_eq!(s.version(), v3, "clearing an empty store changed nothing");
1189    }
1190
1191    /// The next **expiry** on the bus, stepping over the `Changed`
1192    /// wakes every store mutation sends.
1193    ///
1194    /// Those wakes are not noise to be filtered away in production —
1195    /// they are how a posted notification reaches the screen at all —
1196    /// but the tests below are about the expiry *clock*, so they step
1197    /// past them rather than asserting on bus position.
1198    async fn next_expire(
1199        rx: &mut tokio::sync::mpsc::UnboundedReceiver<NotifyInbound>,
1200    ) -> Option<NotifyInbound> {
1201        loop {
1202            match rx.recv().await {
1203                Some(NotifyInbound::Changed) => continue,
1204                other => return other,
1205            }
1206        }
1207    }
1208
1209    /// Whether any expiry is sitting on the bus right now. Drains the
1210    /// `Changed` wakes, which are always present after a post.
1211    fn expiry_pending(rx: &mut tokio::sync::mpsc::UnboundedReceiver<NotifyInbound>) -> bool {
1212        while let Ok(item) = rx.try_recv() {
1213            if !matches!(item, NotifyInbound::Changed) {
1214                return true;
1215            }
1216        }
1217        false
1218    }
1219
1220    /// The expiry path, driven on a paused clock so it is
1221    /// deterministic rather than a sleep in the test suite.
1222    ///
1223    /// What this pins is the thing the fragment warned about: a
1224    /// notification must go away **on its own**, without a keystroke.
1225    /// The store schedules, the bus wakes, the handler dismisses.
1226    #[tokio::test(start_paused = true)]
1227    async fn a_timed_out_notification_dismisses_itself_with_no_keystroke() {
1228        let store: NotificationStoreHandle = Arc::new(NotificationStore::new());
1229
1230        // Stand in for the host's drain: everything the bus receives is
1231        // applied to the store, which is exactly what `install`'s
1232        // handler does.
1233        let (bus, mut rx) = lattice_mode::inbound::make_inbound_raw::<NotifyInbound>(Arc::new(
1234            tokio::sync::Notify::new(),
1235        ));
1236        store.set_expiry_channel(bus, tokio::runtime::Handle::current());
1237
1238        let id = store.post_with(
1239            NotificationLevel::Info,
1240            "fetched",
1241            Some(Duration::from_secs(4)),
1242        );
1243        assert_eq!(store.visible().len(), 1);
1244
1245        // Let the spawned sleep register with the timer driver before
1246        // the clock moves; `post_with` is synchronous, so the task is
1247        // spawned but not yet polled.
1248        tokio::task::yield_now().await;
1249        tokio::time::advance(Duration::from_secs(5)).await;
1250        // Bounded: a broken scheduler sends nothing, and an unbounded
1251        // `recv().await` would hang the suite instead of failing it.
1252        let item = tokio::time::timeout(Duration::from_secs(1), next_expire(&mut rx))
1253            .await
1254            .expect("the expiry must arrive — nothing was scheduled")
1255            .expect("the bus is open");
1256        assert_eq!(item, NotifyInbound::Expire(id));
1257
1258        store.dismiss(id);
1259        assert!(
1260            store.is_empty(),
1261            "the notification goes away on its own, not on the next keypress"
1262        );
1263    }
1264
1265    /// The other half of the same guarantee, and the one that was
1266    /// missing: a notification must **appear** without a keystroke,
1267    /// not merely disappear without one.
1268    ///
1269    /// The asymmetry was invisible because the expiry test above reads
1270    /// like it covers this. It does not: expiry is the only thing that
1271    /// ever reached the inbound bus, so `post` mutated the store, armed
1272    /// a timeout, and woke nobody. A `magit push` finishing while you
1273    /// sat idle was never drawn — and its 4-second clock was already
1274    /// running, so by the time the expiry wake DID arrive the
1275    /// notification had been dismissed unseen. Pressing any key inside
1276    /// those 4 seconds showed it, which is why this reads as "sometimes
1277    /// works" rather than "never works".
1278    #[tokio::test(start_paused = true)]
1279    async fn a_posted_notification_wakes_the_editor_with_no_keystroke() {
1280        let store: NotificationStoreHandle = Arc::new(NotificationStore::new());
1281        let wake = Arc::new(tokio::sync::Notify::new());
1282        // `_rx` is bound, not dropped: `InboundBus::send` fails on a
1283        // closed receiver and would skip the wake, which would pass
1284        // this test for the wrong reason.
1285        let (bus, _rx) = lattice_mode::inbound::make_inbound_raw::<NotifyInbound>(wake.clone());
1286        store.set_expiry_channel(bus, tokio::runtime::Handle::current());
1287
1288        store.post(NotificationLevel::Info, "push: everything up-to-date");
1289
1290        tokio::time::timeout(Duration::from_secs(1), wake.notified())
1291            .await
1292            .expect(
1293                "posting must wake the editor — otherwise the notification \
1294                 sits invisible until the next keypress while its timeout \
1295                 runs down",
1296            );
1297    }
1298
1299    /// A notification with no timeout is the "still running" state —
1300    /// it must not schedule anything, or "fetching…" would disappear
1301    /// mid-fetch.
1302    #[tokio::test(start_paused = true)]
1303    async fn a_notification_with_no_timeout_never_expires() {
1304        let store: NotificationStoreHandle = Arc::new(NotificationStore::new());
1305        let (bus, mut rx) = lattice_mode::inbound::make_inbound_raw::<NotifyInbound>(Arc::new(
1306            tokio::sync::Notify::new(),
1307        ));
1308        store.set_expiry_channel(bus, tokio::runtime::Handle::current());
1309
1310        store.post_with(NotificationLevel::Info, "fetching…", None);
1311        tokio::time::advance(Duration::from_secs(3600)).await;
1312
1313        assert!(
1314            !expiry_pending(&mut rx),
1315            "nothing should have been scheduled for a timeout-less notification"
1316        );
1317        assert_eq!(store.visible().len(), 1);
1318    }
1319
1320    /// The re-arm: "fetching…" (no timeout) replaced by "fetched"
1321    /// (timeout) has to start counting down, or the completion stays up
1322    /// forever.
1323    #[tokio::test(start_paused = true)]
1324    async fn replacing_a_timeout_less_notification_arms_its_expiry() {
1325        let store: NotificationStoreHandle = Arc::new(NotificationStore::new());
1326        let (bus, mut rx) = lattice_mode::inbound::make_inbound_raw::<NotifyInbound>(Arc::new(
1327            tokio::sync::Notify::new(),
1328        ));
1329        store.set_expiry_channel(bus, tokio::runtime::Handle::current());
1330
1331        let id = store.post_with(NotificationLevel::Info, "fetching…", None);
1332        store.replace(
1333            id,
1334            NotificationLevel::Info,
1335            "fetched",
1336            Some(Duration::from_secs(4)),
1337        );
1338
1339        tokio::task::yield_now().await;
1340        tokio::time::advance(Duration::from_secs(5)).await;
1341        assert_eq!(
1342            tokio::time::timeout(Duration::from_secs(1), next_expire(&mut rx))
1343                .await
1344                .expect("the re-armed expiry must fire")
1345                .expect("the bus is open"),
1346            NotifyInbound::Expire(id)
1347        );
1348    }
1349
1350    /// The property the first version of this got wrong: a queued
1351    /// notification must NOT run its clock while invisible, or it is
1352    /// dismissed having never been seen — the very bug the subsystem
1353    /// removes, arrived at from the other end.
1354    #[tokio::test(start_paused = true)]
1355    async fn a_queued_notification_does_not_expire_before_it_is_seen() {
1356        let store: NotificationStoreHandle = Arc::new(NotificationStore::new());
1357        let (bus, mut rx) = lattice_mode::inbound::make_inbound_raw::<NotifyInbound>(Arc::new(
1358            tokio::sync::Notify::new(),
1359        ));
1360        store.set_expiry_channel(bus, tokio::runtime::Handle::current());
1361
1362        // Four at once: three visible, the fourth queued.
1363        let ids: Vec<_> = (0..4)
1364            .map(|i| store.post(NotificationLevel::Info, format!("n{i}")))
1365            .collect();
1366        assert_eq!(store.queued(), 1);
1367
1368        tokio::task::yield_now().await;
1369        tokio::time::advance(Duration::from_secs(5)).await;
1370        // Let the woken sleeps actually send before draining.
1371        tokio::task::yield_now().await;
1372
1373        // Exactly the three that were VISIBLE expire. The queued one's
1374        // clock never started.
1375        // Collect expiries, skipping the `Changed` wakes — a `while let
1376        // Ok(Expire(..))` would stop dead at the first one and report
1377        // an empty set.
1378        let mut expired = Vec::new();
1379        while let Ok(item) = rx.try_recv() {
1380            if let NotifyInbound::Expire(id) = item {
1381                expired.push(id);
1382            }
1383        }
1384        expired.sort();
1385        assert_eq!(
1386            expired,
1387            ids[..MAX_VISIBLE].to_vec(),
1388            "only the visible ones expired: {expired:?}"
1389        );
1390        assert!(
1391            !expired.contains(&ids[3]),
1392            "the queued notification must not expire unseen"
1393        );
1394    }
1395
1396    /// …and once promoted, it starts counting.
1397    #[tokio::test(start_paused = true)]
1398    async fn a_promoted_notification_starts_its_clock() {
1399        let store: NotificationStoreHandle = Arc::new(NotificationStore::new());
1400        let (bus, mut rx) = lattice_mode::inbound::make_inbound_raw::<NotifyInbound>(Arc::new(
1401            tokio::sync::Notify::new(),
1402        ));
1403        store.set_expiry_channel(bus, tokio::runtime::Handle::current());
1404
1405        let ids: Vec<_> = (0..4)
1406            .map(|i| store.post(NotificationLevel::Info, format!("n{i}")))
1407            .collect();
1408        // Free a slot, which promotes the fourth.
1409        store.dismiss(ids[0]);
1410        assert!(store.visible().iter().any(|n| n.id == ids[3]));
1411
1412        tokio::task::yield_now().await;
1413        tokio::time::advance(Duration::from_secs(5)).await;
1414        tokio::task::yield_now().await;
1415
1416        // Collect expiries, skipping the `Changed` wakes — a `while let
1417        // Ok(Expire(..))` would stop dead at the first one and report
1418        // an empty set.
1419        let mut expired = Vec::new();
1420        while let Ok(item) = rx.try_recv() {
1421            if let NotifyInbound::Expire(id) = item {
1422                expired.push(id);
1423            }
1424        }
1425        assert!(
1426            expired.contains(&ids[3]),
1427            "the promoted notification must now expire: {expired:?}"
1428        );
1429    }
1430
1431    /// A store with no channel — a test fixture, a headless harness —
1432    /// records the timeout but schedules nothing. Better than spawning
1433    /// tasks whose sends nobody drains.
1434    #[test]
1435    fn a_store_with_no_channel_records_the_timeout_and_schedules_nothing() {
1436        let s = NotificationStore::new();
1437        let id = s.post(NotificationLevel::Info, "x");
1438        assert_eq!(
1439            s.visible()[0].timeout,
1440            Some(NotificationLevel::Info.default_timeout())
1441        );
1442        assert!(s.visible().iter().any(|n| n.id == id));
1443    }
1444
1445    /// §5.9.9: at most three show, the rest queue. The three kept are
1446    /// the NEWEST — a burst of five must not leave you reading the
1447    /// first three while the two that matter wait behind them.
1448    #[test]
1449    fn a_burst_shows_the_newest_and_queues_the_rest() {
1450        let s = NotificationStore::new();
1451        let ids: Vec<_> = (0..5)
1452            .map(|i| s.post(NotificationLevel::Info, format!("n{i}")))
1453            .collect();
1454
1455        let shown = s.visible();
1456        assert_eq!(shown.len(), MAX_VISIBLE);
1457        assert_eq!(
1458            shown.iter().map(|n| n.id).collect::<Vec<_>>(),
1459            ids[..MAX_VISIBLE].to_vec(),
1460            "the OLDEST three — showing the newest instead lets an early \
1461             notification expire while invisible, which is the bug this \
1462             subsystem exists to remove, from the other end"
1463        );
1464        assert_eq!(s.queued(), 2);
1465        assert_eq!(s.all().len(), 5, "the queued ones are still live");
1466    }
1467
1468    #[test]
1469    fn nothing_queues_below_the_limit() {
1470        let s = NotificationStore::new();
1471        s.post(NotificationLevel::Info, "a");
1472        s.post(NotificationLevel::Info, "b");
1473        assert_eq!(s.visible().len(), 2);
1474        assert_eq!(s.queued(), 0);
1475    }
1476
1477    /// A queued notification becomes visible when one in front of it
1478    /// expires — otherwise a burst would leave rows permanently
1479    /// stranded.
1480    #[test]
1481    fn dismissing_a_visible_one_promotes_a_queued_one() {
1482        let s = NotificationStore::new();
1483        let ids: Vec<_> = (0..4)
1484            .map(|i| s.post(NotificationLevel::Info, format!("n{i}")))
1485            .collect();
1486        assert_eq!(s.queued(), 1);
1487        assert!(
1488            !s.visible().iter().any(|n| n.id == ids[3]),
1489            "the newest is the one waiting"
1490        );
1491
1492        s.dismiss(ids[0]);
1493        assert_eq!(s.queued(), 0);
1494        assert!(
1495            s.visible().iter().any(|n| n.id == ids[3]),
1496            "the one that was queued is now shown"
1497        );
1498    }
1499
1500    #[test]
1501    fn an_empty_buffer_says_so_rather_than_rendering_nothing() {
1502        let s = NotificationStore::new();
1503        let (text, rows) = render_buffer(&s);
1504        assert_eq!(text, "No notifications.\n");
1505        assert!(notification_at(&rows, 0).is_none());
1506    }
1507
1508    #[test]
1509    fn every_row_including_an_action_row_maps_to_its_notification() {
1510        let s = NotificationStore::new();
1511        let a = s.post(NotificationLevel::Info, "fetch finished");
1512        let b = s.post_with_action(
1513            NotificationLevel::Error,
1514            "push failed",
1515            NotificationAction {
1516                label: "show output".into(),
1517                effect: lattice_grammar::Effect::OpenMessages,
1518            },
1519        );
1520        let (text, rows) = render_buffer(&s);
1521        let lines: Vec<&str> = text.lines().collect();
1522
1523        assert!(lines[0].contains("fetch finished"));
1524        assert!(lines[1].contains("push failed"));
1525        assert!(lines[2].contains("show output"), "the action is listed");
1526
1527        assert_eq!(notification_at(&rows, 0), Some(a));
1528        assert_eq!(notification_at(&rows, 1), Some(b));
1529        assert_eq!(
1530            notification_at(&rows, 2),
1531            Some(b),
1532            "an action row belongs to its notification, so `<CR>` works \
1533             from either line"
1534        );
1535    }
1536
1537    /// The corner says "+N more"; this is where you find out what they
1538    /// are. Hiding them here would leave no surface that shows them at
1539    /// all.
1540    #[test]
1541    fn queued_notifications_are_listed_and_marked() {
1542        let s = NotificationStore::new();
1543        for i in 0..5 {
1544            s.post(NotificationLevel::Info, format!("n{i}"));
1545        }
1546        let (text, _) = render_buffer(&s);
1547        assert_eq!(text.lines().count(), 5, "all of them, not just visible");
1548        assert_eq!(
1549            text.lines().filter(|l| l.contains("(queued)")).count(),
1550            2,
1551            "and the ones behind the limit say so"
1552        );
1553    }
1554
1555    #[test]
1556    fn the_level_marker_is_what_you_scan_for() {
1557        let s = NotificationStore::new();
1558        s.post(NotificationLevel::Error, "boom");
1559        s.post(NotificationLevel::Success, "pushed");
1560        let (text, _) = render_buffer(&s);
1561        let lines: Vec<&str> = text.lines().collect();
1562        assert!(
1563            lines[0].contains(NotificationLevel::Error.glyph(false)),
1564            "{text}"
1565        );
1566        assert!(
1567            lines[1].contains(NotificationLevel::Success.glyph(false)),
1568            "the buffer uses the same icons as the corner: {text}"
1569        );
1570    }
1571
1572    /// NOTIF.1f: the action is a typed effect, not a name to resolve.
1573    /// A name can fail to resolve at fire time — silently, on a key the
1574    /// user pressed deliberately.
1575    #[test]
1576    fn a_notifications_action_is_carried_as_an_effect() {
1577        let s = NotificationStore::new();
1578        let id = s.post_with_action(
1579            NotificationLevel::Error,
1580            "push failed",
1581            NotificationAction {
1582                label: "show output".into(),
1583                effect: lattice_grammar::Effect::OpenMessages,
1584            },
1585        );
1586        let n = s.all().into_iter().find(|n| n.id == id).expect("posted");
1587        assert_eq!(n.actions.len(), 1);
1588        assert_eq!(n.actions[0].label, "show output");
1589        assert!(matches!(
1590            n.actions[0].effect,
1591            lattice_grammar::Effect::OpenMessages
1592        ));
1593    }
1594
1595    /// Most notifications have nothing to do, and `<CR>` on one must
1596    /// decline rather than complain — a key that errors in the common
1597    /// case trains you to stop pressing it.
1598    #[test]
1599    fn a_notification_without_an_action_carries_none() {
1600        let s = NotificationStore::new();
1601        let id = s.post(NotificationLevel::Info, "fetch finished");
1602        let n = s.all().into_iter().find(|n| n.id == id).expect("posted");
1603        assert!(n.actions.is_empty());
1604    }
1605
1606    /// NOTIF.1d's shape, end to end at the store: a remote op that
1607    /// finishes posts Info, one that fails posts Error and lingers
1608    /// longer. Before this, success was invisible and failure reached
1609    /// `*messages*` only — the case that opened the gate.
1610    #[test]
1611    fn a_completed_operation_and_a_failed_one_read_differently() {
1612        let s = NotificationStore::new();
1613        s.post(NotificationLevel::Info, "fetch finished");
1614        s.post(NotificationLevel::Error, "push failed: rejected");
1615
1616        let live = s.visible();
1617        assert_eq!(live.len(), 2);
1618        assert_eq!(live[0].level, NotificationLevel::Info);
1619        assert_eq!(live[1].level, NotificationLevel::Error);
1620        assert!(
1621            live[1].timeout > live[0].timeout,
1622            "the failure has to outlast the success: {live:?}"
1623        );
1624    }
1625
1626    /// NOTIF.1e: one knob times a fixed ratio. Raising the base must
1627    /// not leave errors relatively SHORTER than the successes around
1628    /// them, which three independent options would make reachable.
1629    #[test]
1630    fn the_level_multipliers_keep_errors_longest_at_any_base() {
1631        for base in [1u64, 4, 30, 3600] {
1632            let info = base * NotificationLevel::Info.timeout_multiplier();
1633            let warn = base * NotificationLevel::Warn.timeout_multiplier();
1634            let error = base * NotificationLevel::Error.timeout_multiplier();
1635            assert!(error > warn && warn > info, "base {base}");
1636        }
1637    }
1638
1639    /// `max-visible = 0` silences the corner without losing anything —
1640    /// the store keeps running and the `*messages*` tee keeps the
1641    /// record.
1642    #[test]
1643    fn nothing_visible_still_keeps_the_notifications() {
1644        let s = NotificationStore::new();
1645        s.post(NotificationLevel::Error, "push failed");
1646        // With no config the compiled default applies, so this asserts
1647        // the shape rather than the zero case; the zero case is the
1648        // validator's business and is covered there.
1649        assert_eq!(s.all().len(), 1);
1650        assert!(!s.is_empty());
1651    }
1652
1653    #[test]
1654    fn an_empty_store_is_empty_and_versionless() {
1655        let s = NotificationStore::new();
1656        assert!(s.is_empty());
1657        assert!(s.visible().is_empty());
1658        assert_eq!(s.version(), 0);
1659    }
1660}