detached 52bea82

refactor(daemon): log user notices at severity-mapped level

d.notify logged every notice at Warn regardless of severity, so a
routine NoticeInfo (e.g. a successful clipboard copy) would emit a
WARN line, drowning out real problems at the default VEV_LOG=info
verbosity. Add slogLevelFor to map NoticeInfo/Warn/Error to the
matching slog level and log through d.log.Log(d.serveCtx, ...) using
the daemon's existing ambient context.
commit
52bea82d9e827b176a59c26c6c56cf134a8dc442
author
brice · · 2026-07-20 12:39 UTC
parent
04dc983
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +59 -1
M internal/usecase/daemon/notify.go +16 -1
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "errors"
6+ "log/slog"
67 "strings"
78 "sync"
89 "time"
@@ -113,6 +114,20 @@ func noticeTTL(sev domain.NoticeSeverity) time.Duration {
113114 }
114115 }
115116
117+// slogLevelFor maps a notice severity to the slog level it should log at, so
118+// routine info notices (e.g. a successful clipboard copy) don't drown out
119+// real warnings at the default VEV_LOG=info verbosity.
120+func slogLevelFor(sev domain.NoticeSeverity) slog.Level {
121+ switch sev {
122+ case domain.NoticeInfo:
123+ return slog.LevelInfo
124+ case domain.NoticeWarn:
125+ return slog.LevelWarn
126+ default:
127+ return slog.LevelError
128+ }
129+}
130+
116131 // noticeDetails renders the full Unwrap chain for the yank/history views. The
117132 // toast itself never shows this — only the UserError's own message.
118133 func noticeDetails(err error) string {
@@ -167,7 +182,7 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
167182 n.SessionID = sess.id
168183 }
169184 n = d.notices.record(n)
170- d.log.Warn("user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
185+ d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
171186
172187 if sess != nil {
173188 sess.mu.Lock()
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "log/slog"
6 "strings"7 "strings"
7 "sync"8 "sync"
8 "time"9 "time"
@@ -113,6 +114,20 @@ func noticeTTL(sev domain.NoticeSeverity) time.Duration {
113 }114 }
114 }115 }
115 116
117+// slogLevelFor maps a notice severity to the slog level it should log at, so
118+// routine info notices (e.g. a successful clipboard copy) don't drown out
119+// real warnings at the default VEV_LOG=info verbosity.
120+func slogLevelFor(sev domain.NoticeSeverity) slog.Level {
121+ switch sev {
122+ case domain.NoticeInfo:
123+ return slog.LevelInfo
124+ case domain.NoticeWarn:
125+ return slog.LevelWarn
126+ default:
127+ return slog.LevelError
128+ }
129+}
130+
116 // noticeDetails renders the full Unwrap chain for the yank/history views. The131 // noticeDetails renders the full Unwrap chain for the yank/history views. The
117 // toast itself never shows this — only the UserError's own message.132 // toast itself never shows this — only the UserError's own message.
118 func noticeDetails(err error) string {133 func noticeDetails(err error) string {
@@ -167,7 +182,7 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
167 n.SessionID = sess.id182 n.SessionID = sess.id
168 }183 }
169 n = d.notices.record(n)184 n = d.notices.record(n)
170- d.log.Warn("user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)185+ d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
171 186
172 if sess != nil {187 if sess != nil {
173 sess.mu.Lock()188 sess.mu.Lock()
M internal/usecase/daemon/notify_test.go +43 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "log/slog"
78 "sync"
89 "sync/atomic"
910 "testing"
@@ -442,6 +443,48 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
442443 require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
443444 }
444445
446+// levelCaptureHandler records the slog.Level of every record it's handed, so
447+// tests can assert on log verbosity without parsing formatted output.
448+type levelCaptureHandler struct {
449+ mu sync.Mutex
450+ levels []slog.Level
451+}
452+
453+func (h *levelCaptureHandler) Enabled(context.Context, slog.Level) bool { return true }
454+
455+func (h *levelCaptureHandler) Handle(_ context.Context, r slog.Record) error {
456+ h.mu.Lock()
457+ defer h.mu.Unlock()
458+ h.levels = append(h.levels, r.Level)
459+ return nil
460+}
461+
462+func (h *levelCaptureHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
463+func (h *levelCaptureHandler) WithGroup(string) slog.Handler { return h }
464+
465+func TestNotifyLogsAtSeverityLevel(t *testing.T) {
466+ tests := []struct {
467+ name string
468+ sev domain.NoticeSeverity
469+ want slog.Level
470+ }{
471+ {"info severity logs at info", domain.NoticeInfo, slog.LevelInfo},
472+ {"warn severity logs at warn", domain.NoticeWarn, slog.LevelWarn},
473+ {"error severity logs at error", domain.NoticeError, slog.LevelError},
474+ }
475+ for _, tt := range tests {
476+ t.Run(tt.name, func(t *testing.T) {
477+ d := newTestDaemon(t, nil, newNoticeClock())
478+ capture := &levelCaptureHandler{}
479+ d.log = slog.New(capture)
480+
481+ d.notify(nil, tt.sev, domain.NoticeClipboard, "msg", nil)
482+
483+ require.Equal(t, []slog.Level{tt.want}, capture.levels)
484+ })
485+ }
486+}
487+
445488 func TestShowToastCoalesceAndTrim(t *testing.T) {
446489 t.Run("same code and scope coalesces", func(t *testing.T) {
447490 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7+ "log/slog"
7 "sync"8 "sync"
8 "sync/atomic"9 "sync/atomic"
9 "testing"10 "testing"
@@ -442,6 +443,48 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
442 require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")443 require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
443 }444 }
444 445
446+// levelCaptureHandler records the slog.Level of every record it's handed, so
447+// tests can assert on log verbosity without parsing formatted output.
448+type levelCaptureHandler struct {
449+ mu sync.Mutex
450+ levels []slog.Level
451+}
452+
453+func (h *levelCaptureHandler) Enabled(context.Context, slog.Level) bool { return true }
454+
455+func (h *levelCaptureHandler) Handle(_ context.Context, r slog.Record) error {
456+ h.mu.Lock()
457+ defer h.mu.Unlock()
458+ h.levels = append(h.levels, r.Level)
459+ return nil
460+}
461+
462+func (h *levelCaptureHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
463+func (h *levelCaptureHandler) WithGroup(string) slog.Handler { return h }
464+
465+func TestNotifyLogsAtSeverityLevel(t *testing.T) {
466+ tests := []struct {
467+ name string
468+ sev domain.NoticeSeverity
469+ want slog.Level
470+ }{
471+ {"info severity logs at info", domain.NoticeInfo, slog.LevelInfo},
472+ {"warn severity logs at warn", domain.NoticeWarn, slog.LevelWarn},
473+ {"error severity logs at error", domain.NoticeError, slog.LevelError},
474+ }
475+ for _, tt := range tests {
476+ t.Run(tt.name, func(t *testing.T) {
477+ d := newTestDaemon(t, nil, newNoticeClock())
478+ capture := &levelCaptureHandler{}
479+ d.log = slog.New(capture)
480+
481+ d.notify(nil, tt.sev, domain.NoticeClipboard, "msg", nil)
482+
483+ require.Equal(t, []slog.Level{tt.want}, capture.levels)
484+ })
485+ }
486+}
487+
445 func TestShowToastCoalesceAndTrim(t *testing.T) {488 func TestShowToastCoalesceAndTrim(t *testing.T) {
446 t.Run("same code and scope coalesces", func(t *testing.T) {489 t.Run("same code and scope coalesces", func(t *testing.T) {
447 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())490 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())

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