1package client
2
3import (
4 "time"
5
6 renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/protocol"
10 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
11)
12
13// pickerPreviewDebounce bounds how long the cursor may rest before the client
14// subscribes to that row's preview. The daemon pushes frames afterwards, so
15// this only throttles cursor movement.
16const pickerPreviewDebounce = 80 * time.Millisecond
17
18// previewCacheSize bounds the last-good frames kept for stale-while-refresh.
19const previewCacheSize = 8
20
21// previewState is how the displayed preview relates to its target.
22type previewState uint8
23
24const (
25 previewFresh previewState = iota
26 previewStale
27 previewLoading
28 previewUnavailable
29 previewMissing
30)
31
32// pickerPreviewHost is the picker half of the preview: it names the selected
33// row's route and viewport and displays whatever the manager decides.
34type pickerPreviewHost interface {
35 PreviewRequest(domain.Size) (ports.BrokerPreviewRoute, protocol.RemotePreviewRequest, bool)
36 SetPreview(protocol.RemotePreview, previewState)
37 ClearPreview()
38}
39
40type previewCacheKey struct {
41 endpoint string
42 lifecycle domain.SessionLifecycleID
43 tab domain.TabStableID
44 width uint16
45 height uint16
46}
47
48func previewKeyFor(request protocol.RemotePreviewRequest) previewCacheKey {
49 return previewCacheKey{endpoint: request.Target.Endpoint, lifecycle: request.Target.LifecycleID, tab: request.Target.LiveTabID, width: request.Width, height: request.Height}
50}
51
52type previewCacheEntry struct {
53 key previewCacheKey
54 frame protocol.RemotePreview
55}
56
57// previewCache is a tiny LRU of the last OK frame per target; the most
58// recently used entry is last.
59type previewCache struct {
60 entries []previewCacheEntry
61}
62
63func (c *previewCache) get(key previewCacheKey) (protocol.RemotePreview, bool) {
64 for i, entry := range c.entries {
65 if entry.key == key {
66 c.entries = append(append(c.entries[:i:i], c.entries[i+1:]...), entry)
67 return entry.frame, true
68 }
69 }
70 return protocol.RemotePreview{}, false
71}
72
73func (c *previewCache) put(key previewCacheKey, frame protocol.RemotePreview) {
74 for i, entry := range c.entries {
75 if entry.key == key {
76 c.entries = append(c.entries[:i:i], c.entries[i+1:]...)
77 break
78 }
79 }
80 if len(c.entries) >= previewCacheSize {
81 c.entries = append(c.entries[:0:0], c.entries[len(c.entries)-previewCacheSize+1:]...)
82 }
83 c.entries = append(c.entries, previewCacheEntry{key: key, frame: frame})
84}
85
86// previewManager owns the selected row's broker preview subscription. It is
87// driven only by the supervisor's serialized loops; generation never
88// decreases, including across broker reconnections.
89type previewManager struct {
90 clock ports.Clock
91 generation ports.BrokerPreviewGeneration
92 sub ports.BrokerPreviewSubscription
93 request ports.BrokerPreviewRequest
94 // pending is the debounced selection not yet subscribed. Its wake is
95 // the manager's changed channel until it fires or is replaced.
96 pending *previewDebounce
97 cache previewCache
98 // frame is the last OK frame shown for the current selection.
99 frame protocol.RemotePreview
100 hasFrame bool
101}
102
103// previewDebounce is one armed selection. Its goroutine only forwards the
104// clock timer to wake and exits on fire or cancellation.
105type previewDebounce struct {
106 service ports.BrokerNavigator
107 route ports.BrokerPreviewRoute
108 preview protocol.RemotePreviewRequest
109 wake chan struct{}
110 stop chan struct{}
111}
112
113func (d *previewDebounce) cancel() { close(d.stop) }
114
115func (m *previewManager) armDebounce(service ports.BrokerNavigator, route ports.BrokerPreviewRoute, preview protocol.RemotePreviewRequest) {
116 pending := &previewDebounce{service: service, route: route, preview: preview, wake: make(chan struct{}, 1), stop: make(chan struct{})}
117 m.pending = pending
118 if supervisorNil(m.clock) {
119 pending.wake <- struct{}{}
120 return
121 }
122 timer := m.clock.NewTimer(pickerPreviewDebounce)
123 go func() {
124 select {
125 case <-timer.C():
126 pending.wake <- struct{}{}
127 case <-pending.stop:
128 timer.Stop()
129 }
130 }()
131}
132
133// stop ends the current subscription and debounce without touching the
134// display or the cache.
135func (m *previewManager) stop() {
136 if m.pending != nil {
137 m.pending.cancel()
138 m.pending = nil
139 }
140 if m.sub != nil {
141 m.sub.Close()
142 m.sub = nil
143 }
144 m.request = ports.BrokerPreviewRequest{}
145}
146
147func (m *previewManager) close(host pickerHost) {
148 m.stop()
149 m.frame, m.hasFrame = protocol.RemotePreview{}, false
150 if picker, ok := host.(pickerPreviewHost); ok {
151 picker.ClearPreview()
152 }
153}
154
155// selected reports whether route/preview is already subscribed or debounced.
156func (m *previewManager) selected(route ports.BrokerPreviewRoute, preview protocol.RemotePreviewRequest) bool {
157 if m.pending != nil {
158 return m.pending.route == route && m.pending.preview == preview
159 }
160 return m.sub != nil && m.request.Route == route && m.request.Preview == preview
161}
162
163// refresh follows the picker's selected row. A new selection shows its cached
164// frame (stale) or a loading placeholder at once, then subscribes after the
165// cursor rests for the debounce.
166func (m *previewManager) refresh(service ports.BrokerNavigator, host pickerHost, size domain.Size) {
167 picker, ok := host.(pickerPreviewHost)
168 if supervisorNil(service) || !ok {
169 m.close(host)
170 return
171 }
172 route, preview, ok := picker.PreviewRequest(size)
173 if !ok {
174 m.close(host)
175 return
176 }
177 if m.selected(route, preview) {
178 return
179 }
180 m.stop()
181 m.frame, m.hasFrame = m.cache.get(previewKeyFor(preview))
182 if m.hasFrame {
183 picker.SetPreview(m.frame, previewStale)
184 } else {
185 picker.SetPreview(protocol.RemotePreview{}, previewLoading)
186 }
187 m.armDebounce(service, route, preview)
188}
189
190func (m *previewManager) changed() <-chan struct{} {
191 if m.pending != nil {
192 return m.pending.wake
193 }
194 if m.sub == nil {
195 return nil
196 }
197 return m.sub.Changed()
198}
199
200// publish handles one wake of changed and reports whether the display
201// changed.
202func (m *previewManager) publish(host pickerHost) bool {
203 picker, ok := host.(pickerPreviewHost)
204 if !ok {
205 return false
206 }
207 if m.pending != nil {
208 return m.subscribe(picker)
209 }
210 if m.sub == nil {
211 return false
212 }
213 publication := m.sub.Latest()
214 if publication.Validate() != nil ||
215 publication.Epoch != m.request.Epoch || publication.Connection != m.request.Connection ||
216 publication.Generation != m.request.Generation || publication.Target != m.request.Preview.Target ||
217 publication.Width != m.request.Preview.Width || publication.Height != m.request.Preview.Height {
218 return false
219 }
220 if publication.Err == nil && publication.Preview.Status == protocol.RemotePreviewOK {
221 m.frame, m.hasFrame = publication.Preview, true
222 m.cache.put(previewKeyFor(m.request.Preview), publication.Preview)
223 picker.SetPreview(publication.Preview, previewFresh)
224 return true
225 }
226 missing := publication.Err == nil && publication.Preview.Status == protocol.RemotePreviewNoSuchTarget
227 m.showFailure(picker, missing)
228 return true
229}
230
231// subscribe opens the debounced selection's broker subscription.
232func (m *previewManager) subscribe(picker pickerPreviewHost) bool {
233 pending := m.pending
234 m.pending = nil
235 m.generation++
236 request := ports.BrokerPreviewRequest{Epoch: pending.service.Snapshot().Epoch, Connection: pending.service.ConnectionID(), Generation: m.generation, Route: pending.route, Preview: pending.preview}
237 sub, err := pending.service.SubscribePreview(request)
238 if err != nil || supervisorNil(sub) {
239 m.showFailure(picker, false)
240 return true
241 }
242 m.request, m.sub = request, sub
243 return false
244}
245
246// showFailure keeps the last good frame, marked stale, or names why there is
247// none.
248func (m *previewManager) showFailure(picker pickerPreviewHost, missing bool) {
249 switch {
250 case m.hasFrame:
251 picker.SetPreview(m.frame, previewStale)
252 case missing:
253 picker.SetPreview(protocol.RemotePreview{}, previewMissing)
254 default:
255 picker.SetPreview(protocol.RemotePreview{}, previewUnavailable)
256 }
257}
258
259// previewView turns one frame and state into the picker's preview pane. Stale
260// frames are dimmed; frameless states show one short dim label.
261func previewView(preview protocol.RemotePreview, state previewState) pickerusecase.Preview {
262 if state == previewFresh || state == previewStale {
263 rows := preview.FrameRows()
264 if preview.Status != protocol.RemotePreviewOK || rows == nil {
265 return pickerusecase.Preview{}
266 }
267 if state == previewStale {
268 for _, row := range rows {
269 for x := range row {
270 row[x].Style.Attrs |= renderer.AttrDim
271 }
272 }
273 }
274 return pickerusecase.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)}
275 }
276 label := "loading preview…"
277 switch state {
278 case previewUnavailable:
279 label = "preview unavailable"
280 case previewMissing:
281 label = "session gone"
282 }
283 style := renderer.DefaultStyle()
284 style.Attrs |= renderer.AttrDim
285 row := make([]renderer.Cell, 0, len(label))
286 for _, r := range label {
287 row = append(row, renderer.Cell{Rune: r, Style: style})
288 }
289 return pickerusecase.Preview{Rows: [][]renderer.Cell{row}, Width: len(row), Height: 1}
290}