v0.3.0 99dc266

notify.go RawHistoryFolder

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

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