1package daemon
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"
10 scopy "github.com/bnema/vev/internal/usecase/copy"
11 "github.com/bnema/vev/internal/usecase/keys"
12 "github.com/bnema/vev/internal/usecase/notices"
13 "github.com/bnema/vev/internal/usecase/ui"
14)
15
16var noticesModal = ui.Modal{WidthPct: 70, HeightPct: 70, MinWidth: 40, MinHeight: 8, Title: " Notifications ", Anchor: domain.AnchorCenter}
17
18// enterNotices opens the notification history overlay from the daemon's full
19// notice center history, newest first.
20func (d *Daemon) enterNotices(sess *session, ac *attachedClient) {
21 history := d.notices.history()
22 now := d.clock.Now()
23 ac.overlays.noticeMu.Lock()
24 ac.overlays.noticesOverlay = notices.New(history, now)
25 ac.overlays.noticesPending = nil
26 ac.overlays.noticeMu.Unlock()
27 d.invalidateRender(sess, ac, true, "notices_overlay.go")
28}
29
30// listInputState owns the common pending-ESC lifecycle for list overlays.
31// The caller holds the overlay lock while handleListInputLocked runs. The
32// timer callback takes the same lock before checking its identity, so a later
33// input chunk cannot let a stale lone-ESC close a reopened overlay.
34type listInputState struct {
35 pending *[]byte
36 esc *pendingByteTimer
37 moveUp func()
38 moveDown func()
39 lock func()
40 unlock func()
41 active func() bool
42 closeLocked func()
43 afterClose func()
44}
45
46type listInputResult struct {
47 changed bool
48 exit bool
49 action byte
50 stop bool
51}
52
53func handleListInputLocked(clock ports.Clock, data []byte, state listInputState, action func(byte) listInputResult) listInputResult {
54 if len(*state.pending) > 0 {
55 state.esc.stop()
56 combined := make([]byte, 0, len(*state.pending)+len(data))
57 combined = append(combined, (*state.pending)...)
58 combined = append(combined, data...)
59 data = combined
60 *state.pending = nil
61 }
62
63 var result listInputResult
64 for i := 0; i < len(data); i++ {
65 switch data[i] {
66 case 'j':
67 state.moveDown()
68 result.changed = true
69 case 'k':
70 state.moveUp()
71 result.changed = true
72 case 'q', 0x03:
73 result.exit = true
74 return result
75 case keys.ESC:
76 tail := data[i:]
77 if consumed, move := routeListEscape(tail); consumed > 0 {
78 switch move {
79 case 'A':
80 state.moveUp()
81 result.changed = true
82 case 'B':
83 state.moveDown()
84 result.changed = true
85 }
86 i += consumed - 1
87 continue
88 }
89 if len(tail) == 1 {
90 retainListESCLocked(clock, state)
91 break
92 }
93 if isListEscapePrefix(tail) {
94 *state.pending = append((*state.pending)[:0], tail...)
95 i += len(tail) - 1
96 continue
97 }
98 result.exit = true
99 return result
100 default:
101 custom := action(data[i])
102 if custom.action != 0 {
103 result.action = custom.action
104 }
105 if custom.exit {
106 result.exit = true
107 }
108 if custom.stop {
109 result.stop = true
110 return result
111 }
112 if custom.exit {
113 return result
114 }
115 }
116 }
117 return result
118}
119
120// routeListEscape consumes one complete CSI or SS3 sequence. It returns its
121// byte length and the simple arrow final when it is an Up/Down sequence. Other
122// complete terminal sequences are deliberately ignored so their bytes cannot
123// trigger list actions or close the overlay.
124func routeListEscape(data []byte) (consumed int, move byte) {
125 if len(data) < 2 || data[0] != keys.ESC || (data[1] != '[' && data[1] != 'O') {
126 return 0, 0
127 }
128 for i := 2; i < len(data); i++ {
129 if data[i] < 0x40 || data[i] > 0x7e {
130 continue
131 }
132 if i == 2 && (data[i] == 'A' || data[i] == 'B') {
133 return i + 1, data[i]
134 }
135 return i + 1, 0
136 }
137 return 0, 0
138}
139
140func isListEscapePrefix(data []byte) bool {
141 return len(data) >= 2 && data[0] == keys.ESC && (data[1] == '[' || data[1] == 'O')
142}
143
144func retainListESCLocked(clock ports.Clock, state listInputState) {
145 *state.pending = append((*state.pending)[:0], keys.ESC)
146 state.esc.retain(clock, keys.ESCDelay, func(timer ports.Timer) {
147 state.lock()
148 if state.esc.timer != timer || len(*state.pending) != 1 || (*state.pending)[0] != keys.ESC || !state.active() {
149 state.unlock()
150 return
151 }
152 *state.pending = nil
153 state.esc.timer = nil
154 state.esc.done = nil
155 state.closeLocked()
156 state.unlock()
157 state.afterClose()
158 })
159}
160
161func (d *Daemon) noticesListInputState(ac *attachedClient) listInputState {
162 rt := ac.overlays
163 return listInputState{
164 pending: &rt.noticesPending,
165 esc: &rt.noticesESC,
166 moveUp: rt.noticesOverlay.Up,
167 moveDown: rt.noticesOverlay.Down,
168 lock: rt.noticeMu.Lock,
169 unlock: rt.noticeMu.Unlock,
170 active: func() bool { return rt.noticesOverlay != nil },
171 closeLocked: func() {
172 rt.noticesOverlay = nil
173 },
174 afterClose: func() {
175 if entry := ac.currentAttachmentSession(); entry != nil {
176 d.invalidateRender(entry, ac, true, "notices_overlay.go")
177 }
178 },
179 }
180}
181
182func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
183 entry := ac.currentAttachmentSession()
184 if entry == nil {
185 return
186 }
187 rt := ac.overlays
188 rt.noticeMu.Lock()
189 if rt.noticesOverlay == nil {
190 rt.noticesPending = nil
191 rt.noticesESC.stop()
192 rt.noticeMu.Unlock()
193 return
194 }
195 var yankTarget domain.Notification
196 var yank bool
197 result := handleListInputLocked(d.clock, data, d.noticesListInputState(ac), func(b byte) listInputResult {
198 if b == 'y' {
199 yankTarget, yank = rt.noticesOverlay.Selected()
200 return listInputResult{action: b}
201 }
202 return listInputResult{}
203 })
204 rt.noticeMu.Unlock()
205
206 if result.exit {
207 d.closeNotices(ac)
208 }
209 if yank {
210 // yankNotice sends over the wire and repaints itself, so it must run
211 // with noticeMu released; a quick yank doesn't close the overlay the
212 // way copy mode's own 'y' commits and exits.
213 d.yankNotice(entry, ac, yankTarget)
214 }
215 if result.exit || result.changed {
216 d.invalidateRender(entry, ac, true, "notices_overlay.go")
217 }
218}
219
220// yankNotice copies n's formatted details to the client's clipboard via
221// OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput):
222// send each chunk, then leave a one-shot status-bar confirmation that the
223// next repaint clears.
224func (d *Daemon) yankNotice(entry *session, ac *attachedClient, n domain.Notification) {
225 chunks := scopy.OSC52(noticeYankPayload(n))
226 for _, chunk := range chunks {
227 failed, err := d.boundedSendOutputErrTransport(ac, chunk)
228 if err != nil {
229 if entry != nil {
230 d.detachOnSendError(entry, ac, failed)
231 } else {
232 _ = ac.closeCapturedTransport(failed)
233 }
234 return
235 }
236 }
237 ac.overlays.copyMu.Lock()
238 if len(chunks) > 0 {
239 ac.overlays.statusFeedback = "copied notification details"
240 } else {
241 ac.overlays.statusFeedback = "notification too large to copy"
242 }
243 ac.overlays.copyMu.Unlock()
244 d.invalidateRender(entry, ac, true, "notices_overlay.go")
245}
246
247// noticeYankPayload formats n the way the yank commands put it on the
248// clipboard: a header line with timestamp, severity, code slug, and coalesce
249// count, then the message, then the full cause chain when present.
250func noticeYankPayload(n domain.Notification) string {
251 b := &strings.Builder{}
252 fmt.Fprintf(b, "[%s] %s %s", n.Time.Format(time.RFC3339), severityWord(n.Severity), n.Code.String())
253 if n.Count > 1 {
254 fmt.Fprintf(b, " ×%d", n.Count)
255 }
256 fmt.Fprintf(b, "\n%s\n", n.Message)
257 if n.Details != "" {
258 fmt.Fprintf(b, "details: %s\n", n.Details)
259 }
260 return b.String()
261}
262
263// severityWord renders sev as the word used in yank payloads, distinct from
264// the single-letter form the history list draws (notices.severityLetter).
265func severityWord(sev domain.NoticeSeverity) string {
266 switch sev {
267 case domain.NoticeWarn:
268 return "warn"
269 case domain.NoticeError:
270 return "error"
271 default:
272 return "info"
273 }
274}
275
276func (d *Daemon) closeNotices(ac *attachedClient) {
277 ac.overlays.noticeMu.Lock()
278 ac.overlays.noticesOverlay = nil
279 ac.overlays.noticesPending = nil
280 ac.overlays.noticesESC.stop()
281 ac.overlays.noticeMu.Unlock()
282}