feat(domain): add user-facing notice types
- commit
- 004c25c3941a96f17ee4d4706d4bc645eaba9aa3
- author
- brice · · 2026-07-20 12:01 UTC
- parent
- 4904a5a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||