1package daemon
2
3import (
4 "errors"
5
6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/domain/layout"
8 "github.com/bnema/vev/internal/protocol"
9)
10
11// moveRejectionReason is the daemon API category for a rejected move. It keeps
12// callers independent of topology and layout implementation errors.
13type moveRejectionReason uint8
14
15const (
16 moveRejectionInvalid moveRejectionReason = iota + 1
17 moveRejectionNoDestination
18 moveRejectionFloatingWarming
19 moveRejectionFinalSourceFloating
20 moveRejectionStaleTarget
21 moveRejectionTooSmall
22 moveRejectionPaneIDExhausted
23)
24
25// moveRejection carries a stable category while Unwrap preserves identities
26// relied on by lower-level move tests and callers.
27type moveRejection struct {
28 Reason moveRejectionReason
29 msg string
30 cause error
31}
32
33func (e *moveRejection) Error() string { return e.msg }
34func (e *moveRejection) Unwrap() error { return e.cause }
35
36// moveRejectionDescriptor is the canonical presentation for one rejection
37// reason. Palette and control adapters only wrap these values for their
38// respective transports.
39type moveRejectionDescriptor struct {
40 Reason moveRejectionReason
41 PaletteCode domain.NoticeCode
42 PaletteSeverity domain.NoticeSeverity
43 PaletteText string
44 CommandCode uint16
45 CommandText string
46}
47
48func moveRejectionDescriptorFor(err error) (moveRejectionDescriptor, bool) {
49 reason, ok := moveRejectionReasonFor(err)
50 if !ok {
51 return moveRejectionDescriptor{}, false
52 }
53 return moveRejectionDescriptorByReason(reason), true
54}
55
56func moveRejectionReasonFor(err error) (moveRejectionReason, bool) {
57 var rejection *moveRejection
58 if errors.As(err, &rejection) {
59 return rejection.Reason, true
60 }
61 if errors.Is(err, errMoveLifecycleUnavailable) {
62 return moveRejectionStaleTarget, true
63 }
64 return 0, false
65}
66
67func moveRejectionDescriptorByReason(reason moveRejectionReason) moveRejectionDescriptor {
68 descriptor := moveRejectionDescriptor{
69 Reason: reason,
70 PaletteCode: domain.NoticeInternal,
71 PaletteSeverity: domain.NoticeError,
72 PaletteText: "Move failed.",
73 CommandCode: protocol.ErrNoSuchTarget,
74 CommandText: "move request is invalid",
75 }
76 switch reason {
77 case moveRejectionNoDestination:
78 descriptor.PaletteCode = domain.NoticeSessionUnavailable
79 descriptor.PaletteSeverity = domain.NoticeWarn
80 descriptor.PaletteText = "No destination available."
81 descriptor.CommandText = "no move destination available"
82 case moveRejectionFloatingWarming:
83 descriptor.PaletteCode = domain.NoticeSessionUnavailable
84 descriptor.PaletteSeverity = domain.NoticeWarn
85 descriptor.PaletteText = "Wait for the floating pane to finish opening."
86 descriptor.CommandText = "wait for the floating pane to finish opening"
87 case moveRejectionFinalSourceFloating:
88 descriptor.PaletteCode = domain.NoticeLayoutTooSmall
89 descriptor.PaletteSeverity = domain.NoticeWarn
90 descriptor.PaletteText = "Close the floating pane or move the whole tab."
91 descriptor.CommandText = "close the floating pane or move the whole tab"
92 case moveRejectionStaleTarget:
93 descriptor.PaletteCode = domain.NoticeSessionUnavailable
94 descriptor.PaletteSeverity = domain.NoticeWarn
95 descriptor.PaletteText = "Destination is no longer available."
96 descriptor.CommandText = "move target is no longer available"
97 case moveRejectionTooSmall:
98 descriptor.PaletteCode = domain.NoticeLayoutTooSmall
99 descriptor.PaletteSeverity = domain.NoticeWarn
100 descriptor.PaletteText = "Not enough space in destination tab."
101 descriptor.CommandText = "not enough space in destination tab"
102 case moveRejectionPaneIDExhausted:
103 descriptor.PaletteText = "Destination pane IDs are exhausted."
104 descriptor.CommandText = "destination pane IDs are exhausted"
105 }
106 return descriptor
107}
108
109func movePickerUserError(err error) error {
110 if err == nil {
111 return nil
112 }
113 var userErr *domain.UserError
114 if errors.As(err, &userErr) {
115 return err
116 }
117 if errors.Is(err, errPurgeAdmissionClosed) {
118 // A transient KillAll admission rejection is retry state: surface a
119 // retryable warning instead of a permanent "Move failed" verdict.
120 return domain.UserWarn(domain.NoticeSessionUnavailable, sessionKillRetryMessage, err)
121 }
122 descriptor, ok := moveRejectionDescriptorFor(err)
123 if !ok {
124 descriptor = moveRejectionDescriptorByReason(moveRejectionInvalid)
125 }
126 if descriptor.PaletteSeverity == domain.NoticeWarn {
127 return domain.UserWarn(descriptor.PaletteCode, descriptor.PaletteText, err)
128 }
129 return domain.UserErr(descriptor.PaletteCode, descriptor.PaletteText, err)
130}
131
132func isMoveCommandError(err error) bool {
133 _, ok := moveRejectionReasonFor(err)
134 return ok
135}
136
137func moveCommandFailure(err error) protocol.CommandResult {
138 descriptor, ok := moveRejectionDescriptorFor(err)
139 if !ok {
140 descriptor = moveRejectionDescriptorByReason(moveRejectionInvalid)
141 }
142 return commandFailure(descriptor.CommandCode, descriptor.CommandText)
143}
144
145func normalizeMoveRejection(err error) error {
146 if err == nil {
147 return nil
148 }
149 var rejection *moveRejection
150 if errors.As(err, &rejection) {
151 return err
152 }
153 if errors.Is(err, errPurgeAdmissionClosed) {
154 // A transient KillAll admission rejection is retry state, not a move
155 // rejection: preserve the typed sentinel so the command and palette
156 // adapters map it consistently with the other command paths.
157 return err
158 }
159 if errors.Is(err, layout.ErrTooSmall) {
160 return errMoveTooSmall
161 }
162 if errors.Is(err, errMoveLifecycleUnavailable) {
163 return &moveRejection{Reason: moveRejectionStaleTarget, msg: errMoveStaleTarget.msg, cause: errors.Join(errMovePaneInvalid, err)}
164 }
165 return &moveRejection{Reason: moveRejectionInvalid, msg: errMovePaneInvalid.msg, cause: errors.Join(errMovePaneInvalid, err)}
166}
167
168var (
169 errMovePaneInvalid = &moveRejection{Reason: moveRejectionInvalid, msg: "move request is invalid"}
170 errNoMoveDestination = &moveRejection{
171 Reason: moveRejectionNoDestination,
172 msg: "no eligible move destination",
173 }
174 errMoveFloatingWarming = &moveRejection{
175 Reason: moveRejectionFloatingWarming,
176 msg: "floating pane is still opening",
177 }
178 errMovePaneFloatingSibling = errors.New("cannot remove a source tab with a floating pane")
179 errMoveFinalSourceFloating = &moveRejection{
180 Reason: moveRejectionFinalSourceFloating,
181 msg: "final source pane cannot move while its tab has a floating pane",
182 cause: errMovePaneFloatingSibling,
183 }
184 errMoveStaleTarget = &moveRejection{
185 Reason: moveRejectionStaleTarget,
186 msg: "move target is no longer available",
187 cause: errMovePaneInvalid,
188 }
189 errMoveTooSmall = &moveRejection{
190 Reason: moveRejectionTooSmall,
191 msg: "move destination is too small",
192 cause: layout.ErrTooSmall,
193 }
194 errMovePaneIDExhausted = &moveRejection{
195 Reason: moveRejectionPaneIDExhausted,
196 msg: "destination pane IDs exhausted",
197 }
198)