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

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