picker_interaction.go RawHistoryFolder

1package daemon
2
3import (
4	"errors"
5	"fmt"
6	"slices"
7	"time"
8
9	"github.com/bnema/vev/internal/domain"
10	"github.com/bnema/vev/internal/protocol"
11	"github.com/bnema/vev/internal/usecase/picker"
12)
13
14// This file retains the serving-daemon move-destination interaction. It must
15// never admit a navigation interaction: session-picker only offers the client
16// its own picker over the live attachment (offerClientNavigationPicker), and
17// the client supervisor owns all navigation and terminal input while it is
18// open.
19
20// servingPickerSourceID names the source the serving daemon itself owns.
21const servingPickerSourceID = protocol.PickerServingSourceID
22
23var errClientOwnedNavigation = errors.New("session navigation is client-owned")
24
25// pickerAcquisitionGrace bounds how long raw input from the client may still
26// arrive after a move-picker offer and be dropped as bytes that were already
27// in flight before the client saw the offer. A client that presents the picker
28// consumes every key locally, so raw input after this grace proves the client
29// never took the interaction.
30const pickerAcquisitionGrace = 2 * time.Second
31
32// pickerSwallowsInput reports whether raw input must be dropped because an
33// open client-owned picker interaction owns it. It is the fail-safe that keeps
34// input from being swallowed without a live offer: once the acquisition grace
35// elapsed, raw input releases the interaction (confirming the close to the
36// client when an effect is available and forcing an authoritative repaint) and
37// the input is routed normally. Callers must not hold pickerMu.
38func (d *Daemon) pickerSwallowsInput(ac *attachedClient, effect *attachmentEffect) bool {
39	if ac == nil || ac.overlays == nil {
40		return false
41	}
42	ac.overlays.pickerMu.Lock()
43	open := ac.overlays.pickerOpen
44	interaction := ac.overlays.pickerInteraction
45	openedAt := ac.overlays.pickerOpenedAt
46	ac.overlays.pickerMu.Unlock()
47	if !open {
48		return false
49	}
50	if d.daemonNow().Sub(openedAt) < pickerAcquisitionGrace {
51		return true
52	}
53	if d.log != nil {
54		d.log.Warn("releasing picker interaction the client never acquired", "interaction", interaction)
55	}
56	if d.closePickerForAttachment(ac, effect, interaction) {
57		if sess := ac.currentAttachmentSession(); sess != nil {
58			d.invalidateRender(sess, ac, true, "picker_interaction.go:unacquired-release")
59		}
60	}
61	return false
62}
63
64// offerClientNavigationPicker asks the client to open its own session picker
65// over this live attachment. The daemon opens no interaction: navigation rows
66// come from the client's broker catalogue, the client picker consumes the
67// input it owns locally, and the attachment stays attached so cancelling the
68// picker returns to it without reconnecting.
69func (d *Daemon) offerClientNavigationPicker(ac *attachedClient, effect *attachmentEffect) error {
70	if ac == nil || ac.overlays == nil || effect == nil || !effect.current() {
71		return errAttachmentTransition
72	}
73	ac.overlays.pickerMu.Lock()
74	ac.overlays.pickerNavigationOffers++
75	if ac.overlays.pickerNavigationOffers == 0 {
76		ac.overlays.pickerNavigationOffers = 1
77	}
78	offerID := ac.overlays.pickerNavigationOffers
79	ac.overlays.pickerMu.Unlock()
80	offer := protocol.PickerOffer{InteractionID: offerID, Intent: protocol.PickerIntentNavigation, Title: picker.SortRecent.Title()}
81	if err := protocol.ValidatePickerOffer(offer); err != nil {
82		return err
83	}
84	return effect.sendControl(offer)
85}
86
87// pickerDuplicateRows reports the first repeated row key with a readable
88// identity for both rows. A refused snapshot otherwise says only that a key
89// repeated, which is not enough to find the two rows that collided.
90func pickerDuplicateRows(lines []protocol.PickerLine) (first, second string, duplicated bool) {
91	seen := make(map[string]string, len(lines))
92	for _, line := range lines {
93		if line.Key == "" {
94			continue
95		}
96		identity := fmt.Sprintf("%s %q (%s)", line.Label, line.Detail, line.StatusDetail)
97		if previous, ok := seen[line.Key]; ok {
98			return previous, identity, true
99		}
100		seen[line.Key] = identity
101	}
102	return "", "", false
103}
104
105// pickerClientKey derives a stable opaque key for an unchanged navigation
106// target (lifecycle, name). Presentation-only updates keep the key; a changed
107// or removed target retires it and a reappearing target earns a new key
108// through its new lifecycle. Same role as navigationInventoryEntryKey.
109func pickerClientKey(lifecycle domain.IncarnationID, name string) string {
110	return navigationInventoryEntryKey(lifecycle, name)
111}
112
113// retirePicker closes the picker interaction of an attachment that is going
114// away. It never sends: the transport is already failing or parked, and the
115// client's own lease retires on its generation change.
116func (d *Daemon) retirePicker(ac *attachedClient) {
117	if ac == nil || ac.overlays == nil {
118		return
119	}
120	ac.overlays.pickerMu.Lock()
121	open := ac.overlays.pickerOpen
122	interaction := ac.overlays.pickerInteraction
123	ac.overlays.pickerMu.Unlock()
124	if !open {
125		return
126	}
127	d.closePickerForAttachment(ac, nil, interaction)
128}
129
130// pickerState reports the open interaction under the picker mutex.
131func (ac *attachedClient) pickerState() (open bool, interaction uint64, intent protocol.PickerIntent, source moveSourceLocator, requestID uint64) {
132	if ac == nil || ac.overlays == nil {
133		return false, 0, 0, moveSourceLocator{}, 0
134	}
135	ac.overlays.pickerMu.Lock()
136	defer ac.overlays.pickerMu.Unlock()
137	return ac.overlays.pickerOpen, ac.overlays.pickerInteraction, ac.overlays.pickerIntent, ac.overlays.pickerMoveSource, ac.overlays.pickerRequestID
138}
139
140// openPickerForAttachment admits one picker interaction on a guarded serving
141// connection, sends the offer that names the intent and the acquisition
142// barrier, and publishes the first serving snapshot. A failed publication
143// fails closed: the interaction is retired again so no namespace survives
144// without its client-side acquisition.
145func (d *Daemon) openPickerForAttachment(ac *attachedClient, effect *attachmentEffect, intent protocol.PickerIntent, source moveSourceLocator, requestID uint64) error {
146	if ac == nil || ac.overlays == nil || effect == nil || !effect.current() {
147		return errAttachmentTransition
148	}
149	if intent != protocol.PickerIntentMoveTab && intent != protocol.PickerIntentMovePane {
150		return errClientOwnedNavigation
151	}
152	sess := effect.sess
153	if intent != protocol.PickerIntentNavigation {
154		if sess == nil || !sess.capabilities().yieldsMoves() {
155			return errSessionCannotYieldMoves
156		}
157		if source.Attachment != nil && source.AttachmentCapability.ac == nil {
158			source.AttachmentCapability = sess.captureAttachmentCapability(source.Attachment, source.Attachment.transport())
159		}
160	}
161	ac.overlays.pickerMu.Lock()
162	interaction := ac.overlays.pickerInteraction + 1
163	if interaction == 0 {
164		interaction = 1
165	}
166	ac.overlays.pickerOpen = true
167	ac.overlays.pickerInteraction = interaction
168	ac.overlays.pickerIntent = intent
169	ac.overlays.pickerMoveSource = source
170	ac.overlays.pickerRequestID = requestID
171	ac.overlays.pickerOpenedAt = d.daemonNow()
172	ac.overlays.pickerRevisions = make(map[string]uint64)
173	ac.overlays.pickerKeys = nil
174	ac.overlays.pickerSourcePublished = false
175	ac.overlays.pickerRecent = protocol.PickerProjection{}
176	ac.overlays.pickerGrouped = protocol.PickerProjection{}
177	ac.overlays.pickerMu.Unlock()
178
179	barrierEpoch, barrierState, sizeEpoch := pickerClientBarrier(ac)
180	offer := protocol.PickerOffer{
181		InteractionID: interaction, RequestID: requestID, Intent: intent,
182		MoveSourceKey: pickerMoveSourceKey(source), Title: picker.SortRecent.Title(),
183		BarrierEpoch: barrierEpoch, BarrierState: barrierState, SizeEpoch: sizeEpoch,
184	}
185	if err := protocol.ValidatePickerOffer(offer); err != nil {
186		d.log.Error("picker offer rejected", "err", err, "interaction", interaction, "intent", intent)
187		d.closePickerForAttachment(ac, effect, interaction)
188		return err
189	}
190	if err := effect.sendControl(offer); err != nil {
191		d.closePickerForAttachment(ac, effect, interaction)
192		return err
193	}
194	if err := d.publishPickerSourceForAttachment(ac, effect, interaction); err != nil {
195		d.closePickerForAttachment(ac, effect, interaction)
196		return err
197	}
198	if sess := ac.currentAttachmentSession(); sess != nil {
199		d.invalidateRender(sess, ac, true, "picker.go:open")
200	}
201	return nil
202}
203
204// movePickerPrecheck reports the move-picker refusals that are known before
205// the picker opens, so a deferred open still fails its command synchronously.
206// openPickerForAttachment re-checks both when it actually opens.
207func (d *Daemon) movePickerPrecheck(sess *session, ac *attachedClient, intent protocol.PickerIntent, source moveSourceLocator) error {
208	if sess == nil || !sess.capabilities().yieldsMoves() {
209		return errSessionCannotYieldMoves
210	}
211	_, groupedViews, current := d.pickerViewProjections(sess, ac)
212	filter := pickerSourceFilter{Session: source.Session.ID, Incarnation: source.Session.Incarnation, TabID: source.TabID}
213	if !pickerLineSetHasMove(pickerLineSetFor(groupedViews, intent, filter, current)) {
214		return errNoMoveDestination
215	}
216	return nil
217}
218
219// pickerMoveSourceKey names the captured move source in the offer. It is
220// opaque and never a route: only the daemon resolves it, and only for the
221// interaction that captured it.
222func pickerMoveSourceKey(source moveSourceLocator) string {
223	if source.Session.ID == "" || source.TabID == "" {
224		return ""
225	}
226	return pickerClientKey(source.Session.Incarnation, string(source.Session.ID)+"#"+string(source.TabID))
227}
228
229// publishPickerSourceForAttachment builds the serving projection pair and
230// publishes it only when the authoritative presentation changed. Recent is the
231// flat recency order the default client sort consumes; Grouped is the sectioned
232// order the grouped sort consumes. They always carry the same destination keys
233// and actions, but their lines differ, so each client sort runs on the input
234// its mode expects instead of re-deriving one projection from the other. A
235// refresh whose projections equal the last published pair is not
236// authority-relevant — an attention pulse whose rows already reached the client
237// is the common case — and must neither bump the revision nor send a snapshot,
238// or a revision the client never observed would stale its next selection. The
239// revision and recorded projections commit only after the snapshot reached the
240// client, so a rejected validation or failed send leaves the client's last
241// observed authority intact and an identical retry republishes instead of
242// being suppressed.
243// Callers must not hold pickerMu.
244func (d *Daemon) publishPickerSourceForAttachment(ac *attachedClient, effect *attachmentEffect, interaction uint64) error {
245	if ac == nil || ac.overlays == nil || effect == nil {
246		return errAttachmentTransition
247	}
248	open, currentInteraction, intent, source, _ := ac.pickerState()
249	if !open || currentInteraction != interaction {
250		return nil
251	}
252	sess := effect.sess
253	if sess == nil {
254		sess = ac.currentAttachmentSession()
255	}
256	if sess == nil {
257		return errAttachmentTransition
258	}
259	recentViews, groupedViews, current := d.pickerViewProjections(sess, ac)
260	filter := pickerSourceFilter{
261		Session: source.Session.ID, Incarnation: source.Session.Incarnation, TabID: source.TabID,
262	}
263	groupedSet := pickerLineSetFor(groupedViews, intent, filter, current)
264	if intent != protocol.PickerIntentNavigation && !pickerLineSetHasMove(groupedSet) {
265		return errNoMoveDestination
266	}
267	recent := setProjection(pickerLineSetFor(recentViews, intent, filter, current))
268	grouped := setProjection(groupedSet)
269	ac.overlays.pickerPublishMu.Lock()
270	defer ac.overlays.pickerPublishMu.Unlock()
271	ac.overlays.pickerMu.Lock()
272	if !ac.overlays.pickerOpen || ac.overlays.pickerInteraction != interaction {
273		ac.overlays.pickerMu.Unlock()
274		return nil
275	}
276	if ac.overlays.pickerSourcePublished &&
277		pickerProjectionEqual(ac.overlays.pickerRecent, recent) &&
278		pickerProjectionEqual(ac.overlays.pickerGrouped, grouped) {
279		ac.overlays.pickerMu.Unlock()
280		return nil
281	}
282	// Reserve the next revision without committing it: a revision the client
283	// never observed must not become the recorded authority. pickerPublishMu
284	// keeps this reservation exclusive, so the send below cannot be reordered
285	// behind another publisher's.
286	revision := ac.overlays.pickerRevisions[servingPickerSourceID] + 1
287	ac.overlays.pickerMu.Unlock()
288	snapshot := protocol.PickerSnapshot{
289		InteractionID: interaction, SourceID: servingPickerSourceID, SourceRevision: revision,
290		Status: protocol.PickerSourceOK, Lines: groupedSet.lines, Cursor: groupedSet.cursor,
291		Recent: recent, Grouped: grouped,
292	}
293	if err := protocol.ValidatePickerSnapshot(snapshot); err != nil {
294		attrs := []any{"err", err, "interaction", interaction, "lines", len(snapshot.Lines), "revision", revision}
295		if first, second, dup := pickerDuplicateRows(snapshot.Lines); dup {
296			attrs = append(attrs, "first_row", first, "second_row", second)
297		}
298		d.log.Error("picker snapshot rejected", attrs...)
299		return err
300	}
301	if err := effect.sendControl(snapshot); err != nil {
302		return err
303	}
304	// The client now observes this revision: commit the keys and projections
305	// its selection will validate against. A concurrent close or replacement
306	// interaction owns a newer namespace, so a superseded commit is dropped
307	// instead of reviving the retired source.
308	ac.overlays.pickerMu.Lock()
309	if ac.overlays.pickerOpen && ac.overlays.pickerInteraction == interaction {
310		ac.overlays.pickerRevisions[servingPickerSourceID] = revision
311		ac.overlays.pickerKeys = groupedSet.keys
312		ac.overlays.pickerSourcePublished = true
313		ac.overlays.pickerRecent = recent
314		ac.overlays.pickerGrouped = grouped
315	}
316	ac.overlays.pickerMu.Unlock()
317	return nil
318}
319
320// setProjection presents one resolved line set as a publishable projection.
321func setProjection(set pickerLineSet) protocol.PickerProjection {
322	return protocol.PickerProjection{Lines: set.lines, Cursor: set.cursor}
323}
324
325// pickerProjectionEqual reports whether two published projections present the
326// same authoritative facts. Order stays significant: a reordering changes the
327// row a cursor index names, which is authority the client must observe.
328func pickerProjectionEqual(left, right protocol.PickerProjection) bool {
329	return left.Cursor == right.Cursor && slices.Equal(left.Lines, right.Lines)
330}
331
332// pickerLineSetHasMove reports whether a move line set offers any destination,
333// which is what failed move entry must report instead of opening an empty
334// picker.
335func pickerLineSetHasMove(set pickerLineSet) bool {
336	for _, line := range set.lines {
337		if line.Actions&protocol.PickerCanMove != 0 {
338			return true
339		}
340	}
341	return false
342}
343
344// refreshPickerSnapshot republishes the serving line set after its rows, the
345// attachment view, or the size changed. It derives a fresh effect from the
346// current attachment capability: the effect that opened the interaction is
347// bounded to its own operation and may already be retired. A closed
348// interaction is a no-op.
349func (d *Daemon) refreshPickerSnapshot(ac *attachedClient) {
350	open, interaction, _, _, _ := ac.pickerState()
351	if !open || interaction == 0 {
352		return
353	}
354	sess := ac.currentAttachmentSession()
355	if sess == nil {
356		return
357	}
358	current := ac.transportSnapshot()
359	_, effect, admitted := ac.beginCurrentAttachmentEffect(sess, current.transport)
360	if !admitted {
361		return
362	}
363	defer effect.End()
364	_ = d.publishPickerSourceForAttachment(ac, effect, interaction)
365	if sess := ac.currentAttachmentSession(); sess != nil {
366		d.invalidateRender(sess, ac, false, "picker.go:refresh")
367	}
368}
369
370// closePickerForAttachment retires the interaction and confirms it to the
371// client with the daemon output boundary the client must display before it
372// releases the terminal. Stale closes skip so generations stay ordered. The
373// boolean reports whether this call retired the interaction.
374func (d *Daemon) closePickerForAttachment(ac *attachedClient, effect *attachmentEffect, interaction uint64) bool {
375	if ac == nil || ac.overlays == nil || interaction == 0 {
376		return false
377	}
378	ac.overlays.pickerMu.Lock()
379	if !ac.overlays.pickerOpen || ac.overlays.pickerInteraction != interaction {
380		ac.overlays.pickerMu.Unlock()
381		return false
382	}
383	ac.overlays.pickerOpen = false
384	ac.overlays.pickerKeys = nil
385	ac.overlays.pickerRevisions = nil
386	ac.overlays.pickerSourcePublished = false
387	ac.overlays.pickerRecent = protocol.PickerProjection{}
388	ac.overlays.pickerGrouped = protocol.PickerProjection{}
389	ac.overlays.pickerMu.Unlock()
390	if effect == nil {
391		return true
392	}
393	barrierEpoch, barrierState, _ := pickerClientBarrier(ac)
394	closed := protocol.PickerClosed{InteractionID: interaction, BarrierEpoch: barrierEpoch, BarrierState: barrierState}
395	if protocol.ValidatePickerClosed(closed) != nil {
396		return true
397	}
398	_ = effect.sendControl(closed)
399	return true
400}
401
402// resolvePickerSelection validates one typed selection against the open
403// interaction and performs the action the owning source authorised. Unknown
404// keys, foreign sources, and stale revisions reject with a bounded failure.
405func (d *Daemon) resolvePickerSelection(effect *attachmentEffect, selection protocol.PickerSelection) {
406	if protocol.ValidatePickerSelection(selection) != nil || effect == nil {
407		return
408	}
409	ac := effect.ac
410	if ac == nil || ac.overlays == nil {
411		return
412	}
413	ac.overlays.pickerMu.Lock()
414	open := ac.overlays.pickerOpen
415	interaction := ac.overlays.pickerInteraction
416	intent := ac.overlays.pickerIntent
417	source := ac.overlays.pickerMoveSource
418	revision := ac.overlays.pickerRevisions[selection.SourceID]
419	target, ok := ac.overlays.pickerKeys[selection.Key]
420	ac.overlays.pickerMu.Unlock()
421	if !open || interaction != selection.InteractionID {
422		d.sendPickerFailure(effect, selection, protocol.PickerStaleRevision)
423		return
424	}
425	if selection.SourceID != servingPickerSourceID {
426		d.sendPickerFailure(effect, selection, protocol.PickerUnknownSource)
427		return
428	}
429	if selection.SourceRevision != revision {
430		d.sendPickerFailure(effect, selection, protocol.PickerStaleRevision)
431		return
432	}
433	if !ok {
434		d.sendPickerFailure(effect, selection, protocol.PickerUnknownKey)
435		return
436	}
437	// Navigation commits through the handoff, which must not accept a target
438	// whose lifecycle already moved on. Move and kill revalidate source and
439	// destination themselves (move_lifecycle.go / killPickerTarget), so they
440	// report their own precise rejection instead of a generic retire.
441	if selection.Action == protocol.PickerActionNavigate && !d.pickerTargetCurrent(target) {
442		d.sendPickerFailure(effect, selection, protocol.PickerRetiredTarget)
443		return
444	}
445	switch selection.Action {
446	case protocol.PickerActionKill:
447		d.resolvePickerKill(effect, ac, interaction, target, selection)
448	case protocol.PickerActionMove:
449		d.resolvePickerMove(effect, ac, interaction, intent, source, target, selection)
450	default:
451		d.resolvePickerNavigate(effect, ac, interaction, target, selection)
452	}
453}
454
455// resolvePickerNavigate retires the interaction before the handoff. The
456// client's release waits for the daemon's own close, and the handoff's
457// destination paint has to be that paint: closing afterwards would let it be
458// suppressed while the client still owned the terminal.
459func (d *Daemon) resolvePickerNavigate(effect *attachmentEffect, ac *attachedClient, interaction uint64, target picker.Target, selection protocol.PickerSelection) {
460	if !d.closePickerForAttachment(ac, effect, interaction) {
461		d.sendPickerFailure(effect, selection, protocol.PickerStaleRevision)
462		return
463	}
464	if err := d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "picker-select"); err != nil {
465		d.sendPickerFailure(effect, selection, protocol.PickerNavigationFailed)
466		// The interaction is already retired: the client drains until an
467		// authoritative paint, so publish one even though the handoff failed.
468		d.invalidateRender(effect.sess, ac, true, "picker-select-failed")
469	}
470}
471
472// resolvePickerMove closes before committing a composite move+follow. The
473// calling effect must end before the transaction freezes attachment admission.
474func (d *Daemon) resolvePickerMove(effect *attachmentEffect, ac *attachedClient, interaction uint64, intent protocol.PickerIntent, source moveSourceLocator, target picker.Target, selection protocol.PickerSelection) {
475	sess := effect.sess
476	if err := d.movePickerSourceError(source); err != nil {
477		d.sendPickerFailure(effect, selection, protocol.PickerRetiredTarget)
478		d.reportAttachmentError(sess, movePickerUserError(err))
479		d.refreshPickerSnapshot(ac)
480		return
481	}
482	closed := false
483	beforeFollow := func() error {
484		if !d.closePickerForAttachment(ac, effect, interaction) {
485			return errMoveStaleTarget
486		}
487		closed = true
488		effect.End()
489		return nil
490	}
491	// A composite follow reports completion from the move postcommit, which
492	// admits a fresh effect on the published destination capability once the
493	// committing effect has ended and the destination paint is authoritative.
494	afterFollow := func(published *attachmentEffect) {
495		d.sendPickerResult(published, selection, interaction)
496	}
497	// Non-following pane moves retain the open interaction. The transaction
498	// calls beforeFollow only after admission proves that it will follow.
499	defer effect.End()
500	if err := d.commitMovePickerSelection(intent, source, target, beforeFollow, afterFollow); err != nil {
501		if closed {
502			// The committing effect already ended so the transaction could drain
503			// it. Reject and repaint through the attachment's current capability
504			// instead of reusing an effect that is no longer admitted.
505			d.reportAttachmentError(sess, movePickerUserError(err))
506			d.rejectClosedPickerMove(ac, selection)
507		} else {
508			d.sendPickerFailure(effect, selection, protocol.PickerActionFailed)
509			d.reportAttachmentError(sess, movePickerUserError(err))
510			d.refreshPickerSnapshot(ac)
511		}
512		return
513	}
514	if !closed {
515		d.sendPickerResult(effect, selection, interaction)
516		d.refreshPickerSnapshot(ac)
517	}
518}
519
520// rejectClosedPickerMove delivers bounded rejection feedback and an
521// authoritative repaint after a composite move closed the picker and ended its
522// committing effect. It never sends through that ended effect: a fresh effect
523// on the attachment's current capability carries the failure, and the paint
524// releases the client's post-close drain.
525func (d *Daemon) rejectClosedPickerMove(ac *attachedClient, selection protocol.PickerSelection) {
526	sess := ac.currentAttachmentSession()
527	if sess == nil {
528		return
529	}
530	d.invalidateRender(sess, ac, true, "picker-move-failed")
531	_, effect, admitted := ac.beginCurrentAttachmentEffect(sess, ac.transport())
532	if !admitted {
533		return
534	}
535	d.sendPickerFailure(effect, selection, protocol.PickerActionFailed)
536	effect.End()
537}
538
539// resolvePickerKill destroys the target and keeps the picker open.
540func (d *Daemon) resolvePickerKill(effect *attachmentEffect, ac *attachedClient, interaction uint64, target picker.Target, selection protocol.PickerSelection) {
541	// The kill holds this admission until it finishes, so a replacement
542	// initiator cannot make the destructive action look unattributed.
543	effect.bindActionEnd(d, "picker-delete")
544	if err := d.killPickerTargetForAttachment(target, effect); err != nil {
545		d.sendPickerFailure(effect, selection, protocol.PickerActionFailed)
546		d.refreshPickerSnapshot(ac)
547		return
548	}
549	d.sendPickerResult(effect, selection, interaction)
550	d.refreshPickerSnapshot(ac)
551}
552
553// pickerTargetCurrent revalidates a resolved target for lifecycle freshness:
554// incarnation pin plus expected-created-at, mirroring previewTarget and
555// targetMatchesLifecycle. A renamed-or-recreated session between snapshot and
556// commit rejects as retired, never force-commits.
557func (d *Daemon) pickerTargetCurrent(target picker.Target) bool {
558	// A remote target names an endpoint-qualified remote session, not a local
559	// one: resolving it against the local session table would always miss and
560	// report a retired target. Its exact route is revalidated against the remote
561	// catalogue by the handoff itself (remoteCatalogTargetReady), which reports
562	// its own precise rejection.
563	if target.RemoteTarget != nil || target.RemoteKey != nil {
564		return true
565	}
566	if target.Name == "" {
567		d.mu.Lock()
568		sess := d.sessions[target.Session]
569		d.mu.Unlock()
570		if sess == nil {
571			return false
572		}
573		sess.mu.Lock()
574		defer sess.mu.Unlock()
575		return targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation)
576	}
577	d.mu.Lock()
578	defer d.mu.Unlock()
579	_, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
580	if !ok {
581		return false
582	}
583	if isStopped {
584		return targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation)
585	}
586	return true
587}
588
589// pickerClientBarrier snapshots the acquisition boundary for one interaction
590// offer or close: the output fence epoch, the latest committed state, and the
591// view revision that doubles as the size epoch. It reads viewMu-protected
592// state only (never pane/session locks, never sendMu — see the lock-order
593// notes at the top of client.go); callers must not hold pickerMu.
594func pickerClientBarrier(ac *attachedClient) (epoch, state, sizeEpoch uint64) {
595	if ac == nil {
596		return 0, 0, 0
597	}
598	view := ac.viewSnapshot()
599	if ac.output != nil {
600		epoch = ac.output.currentEpoch()
601		state = ac.output.committedState()
602	}
603	return epoch, state, view.revision
604}
605
606func (d *Daemon) sendPickerResult(effect *attachmentEffect, selection protocol.PickerSelection, interaction uint64) {
607	result := protocol.PickerResult{
608		CauseActionID: selection.CauseActionID, RequestID: selection.RequestID,
609		InteractionID: interaction, SourceID: selection.SourceID, Key: selection.Key, Action: selection.Action,
610	}
611	if protocol.ValidatePickerResult(result) != nil {
612		return
613	}
614	_ = effect.sendControl(result)
615}
616
617func (d *Daemon) sendPickerFailure(effect *attachmentEffect, selection protocol.PickerSelection, code protocol.PickerFailureCode) {
618	failure := protocol.PickerFailure{
619		CauseActionID: selection.CauseActionID, RequestID: selection.RequestID,
620		InteractionID: selection.InteractionID, SourceID: selection.SourceID,
621		Key: selection.Key, Action: selection.Action, Code: code,
622	}
623	if protocol.ValidatePickerFailure(failure) != nil {
624		return
625	}
626	_ = effect.sendControl(failure)
627}

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