notify.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6	"log/slog"
7	"strconv"
8	"strings"
9	"sync"
10	"time"
11
12	"github.com/bnema/vev/internal/domain"
13	"github.com/bnema/vev/internal/ports"
14	"github.com/bnema/vev/internal/usecase/ui"
15)
16
17const (
18	noticeHistoryCap = 200
19	noticePendingCap = 32
20	// maxVisibleToasts bounds the stack drawn over a client's screen; later
21	// notices wait for a free slot and are counted as "+N more" meanwhile.
22	maxVisibleToasts = 3
23)
24
25// errNoNeighbor reports a directional focus move with nothing to move to. It is
26// an ordinary outcome of navigation, never a user-facing failure.
27var errNoNeighbor = errors.New("no pane in that direction")
28
29// noticeCenter owns the daemon-wide notification history and the queue of
30// global or session-scoped notices awaiting an eligible attached client. Its
31// mu is leaf-level: no other lock is ever taken while holding it.
32type noticeCenter struct {
33	mu sync.Mutex
34	// routingMu serializes notice deliver-or-queue with the first-paint drain.
35	// Attachment removal also takes it, so a client selected for a toast remains
36	// current until that toast is published.
37	routingMu sync.Mutex
38	ring      []domain.Notification
39	pending   []domain.Notification
40	// beforeQueueGlobal is a deterministic test seam after routing has observed
41	// no attachments and before it queues the notice.
42	beforeQueueGlobal func()
43	// beforeGlobalDelivery is a deterministic test seam after targets have been
44	// selected and before their toast stacks are touched.
45	beforeGlobalDelivery func()
46	// beforeSessionDelivery is a deterministic test seam after a session client
47	// has been selected and before its toast stack is touched.
48	beforeSessionDelivery func()
49	// beforeClientNoticeMutation is a deterministic test seam after a client
50	// notice's ownership validation and before its toast state is changed.
51	beforeClientNoticeMutation func()
52}
53
54func newNoticeCenter() *noticeCenter { return &noticeCenter{} }
55
56func (nc *noticeCenter) record(n domain.Notification) domain.Notification {
57	if n.Count == 0 {
58		n.Count = 1
59	}
60	nc.mu.Lock()
61	defer nc.mu.Unlock()
62	nc.ring = append(nc.ring, n)
63	if len(nc.ring) > noticeHistoryCap {
64		nc.ring = nc.ring[len(nc.ring)-noticeHistoryCap:]
65	}
66	return n
67}
68
69// recordSnapshotFailure coalesces a repeated stable snapshot failure in the
70// history. Details contains the signature rather than raw error text, so this
71// comparison cannot be affected by volatile paths or snapshot content.
72func (nc *noticeCenter) recordSnapshotFailure(n domain.Notification) (domain.Notification, bool) {
73	if n.Count == 0 {
74		n.Count = 1
75	}
76	nc.mu.Lock()
77	defer nc.mu.Unlock()
78	for i := len(nc.ring) - 1; i >= 0; i-- {
79		previous := &nc.ring[i]
80		if previous.Code != domain.NoticeSnapshotWrite || previous.SessionID != n.SessionID || previous.Details != n.Details {
81			continue
82		}
83		previous.Count += n.Count
84		previous.Time = n.Time
85		return *previous, true
86	}
87	nc.ring = append(nc.ring, n)
88	if len(nc.ring) > noticeHistoryCap {
89		nc.ring = nc.ring[len(nc.ring)-noticeHistoryCap:]
90	}
91	return n, false
92}
93
94func (nc *noticeCenter) history() []domain.Notification {
95	nc.mu.Lock()
96	defer nc.mu.Unlock()
97	out := make([]domain.Notification, len(nc.ring))
98	for i, n := range nc.ring {
99		out[len(nc.ring)-1-i] = n
100	}
101	return out
102}
103
104func (nc *noticeCenter) latest() (domain.Notification, bool) {
105	nc.mu.Lock()
106	defer nc.mu.Unlock()
107	if len(nc.ring) == 0 {
108		return domain.Notification{}, false
109	}
110	return nc.ring[len(nc.ring)-1], true
111}
112
113func (nc *noticeCenter) queueGlobal(n domain.Notification) {
114	if n.Count == 0 {
115		n.Count = 1
116	}
117	nc.mu.Lock()
118	defer nc.mu.Unlock()
119	for i := range nc.pending {
120		if sameToastNotice(nc.pending[i], n) {
121			nc.pending[i].Count += n.Count
122			nc.pending[i].Time = n.Time
123			return
124		}
125	}
126	if len(nc.pending) >= noticePendingCap {
127		return // history already has it via record(); toast is dropped
128	}
129	nc.pending = append(nc.pending, n)
130}
131
132func (nc *noticeCenter) drainPending() []domain.Notification {
133	nc.mu.Lock()
134	defer nc.mu.Unlock()
135	out := nc.pending
136	nc.pending = nil
137	return out
138}
139
140// noticeTTL is how long a toast of this severity stays visible.
141func noticeTTL(sev domain.NoticeSeverity) time.Duration {
142	switch sev {
143	case domain.NoticeInfo:
144		return 4 * time.Second
145	case domain.NoticeWarn:
146		return 6 * time.Second
147	default:
148		return 8 * time.Second
149	}
150}
151
152// slogLevelFor maps a notice severity to the slog level it should log at, so
153// routine info notices (e.g. a successful clipboard copy) don't drown out
154// real warnings at the default VEV_LOG=info verbosity.
155func slogLevelFor(sev domain.NoticeSeverity) slog.Level {
156	switch sev {
157	case domain.NoticeInfo:
158		return slog.LevelInfo
159	case domain.NoticeWarn:
160		return slog.LevelWarn
161	default:
162		return slog.LevelError
163	}
164}
165
166// noticeDetails renders the full Unwrap chain for the yank/history views. The
167// toast itself never shows this — only the UserError's own message.
168func noticeDetails(err error) string {
169	if err == nil {
170		return ""
171	}
172	var parts []string
173	for e := err; e != nil; e = errors.Unwrap(e) {
174		parts = append(parts, e.Error())
175	}
176	return strings.Join(parts, " ← ")
177}
178
179// benignNoticeError reports unclassified errors that are expected control flow
180// rather than something the user needs to be told about. A caller that wraps one
181// of these in an explicit *domain.UserError still reports its own message; this
182// filter only keeps an unclassified occurrence from surfacing as an
183// internal-error notice.
184func benignNoticeError(err error) bool {
185	return errors.Is(err, context.Canceled) ||
186		errors.Is(err, errNoNeighbor) ||
187		errors.Is(err, ports.ErrNoClipboardImage) ||
188		// A concurrent detach or token resume that invalidated a teardown's
189		// participant snapshot is expected control flow: the caller observes an
190		// explicit error and retries or reports it, so it must not also surface
191		// as an internal-error notice.
192		errors.Is(err, errSessionKillParticipantsChanged)
193}
194
195// persistShutdownSnapshotFailure records a terminal snapshot failure that
196// cannot be toasted because daemon shutdown has already begun. It belongs to
197// notification ownership so session teardown only decides whether shutdown
198// makes the failure non-retryable.
199func (d *Daemon) persistShutdownSnapshotFailure(name string, cause error) {
200	if d.noticeStore == nil {
201		return
202	}
203	d.shutdownNoticeMu.Lock()
204	if d.shutdownNoticedSessions == nil {
205		d.shutdownNoticedSessions = make(map[string]struct{})
206	}
207	if _, exists := d.shutdownNoticedSessions[name]; exists {
208		d.shutdownNoticeMu.Unlock()
209		return
210	}
211	d.shutdownNoticedSessions[name] = struct{}{}
212	d.shutdownNoticeMu.Unlock()
213	if err := d.noticeStore.Append(domain.Notification{
214		Code:     domain.NoticeSnapshotWrite,
215		Severity: domain.NoticeError,
216		Message:  "session " + name + " shut down without saving terminal state",
217		Details:  noticeDetails(cause),
218		Time:     d.clock.Now(),
219	}); err != nil {
220		d.shutdownNoticeMu.Lock()
221		delete(d.shutdownNoticedSessions, name)
222		d.shutdownNoticeMu.Unlock()
223		d.log.Warn("persisting shutdown notice failed", "err", err, "session", name, "action", "final-checkpoint-timeout")
224	}
225}
226
227// reportError turns any error into a user-facing notice. An explicitly
228// classified *domain.UserError always wins: the caller already decided what the
229// user should see, so it is reported even when the cause chain also matches a
230// routine sentinel (for example a retryable teardown abort). Any other error is
231// dropped only when benignNoticeError says it is routine; unclassified errors
232// become NoticeInternal.
233func (d *Daemon) reportError(sess *session, err error) {
234	if err == nil {
235		return
236	}
237	if ue, ok := errors.AsType[*domain.UserError](err); ok {
238		d.notify(sess, ue.Severity, ue.Code, ue.Msg, ue.Err)
239		return
240	}
241	if benignNoticeError(err) {
242		return
243	}
244	d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err)
245}
246
247func (d *Daemon) reportAttachmentError(entry *session, err error) {
248	d.reportError(entry, err)
249}
250
251// notify records a notice and routes it to whoever can see it. A nil sess means
252// daemon-global: every attached client gets it, and if none is attached the
253// notice waits in the pending queue for the next attach. A detached session's
254// notice waits for that session's next attach.
255//
256// Locking: sess.mu and d.mu are only held to snapshot; showToast is always
257// called with no daemon, session, or pane lock held. Routing holds the notice
258// center's routingMu across selection and publication, which serializes it with
259// attachment detach and pending-queue drains. The order is routingMu -> sess.mu.
260func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
261	n := domain.Notification{
262		Code:     code,
263		Severity: sev,
264		Message:  msg,
265		Details:  noticeDetails(cause),
266		Time:     d.clock.Now(),
267	}
268	if sess != nil {
269		n.SessionID = sess.id
270	}
271	n = d.notices.record(n)
272	d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
273
274	if sess != nil {
275		d.notices.routingMu.Lock()
276		sess.mu.Lock()
277		attachments := sess.snapshotAttachmentsLocked()
278		sess.mu.Unlock()
279		if len(attachments) == 0 {
280			d.notices.queueGlobal(n)
281			d.notices.routingMu.Unlock()
282			return
283		}
284		if d.notices.beforeSessionDelivery != nil {
285			d.notices.beforeSessionDelivery()
286		}
287		published := make([]*attachedClient, 0, len(attachments))
288		for _, ac := range attachments {
289			if d.publishToast(ac, n) {
290				published = append(published, ac)
291			}
292		}
293		d.notices.routingMu.Unlock()
294		for _, ac := range published {
295			d.repaintForNotice(ac)
296		}
297		return
298	}
299
300	d.deliverGlobal(n)
301}
302
303// deliverGlobal atomically selects all current attachments and either publishes
304// to all of them or queues the notice. routingMu is held through toast-state
305// publication, but never through repaint: detach cannot turn a selected target
306// stale before publication, while rendering remains free to re-enter routing.
307func (d *Daemon) deliverGlobal(n domain.Notification) {
308	d.mu.Lock()
309	d.notices.routingMu.Lock()
310	sessions := make([]*session, 0, len(d.sessions))
311	for _, entry := range d.sessions {
312		sessions = append(sessions, entry)
313	}
314	d.mu.Unlock()
315
316	if d.notices.beforeGlobalDelivery != nil {
317		d.notices.beforeGlobalDelivery()
318	}
319	targets := make([]*attachedClient, 0, len(sessions))
320	for _, s := range sessions {
321		if s == nil {
322			continue
323		}
324		for _, ac := range s.snapshotAttachments() {
325			if !d.publishToast(ac, n) {
326				continue
327			}
328			targets = append(targets, ac)
329		}
330	}
331	if len(targets) == 0 {
332		if d.notices.beforeQueueGlobal != nil {
333			d.notices.beforeQueueGlobal()
334		}
335		d.notices.queueGlobal(n)
336	}
337	d.notices.routingMu.Unlock()
338
339	for _, ac := range targets {
340		d.repaintForNotice(ac)
341	}
342}
343
344// drainPendingForFirstPaint publishes pending globals and notices scoped to
345// sess only while ac remains the session's current attachment. It shares
346// routingMu with notice routing, so an unattached route cannot queue after this
347// drain has already happened.
348func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
349	d.notices.routingMu.Lock()
350
351	sess.mu.Lock()
352	current := attachmentRegisteredLocked(sess, ac)
353	sess.mu.Unlock()
354	if !current {
355		d.notices.routingMu.Unlock()
356		return
357	}
358	published := false
359	var keep []domain.Notification
360	for _, n := range d.notices.drainPending() {
361		if n.SessionID == "" || n.SessionID == sess.id {
362			published = d.publishToast(ac, n) || published
363			continue
364		}
365		keep = append(keep, n)
366	}
367	for _, n := range keep {
368		d.notices.queueGlobal(n)
369	}
370	d.notices.routingMu.Unlock()
371	if published {
372		d.repaintForNotice(ac)
373	}
374}
375
376// NotifyGlobal raises a daemon-wide notice from outside the daemon package.
377func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
378	d.notify(nil, sev, code, msg, cause)
379}
380
381// publishToast durably mutates one client's toast state without repainting.
382// Routing paths call it while routingMu excludes detach, then release routingMu
383// before rendering so a render failure can safely route another notice.
384// A notice matching a visible or waiting one (sameToastNotice) adds to its
385// count and restarts its lifetime; otherwise it takes a free slot or waits.
386func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool {
387	if ac == nil {
388		return false
389	}
390	ac.initOverlays()
391	rt := ac.overlays
392
393	rt.noticeMu.Lock()
394	defer rt.noticeMu.Unlock()
395	if rt.noticeQueue == nil {
396		rt.noticeQueue = ui.NewToastQueue[domain.Notification](ui.ToastQueueOptions{MaxVisible: maxVisibleToasts, MaxPending: noticePendingCap})
397	}
398	id := toastNoticeID(n)
399	if prev, ok := rt.noticeQueue.Get(id); ok {
400		prev.Value.Count += n.Count
401		prev.Value.Time = n.Time
402		n = prev.Value
403	}
404	rt.noticeQueue.Push(d.clock.Now(), ui.QueuedToast[domain.Notification]{ID: id, Value: n, Duration: noticeTTL(n.Severity)})
405	d.armToastTimerLocked(ac)
406	return true
407}
408
409// toastNoticeID is a notice's coalescing identity. Generic user notices and
410// endpoint-scoped remote observations use their visible message and details;
411// other typed daemon notices coalesce by code and session.
412func toastNoticeID(n domain.Notification) string {
413	id := strconv.Itoa(int(n.Code)) + "\x00" + string(n.SessionID)
414	if n.Code != domain.NoticeUser && n.Code != domain.NoticeRemoteObservation {
415		return id
416	}
417	return id + "\x00" + strconv.Itoa(int(n.Severity)) + "\x00" + n.Message + "\x00" + n.Details
418}
419
420// sameToastNotice reports whether a and b coalesce into one toast.
421func sameToastNotice(a, b domain.Notification) bool {
422	return toastNoticeID(a) == toastNoticeID(b)
423}
424
425// dismissToastWithoutRepaint removes only the specified toast. In particular, a
426// link-connected event must not clear clipboard or other notice state.
427func (d *Daemon) dismissToastWithoutRepaint(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) bool {
428	if ac == nil || ac.overlays == nil {
429		return false
430	}
431	rt := ac.overlays
432	rt.noticeMu.Lock()
433	defer rt.noticeMu.Unlock()
434	if rt.noticeQueue == nil || !rt.noticeQueue.Dismiss(d.clock.Now(), toastNoticeID(domain.Notification{Code: code, SessionID: sid})) {
435		return false
436	}
437	d.armToastTimerLocked(ac)
438	return true
439}
440
441// armToastTimerLocked points the client's single toast timer at the queue's
442// next expiry. Callers must hold noticeMu; the timer goroutine re-acquires it
443// and releases it before repainting, so the callback never runs with noticeMu
444// held.
445func (d *Daemon) armToastTimerLocked(ac *attachedClient) {
446	rt := ac.overlays
447	due, ok := rt.noticeQueue.NextDeadline()
448	if ok && due.Equal(rt.noticeDue) && rt.noticeTimer.timer != nil {
449		return
450	}
451	rt.noticeTimer.stop()
452	rt.noticeDue = time.Time{}
453	if !ok {
454		return
455	}
456	rt.noticeDue = due
457	rt.noticeTimer.retain(d.clock, max(due.Sub(d.clock.Now()), 0), func(fired ports.Timer) {
458		rt.noticeMu.Lock()
459		rt.noticeQueue.Visible(d.clock.Now())
460		// A fire that lost the race to a re-arm leaves the newer timer alone.
461		if rt.noticeTimer.timer == fired {
462			rt.noticeTimer.timer, rt.noticeTimer.done = nil, nil
463			d.armToastTimerLocked(ac)
464		}
465		rt.noticeMu.Unlock()
466		d.repaintForNotice(ac)
467	})
468}
469
470// repaintForNotice asks for an urgent redraw. It must be called with noticeMu
471// released: invalidateRender can paint inline, which takes sendMu.
472func (d *Daemon) repaintForNotice(ac *attachedClient) {
473	sess := ac.currentSession()
474	if sess == nil {
475		// Mid-handoff or torn down: whoever attaches next repaints anyway, and
476		// invalidateRender cannot paint without a session.
477		return
478	}
479	// Toast footprints carry their own old/new damage, so a normal notice
480	// transition can remain incremental. First paint and transport recovery
481	// still provide their independent reset invariants.
482	d.invalidateRender(sess, ac, false, "notify.go")
483}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close