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)
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}

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