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)
8
9// Picker overlay over a live attachment.
10//
11// The daemon's session-picker command keeps the attachment live: it sends
12// a navigation PickerOffer and the attached worker asks the supervisor for the
13// client picker. The supervisor then composes the same client-owned picker it
14// presents with no attachment over the live one:
15//
16// - The foreground's overlay slot suppresses attachment output (frames are
17// still applied and acknowledged) and diverts every authorized input
18// delivery to the picker, so no picker byte reaches the session and the
19// single terminal reader is never duplicated.
20// - Broker publications, previews, and resizes repaint the picker while the
21// overlay is open, exactly as in the plain picker.
22// - Cancel (Escape, q, Ctrl+C) and a commit to the attached session release
23// the slot; the worker then asks the daemon for an authoritative repaint.
24// The attachment is never reconnected.
25// - A commit to another target detaches the live attachment cleanly and
26// swaps to the committed request through Connecting.
27//
28// Every method runs on the supervisor's run goroutine.
29type attachmentPickerOverlay struct {
30 sup *Supervisor
31 run *attachmentRun
32 service ports.BrokerNavigator
33 request ports.BrokerOpenStreamRequest
34 active bool
35 // swapping is set once a commit elsewhere asked the live attachment to
36 // detach; the overlay keeps the terminal until the attachment settles.
37 swapping bool
38}
39
40func (o *attachmentPickerOverlay) picker() pickerHost { return o.sup.cfg.Picker }
41
42func (o *attachmentPickerOverlay) previewChanged() <-chan struct{} {
43 if !o.active {
44 return nil
45 }
46 return o.sup.preview.changed()
47}
48
49func (o *attachmentPickerOverlay) ops() <-chan struct{} {
50 if !o.active || o.swapping {
51 return nil
52 }
53 return o.picker().OpsReady()
54}
55
56// invalidation is consumed only while the overlay owns the terminal; otherwise
57// the attachment foreground does and the signal stays buffered.
58func (o *attachmentPickerOverlay) invalidation() <-chan struct{} {
59 if !o.active {
60 return nil
61 }
62 return o.sup.presentationInvalidation()
63}
64
65// enter composes the picker over the attachment. It is refused unless the
66// attachment is committed and presented, and while a swap is in progress.
67func (o *attachmentPickerOverlay) enter() {
68 s := o.sup
69 picker := o.picker()
70 if o.active || o.swapping || picker == nil || s.State().Presentation != PresentAttached {
71 return
72 }
73 picker.ApplySnapshot(o.service.Snapshot())
74 // A fresh overlay starts on the attached session's tab.
75 s.setPickerCurrent(o.current())
76 // A decision recorded before the overlay existed never applies to it.
77 picker.TakeOp()
78 picker.SetOwnsInput(true)
79 if !s.attachments.beginNavigationOverlay(picker) {
80 picker.SetOwnsInput(false)
81 return
82 }
83 o.active = true
84 s.invalidatePickerPresentation()
85 s.transition(supervisorEvent{kind: supervisorOverlayOpened})
86 s.refreshPreview(o.service)
87 s.renderCurrent()
88}
89
90// exit returns the terminal to the same live attachment.
91func (o *attachmentPickerOverlay) exit() {
92 if !o.active {
93 return
94 }
95 // Release the slot before the picker stops owning input, so a delivery
96 // racing the cancel reaches the session instead of a picker that no
97 // longer consumes it.
98 o.sup.attachments.endNavigationOverlay()
99 o.drop()
100 o.sup.transition(supervisorEvent{kind: supervisorOverlayClosed})
101}
102
103// drop forgets the overlay without touching the foreground: the attachment
104// settled, so its grant (and overlay slot) is already gone.
105func (o *attachmentPickerOverlay) drop() {
106 if !o.active {
107 return
108 }
109 o.active = false
110 o.sup.preview.close(o.picker())
111 o.picker().SetOwnsInput(false)
112}
113
114func (o *attachmentPickerOverlay) applyPublication() {
115 o.picker().ApplySnapshot(o.service.Snapshot())
116 o.sup.refreshPreview(o.service)
117 o.sup.renderCurrent()
118}
119
120func (o *attachmentPickerOverlay) publishPreview() {
121 if o.sup.preview.publish(o.picker()) {
122 o.sup.renderCurrent()
123 }
124}
125
126func (o *attachmentPickerOverlay) resize() {
127 o.sup.refreshPreview(o.service)
128 o.sup.renderResizeInvalidation()
129}
130
131// takeOp carries out one picker decision over the live attachment.
132func (o *attachmentPickerOverlay) takeOp() {
133 s := o.sup
134 op, key := o.picker().TakeOp()
135 switch {
136 case op.close:
137 // Escape, q, and Ctrl+C all cancel back to the attachment: there is an
138 // attachment to return to, so nothing here ends the process.
139 o.exit()
140 case op.commit && key != "":
141 request, tab, err := s.resolveCommittedStreamRequest(o.service, key)
142 if err != nil {
143 // The picker already shows the bounded refusal; the attachment
144 // stays live under the overlay.
145 s.renderCurrent()
146 return
147 }
148 if o.sameTarget(request) && tab.stopped == nil {
149 // Another tab of the attached session switches in place; the
150 // attachment is never reconnected for it.
151 _, current, _ := s.attachments.committedView()
152 if tab.preferred != "" && tab.preferred != current {
153 s.attachments.requestTabSelection(o.run.token, tab.preferred)
154 }
155 o.exit()
156 return
157 }
158 // The driver action that committed follows the swap and settles on
159 // the destination's first committed publication.
160 if s.cfg.UI != nil && o.run != nil && o.run.fg != nil {
161 s.cfg.UI.followOverlay(o.run.fg.uiGeneration)
162 }
163 s.pendingSwap = &pickerAttachmentTarget{request: request, tab: tab}
164 o.swapping = true
165 s.preview.close(o.picker())
166 s.attachments.requestDetach(o.run.token)
167 case op.kill && key != "":
168 s.startPickerKill(o.service, key)
169 s.renderCurrent()
170 default:
171 s.refreshPreview(o.service)
172 s.renderCurrent()
173 }
174}
175
176// settleAction completes one driver action the overlay consumed. The read's
177// decision was recorded before the worker handed the action over, so any
178// pending decision is carried out first: a commit elsewhere follows the action
179// across the swap, and everything else settles it at this boundary after the
180// picker repainted.
181func (o *attachmentPickerOverlay) settleAction(consumed overlayActionConsumed) {
182 if o.active && !o.swapping {
183 o.takeOp()
184 }
185 if o.sup.cfg.UI != nil {
186 o.sup.cfg.UI.settleOverlay(consumed.generation, consumed.actionID)
187 }
188}
189
190// current names the attachment this overlay is presented over, so the picker
191// opens on its session and tab.
192func (o *attachmentPickerOverlay) current() pickerCurrent {
193 target, tab, known := o.sup.attachments.committedView()
194 if !known {
195 if o.request.Admission != ports.BrokerAdmissionExact {
196 return pickerCurrent{}
197 }
198 target = o.request.Target
199 }
200 if target.LifecycleID == (domain.SessionLifecycleID{}) {
201 return pickerCurrent{}
202 }
203 return pickerCurrent{known: true, local: o.request.Local, endpoint: o.request.Endpoint, lifecycle: target.LifecycleID, tab: tab}
204}
205
206// sameTarget reports whether request names the session this attachment is
207// already showing, in which case committing it is a close.
208func (o *attachmentPickerOverlay) sameTarget(request ports.BrokerOpenStreamRequest) bool {
209 return sameAttachmentTarget(o.request, o.sup.attachments.committedTargetOrZero(), request)
210}
211
212// sameAttachmentTarget compares one committed selection with the live
213// attachment: same daemon authority and same session lifecycle.
214func sameAttachmentTarget(current ports.BrokerOpenStreamRequest, committed protocol.ExactSessionTarget, next ports.BrokerOpenStreamRequest) bool {
215 if next.Admission != ports.BrokerAdmissionExact {
216 return false
217 }
218 if current.Local != next.Local || current.Endpoint != next.Endpoint {
219 return false
220 }
221 if committed != (protocol.ExactSessionTarget{}) {
222 return committed.LifecycleID == next.Target.LifecycleID
223 }
224 return current.Admission == ports.BrokerAdmissionExact && current.Target.LifecycleID == next.Target.LifecycleID
225}