picker_input.go RawHistoryFolder

1package client
2
3import (
4	"bytes"
5	"sync"
6	"time"
7	"unicode/utf8"
8)
9
10// This file owns user-input routing for a client-owned picker interaction.
11//
12// The attach loop is the single owner of the picker model: it publishes who
13// owns user input through pickerConsumer, and the stdin pump only decodes
14// batches into identified operations. The pump never mutates the model, so
15// the two goroutines never share it.
16//
17// While an interaction owns input, every byte read from the terminal
18// belongs to the picker: unknown or oversized sequences are consumed and
19// dropped rather than forwarded to the session. There is deliberately no
20// fallback to the PTY pipeline.
21
22// pickerPendingLimit bounds a withheld escape-sequence prefix. A longer
23// incomplete sequence is unrecognized input and is discarded.
24const pickerPendingLimit = 8
25
26// pickerEscapeDeadline bounds how long a lone ESC is withheld while the
27// picker owns input, so ESC and the start of a CSI/SS3 sequence are told
28// apart without ever falling back to the session pipeline.
29const pickerEscapeDeadline = 20 * time.Millisecond
30
31// Bracketed-paste delimiters. Their content is dropped: a paste must never
32// become a run of modal commands, and the picker has no text field that
33// wants pasted input.
34const (
35	pasteOpenMarker  = "\x1b[200~"
36	pasteCloseMarker = "\x1b[201~"
37)
38
39// pickerInputState is the attach loop's publication of the current input
40// owner: zero interaction with drain unset means the session owns input.
41type pickerInputState struct {
42	interaction uint64
43	generation  uint64
44	// drain consumes and drops every batch while the interaction is
45	// retired but its authoritative repaint is still in flight.
46	drain bool
47}
48
49// pickerEventKind distinguishes a command token from a typed rune. Events
50// keep their arrival order: a batch must never reorder input.
51type pickerEventKind uint8
52
53const (
54	pickerEventKey pickerEventKind = iota
55	pickerEventRune
56)
57
58// pickerEvent is one decoded input event.
59type pickerEvent struct {
60	kind pickerEventKind
61	key  string
62	r    rune
63}
64
65// pickerConsumer is the slot shared between the attach loop and the stdin
66// pump. It protects the published owner, the withheld escape or UTF-8
67// prefix, and the paste state; it holds no picker model.
68type pickerConsumer struct {
69	mu        sync.Mutex
70	state     pickerInputState
71	pending   []byte
72	pasting   bool
73	pasteTail []byte
74}
75
76// setOwned publishes the interaction that now owns user input.
77func (c *pickerConsumer) setOwned(interaction, generation uint64) {
78	if c == nil {
79		return
80	}
81	c.mu.Lock()
82	c.state = pickerInputState{interaction: interaction, generation: generation}
83	c.resetDecoderLocked()
84	c.mu.Unlock()
85}
86
87// clear releases input back to the session and purges the decoder state.
88func (c *pickerConsumer) clear() {
89	if c == nil {
90		return
91	}
92	c.mu.Lock()
93	c.state = pickerInputState{}
94	c.resetDecoderLocked()
95	c.mu.Unlock()
96}
97
98// resetDecoderLocked drops a withheld prefix and any paste state. A retired
99// owner must never leak a half-decoded sequence into the next one.
100func (c *pickerConsumer) resetDecoderLocked() {
101	c.pending = nil
102	c.pasting = false
103	c.pasteTail = nil
104}
105
106// consume decodes one read or admitted automation batch into an identified
107// operation. The second result is false when the session owns input, so the
108// caller keeps its normal pipeline. During the release window every batch is
109// consumed and dropped.
110func (c *pickerConsumer) consume(record terminalReadResult) (pickerConsumeOutcome, bool) {
111	if c == nil {
112		return pickerConsumeOutcome{}, false
113	}
114	c.mu.Lock()
115	state := c.state
116	if state.interaction == 0 && !state.drain {
117		c.mu.Unlock()
118		return pickerConsumeOutcome{}, false
119	}
120	outcome := pickerConsumeOutcome{
121		consumed: true, actionID: record.actionID,
122		generation: state.generation, interaction: state.interaction,
123	}
124	switch {
125	case state.drain:
126		// Retired interaction: consume and drop, including any prefix.
127		c.resetDecoderLocked()
128	case record.keys == nil && record.text == "":
129		var batch pickerInputBatch
130		c.pending, c.pasting, c.pasteTail = c.decodeLocked(record.data, &batch)
131		outcome.events = batch.events
132	default:
133		outcome.events = automationEvents(record.keys, record.text)
134	}
135	c.mu.Unlock()
136	return outcome, true
137}
138
139// flushPending resolves a withheld prefix once its disambiguation window
140// expired. A bare escape becomes Escape; an incomplete escape sequence is
141// dropped; an incomplete UTF-8 prefix is dropped. A paste in progress stays
142// in progress: its content is never replayed as commands.
143func (c *pickerConsumer) flushPending() (pickerConsumeOutcome, bool) {
144	if c == nil {
145		return pickerConsumeOutcome{}, false
146	}
147	c.mu.Lock()
148	state, pending := c.state, c.pending
149	c.pending = nil
150	c.mu.Unlock()
151	if len(pending) == 0 || state.drain {
152		return pickerConsumeOutcome{}, false
153	}
154	outcome := pickerConsumeOutcome{consumed: true, generation: state.generation, interaction: state.interaction}
155	if string(pending) == "\x1b" {
156		outcome.events = []pickerEvent{{kind: pickerEventKey, key: "Escape"}}
157	}
158	return outcome, true
159}
160
161// hasPending reports whether an escape or UTF-8 prefix is withheld.
162func (c *pickerConsumer) hasPending() bool {
163	if c == nil {
164		return false
165	}
166	c.mu.Lock()
167	defer c.mu.Unlock()
168	return len(c.pending) != 0
169}
170
171// automationEvents converts one admitted ui-driver op into ordered events.
172func automationEvents(keys []string, text string) []pickerEvent {
173	events := make([]pickerEvent, 0, len(keys)+len(text))
174	for _, key := range keys {
175		events = append(events, pickerEvent{kind: pickerEventKey, key: key})
176	}
177	for _, r := range text {
178		events = append(events, pickerEvent{kind: pickerEventRune, r: r})
179	}
180	return events
181}
182
183// decoder carries the paste state across reads: a paste's boundaries can
184// span several terminal reads, and its content must never be interpreted.
185type decoder struct {
186	pending []byte
187	pasting bool
188	tail    []byte
189}
190
191// decodeLocked decodes bytes under the consumer lock and returns the next
192// pending prefix, paste state, and paste tail.
193func (c *pickerConsumer) decodeLocked(data []byte, batch *pickerInputBatch) ([]byte, bool, []byte) {
194	d := decoder{pending: c.pending, pasting: c.pasting, tail: c.pasteTail}
195	d.decode(data, batch)
196	return d.pending, d.pasting, d.tail
197}
198
199// decode consumes one read into ordered events. A non-nil pending result is
200// an incomplete escape or UTF-8 prefix to withhold until the next read.
201func (d *decoder) decode(data []byte, batch *pickerInputBatch) {
202	buf := data
203	if len(d.pending) != 0 {
204		buf = append(append([]byte(nil), d.pending...), data...)
205		d.pending = nil
206	}
207	if d.pasting {
208		buf = append(append([]byte(nil), d.tail...), buf...)
209		d.tail = nil
210	}
211	for i := 0; i < len(buf); {
212		if d.pasting {
213			end := bytes.Index(buf[i:], []byte(pasteCloseMarker))
214			if end < 0 {
215				// Hold only enough trailing bytes to recognize a marker split
216				// across reads; everything else in the paste is dropped.
217				keep := len(pasteCloseMarker) - 1
218				if len(buf)-i > keep {
219					d.tail = append([]byte(nil), buf[len(buf)-keep:]...)
220				} else {
221					d.tail = append([]byte(nil), buf[i:]...)
222				}
223				return
224			}
225			i += end + len(pasteCloseMarker)
226			d.pasting = false
227			continue
228		}
229		switch b := buf[i]; {
230		case b == '\r' || b == '\n':
231			batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Enter"})
232			i++
233		case b == 0x7f || b == 0x08:
234			batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Backspace"})
235			i++
236		case b == 0x03:
237			batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Ctrl+C"})
238			i++
239		case b == 0x1b:
240			consumed, held, paste := decodeEscape(buf[i:], batch)
241			if held != nil {
242				d.pending = held
243				return
244			}
245			if paste {
246				d.pasting = true
247			}
248			i += consumed
249		case b < 0x20:
250			// Other control bytes carry no picker meaning; consume them so
251			// they cannot reach the session.
252			i++
253		case b < utf8.RuneSelf:
254			batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: string(rune(b))})
255			i++
256		default:
257			r, size := utf8.DecodeRune(buf[i:])
258			if r == utf8.RuneError && size <= 1 {
259				if !utf8PrefixIncomplete(buf[i:]) {
260					i++ // Invalid UTF-8: consume the byte and keep scanning.
261					continue
262				}
263				d.pending = append([]byte(nil), buf[i:]...)
264				return
265			}
266			batch.events = append(batch.events, pickerEvent{kind: pickerEventRune, r: r})
267			i += size
268		}
269	}
270}
271
272// decodeEscape consumes one escape-led sequence starting at buf[0]. It
273// returns the number of bytes consumed; a non-nil result is an incomplete
274// prefix to withhold until the next read. paste reports the bracketed-paste
275// start marker.
276func decodeEscape(buf []byte, batch *pickerInputBatch) (consumed int, held []byte, paste bool) {
277	if len(buf) == 1 {
278		return 0, append([]byte(nil), buf...), false
279	}
280	if buf[1] != '[' && buf[1] != 'O' {
281		// ESC followed by an ordinary byte: the escape stands alone.
282		batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Escape"})
283		return 1, nil, false
284	}
285	if len(buf) == 2 {
286		return 0, append([]byte(nil), buf...), false
287	}
288	switch buf[2] {
289	case 'A':
290		batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Up"})
291		return 3, nil, false
292	case 'B':
293		batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Down"})
294		return 3, nil, false
295	}
296	// Any other CSI/SS3 sequence (mouse reports, probes, paste markers) is
297	// consumed through its final byte and dropped.
298	final := -1
299	for i := 2; i < len(buf); i++ {
300		if buf[i] >= 0x40 && buf[i] <= 0x7e {
301			final = i
302			break
303		}
304	}
305	if final < 0 {
306		if len(buf) > pickerPendingLimit {
307			return len(buf), nil, false
308		}
309		return 0, append([]byte(nil), buf...), false
310	}
311	seq := buf[:final+1]
312	if string(seq) == pasteOpenMarker {
313		return final + 1, nil, true
314	}
315	return final + 1, nil, false
316}
317
318// utf8PrefixIncomplete reports whether b is a valid but incomplete UTF-8
319// sequence start, which must be withheld until its continuation bytes
320// arrive instead of being dropped.
321func utf8PrefixIncomplete(b []byte) bool {
322	if len(b) == 0 || !utf8.RuneStart(b[0]) {
323		return false
324	}
325	need := 0
326	switch {
327	case b[0]&0xe0 == 0xc0:
328		need = 2
329	case b[0]&0xf0 == 0xe0:
330		need = 3
331	case b[0]&0xf8 == 0xf0:
332		need = 4
333	default:
334		return false
335	}
336	if len(b) >= need {
337		return false
338	}
339	for _, c := range b[1:] {
340		if c&0xc0 != 0x80 {
341			return false
342		}
343	}
344	return true
345}

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