detached 004c25c

feat(domain): add user-facing notice types

commit
004c25c3941a96f17ee4d4706d4bc645eaba9aa3
author
brice · · 2026-07-20 12:01 UTC
parent
4904a5a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +159 -0
A internal/domain/notice.go +103 -0
@@ -0,0 +1,103 @@
1+package domain
2+
3+import "time"
4+
5+// NoticeSeverity ranks a user-facing notification.
6+type NoticeSeverity uint8
7+
8+const (
9+ NoticeInfo NoticeSeverity = iota
10+ NoticeWarn
11+ NoticeError
12+)
13+
14+// NoticeCode identifies a failure meaning, not a call site.
15+type NoticeCode uint16
16+
17+const (
18+ NoticeInternal NoticeCode = iota
19+ NoticePaneSpawn
20+ NoticeTabSpawn
21+ NoticeFloatingSpawn
22+ NoticeSessionSpawn
23+ NoticeLayoutTooSmall
24+ NoticePaneNotFound
25+ NoticeSessionUnavailable
26+ NoticePersistDisabled
27+ NoticeSnapshotWrite
28+ NoticeSnapshotRestore
29+ NoticeSnapshotSaturated
30+ NoticePersistDelete
31+ NoticeConfigReload
32+ NoticeInputDropped
33+ NoticeResizeFailed
34+ NoticeClipboard
35+ NoticeAutoResume
36+)
37+
38+var noticeSlugs = map[NoticeCode]string{
39+ NoticeInternal: "internal",
40+ NoticePaneSpawn: "pane-spawn",
41+ NoticeTabSpawn: "tab-spawn",
42+ NoticeFloatingSpawn: "floating-spawn",
43+ NoticeSessionSpawn: "session-spawn",
44+ NoticeLayoutTooSmall: "layout-too-small",
45+ NoticePaneNotFound: "pane-not-found",
46+ NoticeSessionUnavailable: "session-unavailable",
47+ NoticePersistDisabled: "persist-disabled",
48+ NoticeSnapshotWrite: "snapshot-write",
49+ NoticeSnapshotRestore: "snapshot-restore",
50+ NoticeSnapshotSaturated: "snapshot-saturated",
51+ NoticePersistDelete: "persist-delete",
52+ NoticeConfigReload: "config-reload",
53+ NoticeInputDropped: "input-dropped",
54+ NoticeResizeFailed: "resize-failed",
55+ NoticeClipboard: "clipboard",
56+ NoticeAutoResume: "auto-resume",
57+}
58+
59+func (c NoticeCode) String() string {
60+ if s, ok := noticeSlugs[c]; ok {
61+ return s
62+ }
63+ return "unknown"
64+}
65+
66+// UserError is an error whose message is written for the attached user.
67+// The wrapped cause is shown only in yank details, never in the toast.
68+type UserError struct {
69+ Code NoticeCode
70+ Severity NoticeSeverity
71+ Msg string
72+ Err error
73+}
74+
75+func (e *UserError) Error() string {
76+ if e.Err == nil {
77+ return e.Msg
78+ }
79+ return e.Msg + ": " + e.Err.Error()
80+}
81+
82+func (e *UserError) Unwrap() error { return e.Err }
83+
84+// UserErr wraps err as an error-severity user-facing failure.
85+func UserErr(code NoticeCode, msg string, err error) *UserError {
86+ return &UserError{Code: code, Severity: NoticeError, Msg: msg, Err: err}
87+}
88+
89+// UserWarn wraps err as a warn-severity user-facing failure.
90+func UserWarn(code NoticeCode, msg string, err error) *UserError {
91+ return &UserError{Code: code, Severity: NoticeWarn, Msg: msg, Err: err}
92+}
93+
94+// Notification is one entry in the daemon's user-facing notice history.
95+type Notification struct {
96+ Code NoticeCode
97+ Severity NoticeSeverity
98+ Message string
99+ Details string // formatted error chain for yank
100+ Time time.Time
101+ Count int // >1 when coalesced
102+ SessionID SessionID // "" = daemon-global
103+}
@@ -0,0 +1,103 @@
1+package domain
2+
3+import "time"
4+
5+// NoticeSeverity ranks a user-facing notification.
6+type NoticeSeverity uint8
7+
8+const (
9+ NoticeInfo NoticeSeverity = iota
10+ NoticeWarn
11+ NoticeError
12+)
13+
14+// NoticeCode identifies a failure meaning, not a call site.
15+type NoticeCode uint16
16+
17+const (
18+ NoticeInternal NoticeCode = iota
19+ NoticePaneSpawn
20+ NoticeTabSpawn
21+ NoticeFloatingSpawn
22+ NoticeSessionSpawn
23+ NoticeLayoutTooSmall
24+ NoticePaneNotFound
25+ NoticeSessionUnavailable
26+ NoticePersistDisabled
27+ NoticeSnapshotWrite
28+ NoticeSnapshotRestore
29+ NoticeSnapshotSaturated
30+ NoticePersistDelete
31+ NoticeConfigReload
32+ NoticeInputDropped
33+ NoticeResizeFailed
34+ NoticeClipboard
35+ NoticeAutoResume
36+)
37+
38+var noticeSlugs = map[NoticeCode]string{
39+ NoticeInternal: "internal",
40+ NoticePaneSpawn: "pane-spawn",
41+ NoticeTabSpawn: "tab-spawn",
42+ NoticeFloatingSpawn: "floating-spawn",
43+ NoticeSessionSpawn: "session-spawn",
44+ NoticeLayoutTooSmall: "layout-too-small",
45+ NoticePaneNotFound: "pane-not-found",
46+ NoticeSessionUnavailable: "session-unavailable",
47+ NoticePersistDisabled: "persist-disabled",
48+ NoticeSnapshotWrite: "snapshot-write",
49+ NoticeSnapshotRestore: "snapshot-restore",
50+ NoticeSnapshotSaturated: "snapshot-saturated",
51+ NoticePersistDelete: "persist-delete",
52+ NoticeConfigReload: "config-reload",
53+ NoticeInputDropped: "input-dropped",
54+ NoticeResizeFailed: "resize-failed",
55+ NoticeClipboard: "clipboard",
56+ NoticeAutoResume: "auto-resume",
57+}
58+
59+func (c NoticeCode) String() string {
60+ if s, ok := noticeSlugs[c]; ok {
61+ return s
62+ }
63+ return "unknown"
64+}
65+
66+// UserError is an error whose message is written for the attached user.
67+// The wrapped cause is shown only in yank details, never in the toast.
68+type UserError struct {
69+ Code NoticeCode
70+ Severity NoticeSeverity
71+ Msg string
72+ Err error
73+}
74+
75+func (e *UserError) Error() string {
76+ if e.Err == nil {
77+ return e.Msg
78+ }
79+ return e.Msg + ": " + e.Err.Error()
80+}
81+
82+func (e *UserError) Unwrap() error { return e.Err }
83+
84+// UserErr wraps err as an error-severity user-facing failure.
85+func UserErr(code NoticeCode, msg string, err error) *UserError {
86+ return &UserError{Code: code, Severity: NoticeError, Msg: msg, Err: err}
87+}
88+
89+// UserWarn wraps err as a warn-severity user-facing failure.
90+func UserWarn(code NoticeCode, msg string, err error) *UserError {
91+ return &UserError{Code: code, Severity: NoticeWarn, Msg: msg, Err: err}
92+}
93+
94+// Notification is one entry in the daemon's user-facing notice history.
95+type Notification struct {
96+ Code NoticeCode
97+ Severity NoticeSeverity
98+ Message string
99+ Details string // formatted error chain for yank
100+ Time time.Time
101+ Count int // >1 when coalesced
102+ SessionID SessionID // "" = daemon-global
103+}
A internal/domain/notice_test.go +56 -0
@@ -0,0 +1,56 @@
1+package domain
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "testing"
7+)
8+
9+func TestNoticeCodeString(t *testing.T) {
10+ tests := []struct {
11+ code NoticeCode
12+ want string
13+ }{
14+ {NoticeInternal, "internal"},
15+ {NoticePaneSpawn, "pane-spawn"},
16+ {NoticeSnapshotWrite, "snapshot-write"},
17+ {NoticeCode(9999), "unknown"},
18+ }
19+ for _, tt := range tests {
20+ if got := tt.code.String(); got != tt.want {
21+ t.Errorf("NoticeCode(%d).String() = %q, want %q", tt.code, got, tt.want)
22+ }
23+ }
24+}
25+
26+func TestUserError(t *testing.T) {
27+ cause := errors.New("boom")
28+ tests := []struct {
29+ name string
30+ err *UserError
31+ wantMsg string
32+ wantSev NoticeSeverity
33+ wantWrap error
34+ }{
35+ {"err with cause", UserErr(NoticePaneSpawn, "couldn't open pane", cause), "couldn't open pane: boom", NoticeError, cause},
36+ {"err without cause", UserErr(NoticeSessionUnavailable, "session is unavailable", nil), "session is unavailable", NoticeError, nil},
37+ {"warn", UserWarn(NoticeResizeFailed, "pane resize failed", cause), "pane resize failed: boom", NoticeWarn, cause},
38+ }
39+ for _, tt := range tests {
40+ t.Run(tt.name, func(t *testing.T) {
41+ if got := tt.err.Error(); got != tt.wantMsg {
42+ t.Errorf("Error() = %q, want %q", got, tt.wantMsg)
43+ }
44+ if tt.err.Severity != tt.wantSev {
45+ t.Errorf("Severity = %d, want %d", tt.err.Severity, tt.wantSev)
46+ }
47+ if !errors.Is(tt.err, tt.wantWrap) && tt.wantWrap != nil {
48+ t.Errorf("errors.Is(err, cause) = false, want true")
49+ }
50+ var ue *UserError
51+ if !errors.As(fmt.Errorf("wrapped: %w", tt.err), &ue) {
52+ t.Errorf("errors.As through wrapping = false, want true")
53+ }
54+ })
55+ }
56+}
@@ -0,0 +1,56 @@
1+package domain
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "testing"
7+)
8+
9+func TestNoticeCodeString(t *testing.T) {
10+ tests := []struct {
11+ code NoticeCode
12+ want string
13+ }{
14+ {NoticeInternal, "internal"},
15+ {NoticePaneSpawn, "pane-spawn"},
16+ {NoticeSnapshotWrite, "snapshot-write"},
17+ {NoticeCode(9999), "unknown"},
18+ }
19+ for _, tt := range tests {
20+ if got := tt.code.String(); got != tt.want {
21+ t.Errorf("NoticeCode(%d).String() = %q, want %q", tt.code, got, tt.want)
22+ }
23+ }
24+}
25+
26+func TestUserError(t *testing.T) {
27+ cause := errors.New("boom")
28+ tests := []struct {
29+ name string
30+ err *UserError
31+ wantMsg string
32+ wantSev NoticeSeverity
33+ wantWrap error
34+ }{
35+ {"err with cause", UserErr(NoticePaneSpawn, "couldn't open pane", cause), "couldn't open pane: boom", NoticeError, cause},
36+ {"err without cause", UserErr(NoticeSessionUnavailable, "session is unavailable", nil), "session is unavailable", NoticeError, nil},
37+ {"warn", UserWarn(NoticeResizeFailed, "pane resize failed", cause), "pane resize failed: boom", NoticeWarn, cause},
38+ }
39+ for _, tt := range tests {
40+ t.Run(tt.name, func(t *testing.T) {
41+ if got := tt.err.Error(); got != tt.wantMsg {
42+ t.Errorf("Error() = %q, want %q", got, tt.wantMsg)
43+ }
44+ if tt.err.Severity != tt.wantSev {
45+ t.Errorf("Severity = %d, want %d", tt.err.Severity, tt.wantSev)
46+ }
47+ if !errors.Is(tt.err, tt.wantWrap) && tt.wantWrap != nil {
48+ t.Errorf("errors.Is(err, cause) = false, want true")
49+ }
50+ var ue *UserError
51+ if !errors.As(fmt.Errorf("wrapped: %w", tt.err), &ue) {
52+ t.Errorf("errors.As through wrapping = false, want true")
53+ }
54+ })
55+ }
56+}

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