detached dbdedb4

feat(daemon): add notification center ring and pending queue

Adds noticeCenter: a 200-entry history ring and a 32-cap pending-global
queue (deduped by NoticeCode) for daemon-wide user notifications. Store
only — Task 3 wires reportError/notify and client draining on top.
commit
dbdedb471aea6ee434e6d69538c9c27342b5e375
author
brice · · 2026-07-20 12:06 UTC
parent
004c25c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +137 -0
A internal/usecase/daemon/notify.go +82 -0
@@ -0,0 +1,82 @@
1+package daemon
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+const (
10+ noticeHistoryCap = 200
11+ noticePendingCap = 32
12+)
13+
14+// noticeCenter owns the daemon-wide notification history and the queue of
15+// global notices awaiting a first attached client. Its mu is leaf-level: no
16+// other lock is ever taken while holding it.
17+type noticeCenter struct {
18+ mu sync.Mutex
19+ ring []domain.Notification
20+ pending []domain.Notification
21+}
22+
23+func newNoticeCenter() *noticeCenter { return &noticeCenter{} }
24+
25+func (nc *noticeCenter) record(n domain.Notification) domain.Notification {
26+ if n.Count == 0 {
27+ n.Count = 1
28+ }
29+ nc.mu.Lock()
30+ defer nc.mu.Unlock()
31+ nc.ring = append(nc.ring, n)
32+ if len(nc.ring) > noticeHistoryCap {
33+ nc.ring = nc.ring[len(nc.ring)-noticeHistoryCap:]
34+ }
35+ return n
36+}
37+
38+func (nc *noticeCenter) history() []domain.Notification {
39+ nc.mu.Lock()
40+ defer nc.mu.Unlock()
41+ out := make([]domain.Notification, len(nc.ring))
42+ for i, n := range nc.ring {
43+ out[len(nc.ring)-1-i] = n
44+ }
45+ return out
46+}
47+
48+func (nc *noticeCenter) latest() (domain.Notification, bool) {
49+ nc.mu.Lock()
50+ defer nc.mu.Unlock()
51+ if len(nc.ring) == 0 {
52+ return domain.Notification{}, false
53+ }
54+ return nc.ring[len(nc.ring)-1], true
55+}
56+
57+func (nc *noticeCenter) queueGlobal(n domain.Notification) {
58+ if n.Count == 0 {
59+ n.Count = 1
60+ }
61+ nc.mu.Lock()
62+ defer nc.mu.Unlock()
63+ for i := range nc.pending {
64+ if nc.pending[i].Code == n.Code {
65+ nc.pending[i].Count += n.Count
66+ nc.pending[i].Time = n.Time
67+ return
68+ }
69+ }
70+ if len(nc.pending) >= noticePendingCap {
71+ return // history already has it via record(); toast is dropped
72+ }
73+ nc.pending = append(nc.pending, n)
74+}
75+
76+func (nc *noticeCenter) drainPending() []domain.Notification {
77+ nc.mu.Lock()
78+ defer nc.mu.Unlock()
79+ out := nc.pending
80+ nc.pending = nil
81+ return out
82+}
@@ -0,0 +1,82 @@
1+package daemon
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+const (
10+ noticeHistoryCap = 200
11+ noticePendingCap = 32
12+)
13+
14+// noticeCenter owns the daemon-wide notification history and the queue of
15+// global notices awaiting a first attached client. Its mu is leaf-level: no
16+// other lock is ever taken while holding it.
17+type noticeCenter struct {
18+ mu sync.Mutex
19+ ring []domain.Notification
20+ pending []domain.Notification
21+}
22+
23+func newNoticeCenter() *noticeCenter { return &noticeCenter{} }
24+
25+func (nc *noticeCenter) record(n domain.Notification) domain.Notification {
26+ if n.Count == 0 {
27+ n.Count = 1
28+ }
29+ nc.mu.Lock()
30+ defer nc.mu.Unlock()
31+ nc.ring = append(nc.ring, n)
32+ if len(nc.ring) > noticeHistoryCap {
33+ nc.ring = nc.ring[len(nc.ring)-noticeHistoryCap:]
34+ }
35+ return n
36+}
37+
38+func (nc *noticeCenter) history() []domain.Notification {
39+ nc.mu.Lock()
40+ defer nc.mu.Unlock()
41+ out := make([]domain.Notification, len(nc.ring))
42+ for i, n := range nc.ring {
43+ out[len(nc.ring)-1-i] = n
44+ }
45+ return out
46+}
47+
48+func (nc *noticeCenter) latest() (domain.Notification, bool) {
49+ nc.mu.Lock()
50+ defer nc.mu.Unlock()
51+ if len(nc.ring) == 0 {
52+ return domain.Notification{}, false
53+ }
54+ return nc.ring[len(nc.ring)-1], true
55+}
56+
57+func (nc *noticeCenter) queueGlobal(n domain.Notification) {
58+ if n.Count == 0 {
59+ n.Count = 1
60+ }
61+ nc.mu.Lock()
62+ defer nc.mu.Unlock()
63+ for i := range nc.pending {
64+ if nc.pending[i].Code == n.Code {
65+ nc.pending[i].Count += n.Count
66+ nc.pending[i].Time = n.Time
67+ return
68+ }
69+ }
70+ if len(nc.pending) >= noticePendingCap {
71+ return // history already has it via record(); toast is dropped
72+ }
73+ nc.pending = append(nc.pending, n)
74+}
75+
76+func (nc *noticeCenter) drainPending() []domain.Notification {
77+ nc.mu.Lock()
78+ defer nc.mu.Unlock()
79+ out := nc.pending
80+ nc.pending = nil
81+ return out
82+}
A internal/usecase/daemon/notify_test.go +55 -0
@@ -0,0 +1,55 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification {
11+ return domain.Notification{Code: code, Severity: domain.NoticeError, Message: msg, Time: time.Unix(1, 0), SessionID: sid}
12+}
13+
14+func TestNoticeCenterRingEviction(t *testing.T) {
15+ nc := newNoticeCenter()
16+ for i := 0; i < 205; i++ {
17+ nc.record(notice(domain.NoticeInternal, "m", ""))
18+ }
19+ h := nc.history()
20+ if len(h) != 200 {
21+ t.Fatalf("history len = %d, want 200", len(h))
22+ }
23+}
24+
25+func TestNoticeCenterHistoryNewestFirst(t *testing.T) {
26+ nc := newNoticeCenter()
27+ nc.record(notice(domain.NoticePaneSpawn, "first", ""))
28+ nc.record(notice(domain.NoticeTabSpawn, "second", ""))
29+ h := nc.history()
30+ if h[0].Message != "second" || h[1].Message != "first" {
31+ t.Fatalf("history order = %q,%q; want second,first", h[0].Message, h[1].Message)
32+ }
33+ last, ok := nc.latest()
34+ if !ok || last.Message != "second" {
35+ t.Fatalf("latest = %v,%v", last.Message, ok)
36+ }
37+}
38+
39+func TestNoticeCenterPendingQueue(t *testing.T) {
40+ nc := newNoticeCenter()
41+ for i := 0; i < 40; i++ {
42+ nc.queueGlobal(notice(domain.NoticeSnapshotRestore, "restore failed", ""))
43+ }
44+ nc.queueGlobal(notice(domain.NoticePersistDisabled, "persistence disabled", ""))
45+ pending := nc.drainPending()
46+ if len(pending) != 2 {
47+ t.Fatalf("pending len = %d, want 2 (deduped by code)", len(pending))
48+ }
49+ if pending[0].Count != 40 {
50+ t.Fatalf("coalesced Count = %d, want 40", pending[0].Count)
51+ }
52+ if got := nc.drainPending(); len(got) != 0 {
53+ t.Fatalf("second drain len = %d, want 0", len(got))
54+ }
55+}
@@ -0,0 +1,55 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification {
11+ return domain.Notification{Code: code, Severity: domain.NoticeError, Message: msg, Time: time.Unix(1, 0), SessionID: sid}
12+}
13+
14+func TestNoticeCenterRingEviction(t *testing.T) {
15+ nc := newNoticeCenter()
16+ for i := 0; i < 205; i++ {
17+ nc.record(notice(domain.NoticeInternal, "m", ""))
18+ }
19+ h := nc.history()
20+ if len(h) != 200 {
21+ t.Fatalf("history len = %d, want 200", len(h))
22+ }
23+}
24+
25+func TestNoticeCenterHistoryNewestFirst(t *testing.T) {
26+ nc := newNoticeCenter()
27+ nc.record(notice(domain.NoticePaneSpawn, "first", ""))
28+ nc.record(notice(domain.NoticeTabSpawn, "second", ""))
29+ h := nc.history()
30+ if h[0].Message != "second" || h[1].Message != "first" {
31+ t.Fatalf("history order = %q,%q; want second,first", h[0].Message, h[1].Message)
32+ }
33+ last, ok := nc.latest()
34+ if !ok || last.Message != "second" {
35+ t.Fatalf("latest = %v,%v", last.Message, ok)
36+ }
37+}
38+
39+func TestNoticeCenterPendingQueue(t *testing.T) {
40+ nc := newNoticeCenter()
41+ for i := 0; i < 40; i++ {
42+ nc.queueGlobal(notice(domain.NoticeSnapshotRestore, "restore failed", ""))
43+ }
44+ nc.queueGlobal(notice(domain.NoticePersistDisabled, "persistence disabled", ""))
45+ pending := nc.drainPending()
46+ if len(pending) != 2 {
47+ t.Fatalf("pending len = %d, want 2 (deduped by code)", len(pending))
48+ }
49+ if pending[0].Count != 40 {
50+ t.Fatalf("coalesced Count = %d, want 40", pending[0].Count)
51+ }
52+ if got := nc.drainPending(); len(got) != 0 {
53+ t.Fatalf("second drain len = %d, want 0", len(got))
54+ }
55+}

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