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}