1package client
2
3import (
4 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/ports"
6 "github.com/bnema/vev/internal/protocol"
7 "github.com/bnema/vev/internal/protocol/catalogue"
8)
9
10// Picker overlay over a live attachment.
11//
12// The daemon's session-picker command keeps the attachment live: it sends
13// a navigation PickerOffer and the attached worker asks the supervisor for the
14// client picker. The supervisor then composes the same client-owned picker it
15// presents with no attachment over the live one:
16//
17// - The foreground's overlay slot suppresses attachment output (frames are
18// still applied and acknowledged) and diverts every authorized input
19// delivery to the picker, so no picker byte reaches the session and the
20// single terminal reader is never duplicated.
21// - Broker publications, previews, and resizes repaint the picker while the
22// overlay is open, exactly as in the plain picker.
23// - Cancel (Escape, q, Ctrl+C) and a commit to the attached session release
24// the slot; the worker then asks the daemon for an authoritative repaint.
25// The attachment is never reconnected.
26// - A commit to another target detaches the live attachment cleanly and
27// swaps to the committed request through Connecting.
28//
29// Every method runs on the supervisor's run goroutine.
30type attachmentPickerOverlay struct {
31 sup *Supervisor
32 run *attachmentRun
33 service ports.BrokerNavigator
34 request ports.BrokerOpenStreamRequest
35 active bool
36 // swapping is set once a commit elsewhere asked the live attachment to
37 // detach; the overlay keeps the terminal until the attachment settles.
38 swapping bool
39}
40
41func (o *attachmentPickerOverlay) picker() pickerHost { return o.sup.cfg.Picker }
42
43func (o *attachmentPickerOverlay) previewChanged() <-chan struct{} {
44 if !o.active {
45 return nil
46 }
47 return o.sup.preview.changed()
48}
49
50func (o *attachmentPickerOverlay) ops() <-chan struct{} {
51 if !o.active || o.swapping {
52 return nil
53 }
54 return o.picker().OpsReady()
55}
56
57// invalidation is consumed only while the overlay owns the terminal; otherwise
58// the attachment foreground does and the signal stays buffered.
59func (o *attachmentPickerOverlay) invalidation() <-chan struct{} {
60 if !o.active {
61 return nil
62 }
63 return o.sup.presentationInvalidation()
64}
65
66// enter composes the picker over the attachment. It is refused unless the
67// attachment is committed and presented, and while a swap is in progress.
68func (o *attachmentPickerOverlay) enter() {
69 s := o.sup
70 picker := o.picker()
71 if o.active || o.swapping || picker == nil || s.State().Presentation != PresentAttached {
72 return
73 }
74 picker.ApplySnapshot(o.service.Snapshot())
75 // A fresh overlay starts on the attached session's tab.
76 s.setPickerCurrent(o.current())
77 // A decision recorded before the overlay existed never applies to it.
78 picker.TakeOp()
79 picker.SetOwnsInput(true)
80 if !s.attachments.beginNavigationOverlay(picker) {
81 picker.SetOwnsInput(false)
82 return
83 }
84 o.active = true
85 s.invalidatePickerPresentation()
86 s.transition(supervisorEvent{kind: supervisorOverlayOpened})
87 s.refreshPreview(o.service)
88 s.renderCurrent()
89}
90
91// exit returns the terminal to the same live attachment.
92func (o *attachmentPickerOverlay) exit() { o.release(true) }
93
94// release returns the terminal to the live attachment. repaint asks the
95// daemon to repaint the attached session over the picker box; an in-place
96// switch skips it because the destination's first paint is a full frame.
97func (o *attachmentPickerOverlay) release(repaint bool) {
98 if !o.active {
99 return
100 }
101 // Release the slot before the picker stops owning input, so a delivery
102 // racing the cancel reaches the session instead of a picker that no
103 // longer consumes it.
104 o.sup.attachments.endNavigationOverlay(repaint)
105 o.drop()
106 o.sup.transition(supervisorEvent{kind: supervisorOverlayClosed})
107}
108
109// drop forgets the overlay without touching the foreground: the attachment
110// settled, so its grant (and overlay slot) is already gone.
111func (o *attachmentPickerOverlay) drop() {
112 if !o.active {
113 return
114 }
115 o.active = false
116 o.sup.preview.close(o.picker())
117 o.picker().SetOwnsInput(false)
118}
119
120func (o *attachmentPickerOverlay) applyPublication() {
121 o.picker().ApplySnapshot(o.service.Snapshot())
122 o.sup.refreshPreview(o.service)
123 o.sup.renderCurrent()
124}
125
126func (o *attachmentPickerOverlay) publishPreview() {
127 if o.sup.preview.publish(o.picker()) {
128 o.sup.renderCurrent()
129 }
130}
131
132func (o *attachmentPickerOverlay) resize() {
133 o.sup.refreshPreview(o.service)
134 o.sup.renderResizeInvalidation()
135}
136
137// takeOp carries out one picker decision over the live attachment.
138func (o *attachmentPickerOverlay) takeOp() {
139 s := o.sup
140 op, key := o.picker().TakeOp()
141 switch {
142 case op.close:
143 // Escape, q, and Ctrl+C all cancel back to the attachment: there is an
144 // attachment to return to, so nothing here ends the process.
145 o.exit()
146 case op.commit && key != "":
147 request, tab, err := s.resolveCommittedStreamRequest(o.service, key)
148 if err != nil {
149 // The picker already shows the bounded refusal; the attachment
150 // stays live under the overlay.
151 s.renderCurrent()
152 return
153 }
154 // While an in-place switch is in flight the attachment is leaving
155 // its committed session, so choosing that session again is a new
156 // navigation, not a close.
157 if s.pendingInPlace != nil && s.pendingInPlace.target.request.Target == request.Target && tab.stopped == nil {
158 // The attachment is already switching there.
159 o.release(false)
160 return
161 }
162 if s.pendingInPlace == nil && o.sameTarget(request) && tab.stopped == nil {
163 // Another tab of the attached session switches in place; the
164 // attachment is never reconnected for it.
165 _, current, _ := s.attachments.committedView()
166 if tab.preferred != "" && tab.preferred != current {
167 s.attachments.requestTabSelection(o.run.token, tab.preferred)
168 }
169 o.exit()
170 return
171 }
172 // The driver action that committed follows the swap and settles on
173 // the destination's first committed publication.
174 if s.cfg.UI != nil && o.run != nil && o.run.fg != nil {
175 s.cfg.UI.followOverlay(o.run.fg.uiGeneration)
176 }
177 target := pickerAttachmentTarget{request: request, tab: tab}
178 if o.tryInPlace(target) {
179 o.release(false)
180 return
181 }
182 s.pendingSwap = &target
183 o.swapping = true
184 s.preview.close(o.picker())
185 s.attachments.requestDetach(o.run.token)
186 case op.kill && key != "":
187 s.startPickerKill(o.service, key)
188 s.renderCurrent()
189 default:
190 s.refreshPreview(o.service)
191 s.renderCurrent()
192 }
193}
194
195// settleAction completes one driver action the overlay consumed. The read's
196// decision was recorded before the worker handed the action over, so any
197// pending decision is carried out first: a commit elsewhere follows the action
198// across the swap, and everything else settles it at this boundary after the
199// picker repainted.
200func (o *attachmentPickerOverlay) settleAction(consumed overlayActionConsumed) {
201 if o.active && !o.swapping {
202 o.takeOp()
203 }
204 if o.sup.cfg.UI != nil {
205 o.sup.cfg.UI.settleOverlay(consumed.generation, consumed.actionID)
206 }
207}
208
209// current names the attachment this overlay is presented over, so the picker
210// opens on its session and tab.
211func (o *attachmentPickerOverlay) current() pickerCurrent {
212 target, tab, known := o.sup.attachments.committedView()
213 if !known {
214 if o.request.Admission != ports.BrokerAdmissionExact {
215 return pickerCurrent{}
216 }
217 target = o.request.Target
218 }
219 if target.LifecycleID == (domain.SessionLifecycleID{}) {
220 return pickerCurrent{}
221 }
222 return pickerCurrent{known: true, local: o.request.Local, endpoint: o.request.Endpoint, lifecycle: target.LifecycleID, tab: tab}
223}
224
225// sameTarget reports whether request names the session this attachment is
226// already showing, in which case committing it is a close.
227func (o *attachmentPickerOverlay) sameTarget(request ports.BrokerOpenStreamRequest) bool {
228 return sameAttachmentTarget(o.request, o.sup.attachments.committedTargetOrZero(), request)
229}
230
231// tryInPlace asks the live attachment to move to target on its own daemon,
232// without a new stream or Hello. It applies only to a live session on the
233// serving daemon (the local daemon, or the same remote registration) and
234// reports false otherwise, leaving the caller to swap. A refusal later falls
235// back to the swap (see inPlaceSettled).
236func (o *attachmentPickerOverlay) tryInPlace(target pickerAttachmentTarget) bool {
237 s := o.sup
238 request := target.request
239 if request.Admission != ports.BrokerAdmissionExact || request.Local != o.request.Local || target.tab.stopped != nil {
240 return false
241 }
242 if !request.Local && !request.Registration.Equal(o.request.Registration) {
243 return false
244 }
245 if !catalogueSessionLive(o.service.Snapshot(), request) {
246 // A stopped session needs a restore, which only a fresh attach does.
247 return false
248 }
249 s.inPlaceSeq++
250 if !s.attachments.requestInPlace(o.run.token, inPlaceSwitch{seq: s.inPlaceSeq, target: request.Target, tab: target.tab.preferred}) {
251 return false
252 }
253 s.pendingInPlace = &pendingInPlace{seq: s.inPlaceSeq, target: target}
254 return true
255}
256
257// catalogueSessionLive reports whether the broker catalogue shows the exact
258// session request names as running on its daemon.
259func catalogueSessionLive(snapshot ports.BrokerSnapshot, request ports.BrokerOpenStreamRequest) bool {
260 authority := pickerAuthorityInSnapshot(snapshot, pickerSelectionRef{local: request.Local, endpoint: request.Endpoint})
261 if !authority.found {
262 return false
263 }
264 session, ok := pickerFindSession(authority.observation.Sessions, request.Target.LifecycleID)
265 return ok && session.Name == request.Target.SessionName && session.State == catalogue.RemoteCatalogSessionUp
266}
267
268// inPlaceSettled applies the worker's outcome for the current in-place
269// switch; an outcome for an older choice is ignored. A refusal leaves the
270// source attached, so the same choice falls back to a fresh attachment. A
271// superseded switch was replaced by a newer navigation and is dropped.
272func (o *attachmentPickerOverlay) inPlaceSettled(result inPlaceResult) {
273 s := o.sup
274 pending := s.pendingInPlace
275 if pending == nil || pending.seq != result.seq {
276 return
277 }
278 s.pendingInPlace = nil
279 if result.outcome != inPlaceRefused || o.swapping {
280 return
281 }
282 if o.active {
283 o.exit()
284 }
285 s.pendingSwap = &pending.target
286 o.swapping = true
287 s.attachments.requestDetach(o.run.token)
288}
289
290// sameAttachmentTarget compares one committed selection with the live
291// attachment: same daemon authority and same session lifecycle.
292func sameAttachmentTarget(current ports.BrokerOpenStreamRequest, committed protocol.ExactSessionTarget, next ports.BrokerOpenStreamRequest) bool {
293 if next.Admission != ports.BrokerAdmissionExact {
294 return false
295 }
296 if current.Local != next.Local || current.Endpoint != next.Endpoint {
297 return false
298 }
299 if committed != (protocol.ExactSessionTarget{}) {
300 return committed.LifecycleID == next.Target.LifecycleID
301 }
302 return current.Admission == ports.BrokerAdmissionExact && current.Target.LifecycleID == next.Target.LifecycleID
303}