move_picker_overlay.go RawHistoryFolder

1package client
2
3import (
4	"context"
5	"fmt"
6	"time"
7
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/ports"
10	"github.com/bnema/vev/internal/protocol"
11	"github.com/bnema/vev/internal/usecase/keys/kittykey"
12	pickerusecase "github.com/bnema/vev/internal/usecase/picker"
13)
14
15// Move-destination picker over a live attachment.
16//
17// The serving daemon still owns move semantics: it captures the source when
18// MFP/MAT runs, publishes the destination rows in a PickerSnapshot, resolves
19// the opaque key of a typed PickerSelection, and confirms the end of the
20// interaction with PickerClosed. The attachment worker only presents it: it
21// composes the box over the attachment through the foreground's overlay slot,
22// decodes the input the overlay owns, and answers with the existing typed
23// PickerSelection/PickerClose replies. Zero picker bytes reach the session.
24//
25// Every method runs on the worker goroutine, which is the overlay's only
26// owner, so it holds no lock of its own.
27
28// movePickerInputGeneration is the fixed input generation of the worker-owned
29// move overlay; the interaction ID alone names the namespace.
30const movePickerInputGeneration = uint64(1)
31
32type movePickerOverlay struct {
33	namespace pickerInteraction
34	intent    protocol.PickerIntent
35	loop      *pickerLoop
36	consumer  pickerConsumer
37	renderer  *pickerRenderer
38	// presenting reports that the foreground overlay slot is held for this
39	// interaction.
40	presenting bool
41}
42
43func newMovePickerOverlay(trueColor bool) *movePickerOverlay {
44	return &movePickerOverlay{renderer: newPickerRenderer(pickerColorProfile(trueColor))}
45}
46
47// offer opens the namespace a move offer names. A superseding offer retires
48// the previous interaction, whose rows can no longer be committed; the caller
49// releases the overlay when superseded reports true.
50func (m *movePickerOverlay) offer(offer protocol.PickerOffer) (superseded bool) {
51	superseded = m.presenting && m.namespace.interaction != offer.InteractionID
52	if superseded {
53		m.retire()
54	}
55	m.namespace.setOpen(true, offer.InteractionID)
56	m.namespace.setIntent(offer.InteractionID, offer.Intent)
57	m.intent = offer.Intent
58	m.loop = nil
59	return superseded
60}
61
62// admit folds one daemon publication into the model. It reports whether the
63// snapshot belongs to the open interaction and is newer than what is shown.
64func (m *movePickerOverlay) admit(snapshot protocol.PickerSnapshot) bool {
65	if !m.namespace.open || m.intent == protocol.PickerIntentNavigation || m.intent == 0 {
66		return false
67	}
68	if !m.namespace.admitSnapshot(snapshot, true) {
69		return false
70	}
71	if m.loop == nil {
72		// Moves always open most-recent first so the likely destination is on
73		// top; the session picker's remembered sort does not apply here.
74		m.loop = pickerLoopFromSnapshot(snapshot, m.intent, pickerusecase.SortRecent)
75		m.consumer.setOwned(snapshot.InteractionID, movePickerInputGeneration)
76		return true
77	}
78	m.loop.replaceLines(snapshot)
79	return true
80}
81
82// interaction names the open namespace, or zero.
83func (m *movePickerOverlay) interaction() uint64 {
84	if !m.namespace.open {
85		return 0
86	}
87	return m.namespace.interaction
88}
89
90// closedBy reports whether a daemon close retires the open interaction.
91func (m *movePickerOverlay) closedBy(interaction uint64) bool {
92	return m.namespace.open && m.namespace.interaction == interaction
93}
94
95// retire ends the interaction locally. A late snapshot for it is refused.
96func (m *movePickerOverlay) retire() {
97	if m.namespace.open {
98		m.namespace.setOpen(false, m.namespace.interaction)
99	}
100	m.loop = nil
101	m.presenting = false
102	m.consumer.clear()
103}
104
105// input decodes one owned delivery and applies it to the model.
106func (m *movePickerOverlay) input(data []byte, actionID uint64) (pickerOp, bool) {
107	outcome, consumed := m.consumer.consume(terminalReadResult{data: data, actionID: actionID})
108	return m.apply(outcome, consumed)
109}
110
111// flush resolves a withheld lone escape once its window elapsed.
112func (m *movePickerOverlay) flush() (pickerOp, bool) {
113	outcome, consumed := m.consumer.flushPending()
114	return m.apply(outcome, consumed)
115}
116
117func (m *movePickerOverlay) apply(outcome pickerConsumeOutcome, consumed bool) (pickerOp, bool) {
118	if !consumed || m.loop == nil || !outcome.acceptOutcome(m.namespace.interaction, movePickerInputGeneration) {
119		return pickerOp{}, false
120	}
121	return m.loop.apply(outcome.events)
122}
123
124// selection builds the typed move for the cursor row, refusing rows the daemon
125// did not authorise for a move.
126func (m *movePickerOverlay) selection(causeActionID uint64) (protocol.PickerSelection, bool) {
127	action, ok := m.loop.selectedAction()
128	if !ok || action != protocol.PickerActionMove {
129		return protocol.PickerSelection{}, false
130	}
131	return m.loop.selection(action, causeActionID)
132}
133
134// render composes the box for one terminal size.
135func (m *movePickerOverlay) render(size domain.Size) []byte {
136	if m.loop == nil {
137		return nil
138	}
139	return m.renderer.render(m.loop, size, emptyPickerPreview())
140}
141
142// attachmentMovePicker binds the move overlay and the navigation request to
143// one attached worker loop: it owns the foreground overlay slot while a move
144// interaction is presented and answers the daemon on the worker's stream.
145type attachmentMovePicker struct {
146	worker  *sessionAttachmentWorker
147	fg      AttachmentForeground
148	overlay attachmentOverlayForeground
149	stream  ports.BrokerLogicalConnection
150	size    domain.Size
151	move    *movePickerOverlay
152	// escapeTimer bounds a withheld lone escape while the move overlay owns
153	// input; nil while nothing is withheld.
154	escapeTimer ports.Timer
155}
156
157// offer handles one daemon PickerOffer. A navigation offer asks the
158// supervisor for the client's session picker over this attachment; a move
159// offer opens the namespace whose snapshot the overlay will present.
160func (p *attachmentMovePicker) offer(ctx context.Context, offer protocol.PickerOffer) error {
161	if p.overlay == nil {
162		// No overlay seam: never leave the daemon swallowing input for an
163		// interaction nothing presents.
164		return p.decline(ctx, offer)
165	}
166	if offer.Intent == protocol.PickerIntentNavigation {
167		if p.move.presenting {
168			return nil
169		}
170		p.overlay.requestNavigationPicker()
171		return nil
172	}
173	if p.overlay.overlayKind() == attachmentOverlayNavigation {
174		// The session picker owns the terminal; a move picker cannot be
175		// presented under it, so release the daemon's interaction at once.
176		return p.decline(ctx, offer)
177	}
178	if p.move.offer(offer) {
179		p.stopEscape()
180		p.overlay.clearOverlay(attachmentOverlayMove)
181	}
182	return nil
183}
184
185// decline closes a move interaction the client will not present.
186func (p *attachmentMovePicker) decline(ctx context.Context, offer protocol.PickerOffer) error {
187	if offer.Intent == protocol.PickerIntentNavigation {
188		return nil
189	}
190	return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: offer.InteractionID})
191}
192
193// snapshot presents one daemon publication. The first admitted snapshot takes
194// the foreground overlay slot; a refused slot declines the interaction.
195func (p *attachmentMovePicker) snapshot(ctx context.Context, state outputApplyState, snapshot protocol.PickerSnapshot) error {
196	if p.overlay == nil || !p.move.admit(snapshot) {
197		return nil
198	}
199	if !p.move.presenting {
200		if !p.overlay.setOverlay(attachmentOverlayMove, nil) {
201			interaction := p.move.interaction()
202			p.move.retire()
203			return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: interaction})
204		}
205		p.move.presenting = true
206		p.move.renderer.invalidate()
207	}
208	return p.paint(state)
209}
210
211// closed retires the interaction the daemon closed and returns the terminal to
212// the attachment.
213func (p *attachmentMovePicker) closed(interaction uint64) {
214	if !p.move.closedBy(interaction) {
215		return
216	}
217	p.release()
218}
219
220func (p *attachmentMovePicker) release() {
221	presenting := p.move.presenting
222	p.move.retire()
223	p.stopEscape()
224	if presenting && p.overlay != nil {
225		p.overlay.clearOverlay(attachmentOverlayMove)
226	}
227}
228
229// consumeInput offers one authorized delivery to the overlay that owns input.
230// It reports consumed when the bytes must not reach the session, and
231// supervised when the supervisor-owned navigation picker consumed them: its
232// action is then settled by the supervisor, never fenced through the daemon.
233func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (consumed, supervised bool, err error) {
234	// Overlays decode legacy keys; the session path keeps kitty sequences.
235	input.Data = kittykey.Translate(input.Data, 0)
236	if p.move.presenting {
237		p.stopEscape()
238		op, changed := p.move.input(input.Data, input.actionID)
239		if err := p.applyOp(ctx, state, op, changed); err != nil {
240			return true, false, err
241		}
242		p.armEscape()
243		return true, false, nil
244	}
245	if p.overlay != nil && p.overlay.divertInput(input.Data, input.actionID) {
246		return true, true, nil
247	}
248	return false, false, nil
249}
250
251// applyOp carries out one decoded decision: close sends the typed close and
252// releases the terminal, commit sends the typed move, and a changed model
253// repaints the box.
254func (p *attachmentMovePicker) applyOp(ctx context.Context, state outputApplyState, op pickerOp, changed bool) error {
255	if !p.move.presenting {
256		return nil
257	}
258	if op.close {
259		interaction := p.move.interaction()
260		p.release()
261		return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: interaction})
262	}
263	if op.commit {
264		if selection, ok := p.move.selection(0); ok {
265			if err := p.worker.send(ctx, p.fg, p.stream, selection); err != nil {
266				return err
267			}
268		}
269	}
270	if changed {
271		return p.paint(state)
272	}
273	return nil
274}
275
276// resize repaints the presented box at the new terminal size.
277func (p *attachmentMovePicker) resize(_ context.Context, state outputApplyState, size domain.Size) error {
278	p.size = size
279	if !p.move.presenting {
280		return nil
281	}
282	return p.paint(state)
283}
284
285func (p *attachmentMovePicker) paint(state outputApplyState) error {
286	frame := p.move.render(p.size)
287	if len(frame) == 0 {
288		return nil
289	}
290	uiContext := state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(p.fg, p.fg.Token())}, ports.UIStatusAttached)
291	if err := p.overlay.overlayOutput(uiContext, frame); err != nil {
292		return fmt.Errorf("vev: publishing move picker: %w", err)
293	}
294	return nil
295}
296
297// escape is the armed lone-escape window, or nil.
298func (p *attachmentMovePicker) escape() <-chan time.Time {
299	if p.escapeTimer == nil {
300		return nil
301	}
302	return p.escapeTimer.C()
303}
304
305func (p *attachmentMovePicker) armEscape() {
306	if p.escapeTimer != nil || !p.move.presenting || !p.move.consumer.hasPending() {
307		return
308	}
309	timer := p.worker.cfg.Clock.NewTimer(pickerEscapeDeadline)
310	if supervisorNil(timer) {
311		return
312	}
313	p.escapeTimer = timer
314}
315
316// escapeFired forgets the timer whose window just elapsed.
317func (p *attachmentMovePicker) escapeFired() { p.escapeTimer = nil }
318
319func (p *attachmentMovePicker) stopEscape() {
320	if p.escapeTimer == nil {
321		return
322	}
323	stopSupervisorTimer(p.escapeTimer)
324	p.escapeTimer = nil
325}

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