detached ece7682

picker_interaction.go RawHistoryFolder

1package protocol
2
3import "fmt"
4
5// PickerServingSourceID names the source the serving daemon owns: every
6// attachment publishes its own rows under this one source identity.
7const (
8	PickerServingSourceID = "serving"
9	PickerHomeSourceID    = "home"
10)
11
12// Client-picker interaction bounds. A source publishes at most
13// PickerInteractionMaxLines structured lines; the complete encoded snapshot
14// stays below PickerInteractionMaxEncodedBytes, the same 4 MiB export budget
15// shared with navigation inventory.
16const (
17	PickerInteractionMaxLines        = 4096
18	PickerInteractionMaxDisplayBytes = 256
19	PickerInteractionMaxEncodedBytes = 4 << 20
20	PickerInteractionMaxSourceBytes  = 128
21	PickerInteractionMaxTitleBytes   = 256
22)
23
24// PickerIntent names which destinations a picker offers and which mutation a
25// selection performs. The daemon owns the intent: it captures move sources and
26// decides which rows are eligible.
27type PickerIntent uint8
28
29const (
30	PickerIntentNavigation PickerIntent = iota + 1
31	PickerIntentMovePane
32	PickerIntentMoveTab
33)
34
35// PickerAction names the mutation one selection requests.
36type PickerAction uint8
37
38const (
39	PickerActionNavigate PickerAction = iota + 1
40	PickerActionMove
41	PickerActionKill
42)
43
44// PickerLineKind names the row shape. Section rows are group headers and
45// carry no key; presentation-only rows (host status) stay focusable but not
46// selectable.
47type PickerLineKind uint8
48
49const (
50	PickerLineSection PickerLineKind = iota + 1
51	PickerLineSession
52	PickerLineTab
53	PickerLineHost
54)
55
56// PickerLineStatus is the bounded state badge drawn on a row.
57type PickerLineStatus uint8
58
59const (
60	PickerLineStatusNone PickerLineStatus = iota
61	PickerLineStatusUp
62	PickerLineStatusStopped
63	PickerLineStatusDown
64	PickerLineStatusStale
65	PickerLineStatusVersion
66	PickerLineStatusError
67	PickerLineStatusNoDaemon
68)
69
70// PickerLineActions is the set of actions a row admits. The presenting client
71// offers only what the owning source authorised; the source revalidates the
72// action and the row lifecycle at commit.
73type PickerLineActions uint8
74
75const (
76	PickerCanNavigate PickerLineActions = 1 << iota
77	PickerCanMove
78	PickerCanKill
79)
80
81// PickerSourceStatus is the per-source outcome. A failed source never
82// invalidates another source's lines.
83type PickerSourceStatus uint8
84
85const (
86	PickerSourceOK PickerSourceStatus = iota + 1
87	PickerSourceUnavailable
88	PickerSourceTooLarge
89)
90
91// PickerLine is one structured row. Key is opaque and source-assigned; it
92// resolves to a target source-side only. Display strings carry presentation
93// facts, never endpoints, credentials, environment, CWD, or pane content.
94// Lines are published in the source's own order (recency for local sessions)
95// and Ephemeral marks sessions created for one attach, which is what the
96// presenting client needs to offer its local grouped order.
97type PickerLine struct {
98	Key          string
99	Kind         PickerLineKind
100	Label        string
101	Detail       string
102	Status       PickerLineStatus
103	StatusDetail string
104	Attention    bool
105	Stopped      bool
106	Dim          bool
107	// Focusable reports that the source authorises this row as a cursor
108	// destination for this interaction, independently of whether it admits an
109	// action. A Focusable row with no Actions is deliberately inspectable and
110	// never committable; a row that is neither Focusable nor actionable is
111	// rendered but skipped by cursor navigation.
112	Focusable bool
113	Actions   PickerLineActions
114	Ephemeral bool
115}
116
117// Selectable reports whether the row admits at least one action.
118func (line PickerLine) Selectable() bool { return line.Actions != 0 }
119
120// PickerCursor restores the selected row by opaque key with its list index.
121type PickerCursor struct {
122	Key   string
123	Index int
124}
125
126// PickerOffer opens one interaction. The daemon sends it for intents it
127// initiates (a session-picker command or a move request); it carries the
128// acquisition barrier the client must display before owning the terminal and
129// the opaque identity of the captured move source. RequestID correlates a
130// client-owned interaction request, and is zero for daemon-driven opens.
131type PickerOffer struct {
132	InteractionID uint64
133	RequestID     uint64
134	Intent        PickerIntent
135	MoveSourceKey string
136	BarrierEpoch  uint64
137	BarrierState  uint64
138	SizeEpoch     uint64
139	Title         string
140}
141
142// PickerProjection is one complete daemon-authorised presentation. Recent and
143// Grouped contain the same destination keys and actions but may differ in
144// order, sections, and display labels.
145type PickerProjection struct {
146	Lines  []PickerLine
147	Cursor PickerCursor
148}
149
150// PickerSnapshot atomically publishes both presentation projections of one
151// source at one revision. The client chooses a projection but never derives
152// grouping or ordering policy from row metadata.
153type PickerSnapshot struct {
154	InteractionID  uint64
155	SourceID       string
156	SourceRevision uint64
157	Status         PickerSourceStatus
158	StatusDetail   string
159	// Lines/Cursor are retained as construction aliases inside this v0 branch;
160	// normalization copies them into both projections before encoding.
161	Lines   []PickerLine
162	Cursor  PickerCursor
163	Recent  PickerProjection
164	Grouped PickerProjection
165}
166
167// PickerSelection commits the opaque row key displayed at SourceRevision.
168// CauseActionID correlates ui-driver input like NavigationInventorySelection.
169// RequestID correlates a client-initiated interaction, and is zero otherwise.
170type PickerSelection struct {
171	CauseActionID  uint64
172	RequestID      uint64
173	InteractionID  uint64
174	SourceID       string
175	SourceRevision uint64
176	Key            string
177	Action         PickerAction
178}
179
180// PickerResult reports a completed mutation that changed source state, so the
181// client can refresh without guessing. Rejections use PickerFailure.
182type PickerResult struct {
183	CauseActionID uint64
184	RequestID     uint64
185	InteractionID uint64
186	SourceID      string
187	Key           string
188	Action        PickerAction
189}
190
191// PickerFailureCode classifies client-picker selection failures.
192type PickerFailureCode uint8
193
194const (
195	PickerStaleRevision PickerFailureCode = iota + 1
196	PickerUnknownKey
197	PickerRetiredTarget
198	PickerNavigationFailed
199	PickerUnknownSource
200	PickerActionFailed
201)
202
203// PickerFailure reports a rejected selection with a bounded code.
204type PickerFailure struct {
205	CauseActionID uint64
206	RequestID     uint64
207	InteractionID uint64
208	SourceID      string
209	Key           string
210	Action        PickerAction
211	Code          PickerFailureCode
212}
213
214// PickerClose asks the serving daemon to retire one interaction. It is
215// idempotent: a close for an unknown or already retired interaction is a
216// no-op, never an error.
217type PickerClose struct {
218	InteractionID uint64
219	RequestID     uint64
220}
221
222// PickerClosed confirms that the serving daemon retired one interaction. The
223// barrier names the daemon output boundary the client must display before it
224// releases the terminal.
225type PickerClosed struct {
226	InteractionID uint64
227	BarrierEpoch  uint64
228	BarrierState  uint64
229}
230
231func validPickerIntent(intent PickerIntent) bool {
232	switch intent {
233	case PickerIntentNavigation, PickerIntentMovePane, PickerIntentMoveTab:
234		return true
235	default:
236		return false
237	}
238}
239
240func validPickerAction(action PickerAction) bool {
241	switch action {
242	case PickerActionNavigate, PickerActionMove, PickerActionKill:
243		return true
244	default:
245		return false
246	}
247}
248
249func validPickerLineKind(kind PickerLineKind) bool {
250	switch kind {
251	case PickerLineSection, PickerLineSession, PickerLineTab, PickerLineHost:
252		return true
253	default:
254		return false
255	}
256}
257
258func validPickerLineStatus(status PickerLineStatus) bool {
259	return status <= PickerLineStatusNoDaemon
260}
261
262func validPickerLineActions(actions PickerLineActions) bool {
263	return actions&^(PickerCanNavigate|PickerCanMove|PickerCanKill) == 0
264}
265
266func validPickerSourceStatus(status PickerSourceStatus) bool {
267	switch status {
268	case PickerSourceOK, PickerSourceUnavailable, PickerSourceTooLarge:
269		return true
270	default:
271		return false
272	}
273}
274
275func validPickerFailureCode(code PickerFailureCode) bool {
276	switch code {
277	case PickerStaleRevision, PickerUnknownKey, PickerRetiredTarget, PickerNavigationFailed, PickerUnknownSource, PickerActionFailed:
278		return true
279	default:
280		return false
281	}
282}
283
284func validPickerKey(value string) bool {
285	return validInventoryKey(value)
286}
287
288func validPickerDisplay(value string) bool {
289	if len(value) > PickerInteractionMaxDisplayBytes {
290		return false
291	}
292	return validInventoryDisplay(value)
293}
294
295func validPickerSourceID(value string) bool {
296	if value == "" || len(value) > PickerInteractionMaxSourceBytes {
297		return false
298	}
299	return validInventoryDisplay(value)
300}
301
302// ValidatePickerOffer enforces a nonzero interaction, a known intent, bounded
303// display strings, and a move source only on move intents.
304func ValidatePickerOffer(offer PickerOffer) error {
305	if offer.InteractionID == 0 || !validPickerIntent(offer.Intent) {
306		return ErrInvalidNavigation
307	}
308	if !validPickerDisplay(offer.Title) {
309		return ErrInvalidNavigation
310	}
311	if offer.MoveSourceKey != "" {
312		if offer.Intent == PickerIntentNavigation || !validPickerKey(offer.MoveSourceKey) {
313			return ErrInvalidNavigation
314		}
315	}
316	return nil
317}
318
319// ValidatePickerSnapshot enforces a nonzero interaction/source revision, a
320// bounded source identity, and unique opaque keys with display safety. Order
321// is preserved verbatim: lines are never re-sorted here.
322// ValidatePickerSnapshot enforces the envelope, the per-line shape, unique row
323// keys, and a cursor that names a published row. Every rejection wraps
324// ErrInvalidNavigation with the offending row, because a refused snapshot
325// otherwise leaves an operator with nothing but a silent, closed picker.
326func ValidatePickerSnapshot(snapshot PickerSnapshot) error {
327	snapshot = NormalizePickerSnapshot(snapshot)
328	if snapshot.InteractionID == 0 || snapshot.SourceRevision == 0 {
329		return fmt.Errorf("%w: snapshot envelope", ErrInvalidNavigation)
330	}
331	if !validPickerSourceID(snapshot.SourceID) || !validPickerSourceStatus(snapshot.Status) {
332		return fmt.Errorf("%w: snapshot source %q", ErrInvalidNavigation, snapshot.SourceID)
333	}
334	if !validPickerDisplay(snapshot.StatusDetail) {
335		return fmt.Errorf("%w: snapshot status detail", ErrInvalidNavigation)
336	}
337	recentKeys, err := validatePickerProjection("recent", snapshot.Recent)
338	if err != nil {
339		return err
340	}
341	groupedKeys, err := validatePickerProjection("grouped", snapshot.Grouped)
342	if err != nil {
343		return err
344	}
345	if len(recentKeys) != len(groupedKeys) {
346		return fmt.Errorf("%w: projection destination count", ErrInvalidNavigation)
347	}
348	for key, recent := range recentKeys {
349		grouped, ok := groupedKeys[key]
350		if !ok || recent != grouped {
351			return fmt.Errorf("%w: projection destination %q", ErrInvalidNavigation, key)
352		}
353	}
354	return nil
355}
356
357func NormalizePickerSnapshot(snapshot PickerSnapshot) PickerSnapshot {
358	if len(snapshot.Recent.Lines) == 0 && len(snapshot.Lines) != 0 {
359		snapshot.Recent = PickerProjection{Lines: snapshot.Lines, Cursor: snapshot.Cursor}
360	}
361	if len(snapshot.Grouped.Lines) == 0 && len(snapshot.Lines) != 0 {
362		snapshot.Grouped = PickerProjection{Lines: snapshot.Lines, Cursor: snapshot.Cursor}
363	}
364	return snapshot
365}
366
367func validatePickerProjection(name string, projection PickerProjection) (map[string]PickerLineActions, error) {
368	if len(projection.Lines) > PickerInteractionMaxLines {
369		return nil, fmt.Errorf("%w: %s has %d lines", ErrInvalidNavigation, name, len(projection.Lines))
370	}
371	seen := make(map[string]PickerLineActions, len(projection.Lines))
372	for i, line := range projection.Lines {
373		if !validPickerLineKind(line.Kind) || !validPickerLineStatus(line.Status) || !validPickerLineActions(line.Actions) {
374			return nil, fmt.Errorf("%w: %s line %d kind/status/actions", ErrInvalidNavigation, name, i)
375		}
376		if !validPickerDisplay(line.Label) || !validPickerDisplay(line.Detail) || !validPickerDisplay(line.StatusDetail) {
377			return nil, fmt.Errorf("%w: %s line %d display", ErrInvalidNavigation, name, i)
378		}
379		// Focus eligibility is published explicitly: a row that admits an
380		// action is always a cursor destination, and a section header is never
381		// one. A row that is neither is rendered and skipped.
382		if line.Actions != 0 && !line.Focusable {
383			return nil, fmt.Errorf("%w: %s line %d action without focus", ErrInvalidNavigation, name, i)
384		}
385		if line.Kind == PickerLineSection {
386			if line.Key != "" || line.Focusable || line.Actions != 0 {
387				return nil, fmt.Errorf("%w: %s line %d section key or action", ErrInvalidNavigation, name, i)
388			}
389			continue
390		}
391		if !validPickerKey(line.Key) {
392			return nil, fmt.Errorf("%w: %s line %d key %q", ErrInvalidNavigation, name, i, line.Key)
393		}
394		if _, dup := seen[line.Key]; dup {
395			return nil, fmt.Errorf("%w: %s line %d repeats key %q", ErrInvalidNavigation, name, i, line.Key)
396		}
397		seen[line.Key] = line.Actions
398	}
399	if projection.Cursor.Key != "" {
400		if !validPickerKey(projection.Cursor.Key) {
401			return nil, fmt.Errorf("%w: %s cursor key %q", ErrInvalidNavigation, name, projection.Cursor.Key)
402		}
403		if _, ok := seen[projection.Cursor.Key]; !ok {
404			return nil, fmt.Errorf("%w: %s cursor key %q is not a published row", ErrInvalidNavigation, name, projection.Cursor.Key)
405		}
406	}
407	if projection.Cursor.Index < 0 || projection.Cursor.Index > len(projection.Lines) {
408		return nil, fmt.Errorf("%w: %s cursor index %d of %d lines", ErrInvalidNavigation, name, projection.Cursor.Index, len(projection.Lines))
409	}
410	return seen, nil
411}
412
413// ValidatePickerClose requires a nonzero interaction.
414func ValidatePickerClose(close PickerClose) error {
415	if close.InteractionID == 0 {
416		return ErrInvalidNavigation
417	}
418	return nil
419}
420
421// ValidatePickerClosed requires a nonzero interaction.
422func ValidatePickerClosed(closed PickerClosed) error {
423	if closed.InteractionID == 0 {
424		return ErrInvalidNavigation
425	}
426	return nil
427}
428
429// ValidatePickerSelection enforces nonzero interaction/source revision, a
430// bounded source identity, a known action, and a known key shape. Unknown
431// keys, foreign sources, and stale revisions reject at resolve time, not here.
432func ValidatePickerSelection(selection PickerSelection) error {
433	if selection.InteractionID == 0 || selection.SourceRevision == 0 {
434		return ErrInvalidNavigation
435	}
436	if !validPickerSourceID(selection.SourceID) || !validPickerAction(selection.Action) {
437		return ErrInvalidNavigation
438	}
439	if !validPickerKey(selection.Key) {
440		return ErrInvalidNavigation
441	}
442	return nil
443}
444
445// ValidatePickerResult enforces matching identity for a completed mutation.
446func ValidatePickerResult(result PickerResult) error {
447	if result.InteractionID == 0 || !validPickerSourceID(result.SourceID) || !validPickerAction(result.Action) {
448		return ErrInvalidNavigation
449	}
450	if !validPickerKey(result.Key) {
451		return ErrInvalidNavigation
452	}
453	return nil
454}
455
456// ValidatePickerFailure enforces matching IDs and bounded codes.
457func ValidatePickerFailure(failure PickerFailure) error {
458	if failure.InteractionID == 0 || !validPickerAction(failure.Action) {
459		return ErrInvalidNavigation
460	}
461	if failure.SourceID != "" && !validPickerSourceID(failure.SourceID) {
462		return ErrInvalidNavigation
463	}
464	if failure.Key != "" && !validPickerKey(failure.Key) {
465		return ErrInvalidNavigation
466	}
467	if !validPickerFailureCode(failure.Code) {
468		return ErrInvalidNavigation
469	}
470	return nil
471}

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