supervisor_overlay.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close