ui_input.go RawHistoryFolder

1package client
2
3import (
4	"errors"
5	"strings"
6	"unicode/utf8"
7)
8
9const uiMaxInputBytes = 16 << 10
10
11var errUIInvalidInput = errors.New("invalid UI input")
12
13// encodeUIText accepts literal printable UTF-8, never terminal controls.
14func encodeUIText(text string) ([]byte, error) {
15	if text == "" || len(text) > uiMaxInputBytes || !utf8.ValidString(text) {
16		return nil, errUIInvalidInput
17	}
18	for _, r := range text {
19		if r < 0x20 || r >= 0x7f && r <= 0x9f {
20			return nil, errUIInvalidInput
21		}
22	}
23	return []byte(text), nil
24}
25
26// encodeUIKeys validates the complete batch as well as each token: individually
27// legal Escape and ASCII tokens must not assemble a terminal reply or paste.
28func encodeUIKeys(keys []string, applicationCursor bool) ([]byte, error) {
29	if len(keys) == 0 || len(keys) > 256 {
30		return nil, errUIInvalidInput
31	}
32	var out []byte
33	for _, key := range keys {
34		encoded, ok := encodeUIKey(key, applicationCursor)
35		if !ok || len(out)+len(encoded) > uiMaxInputBytes {
36			return nil, errUIInvalidInput
37		}
38		out = append(out, encoded...)
39	}
40	if !validUIKeyBatch(out) {
41		return nil, errUIInvalidInput
42	}
43	return out, nil
44}
45
46func encodeUIKey(key string, applicationCursor bool) (string, bool) {
47	if strings.HasPrefix(key, "Alt+") {
48		plain := strings.TrimPrefix(key, "Alt+")
49		switch plain {
50		case "Enter", "Escape", "Tab", "Backspace", "Space":
51			encoded, _ := encodeUIKey(plain, applicationCursor)
52			return "\x1b" + encoded, true
53		}
54		if len(plain) == 1 && plain[0] >= 0x20 && plain[0] <= 0x7e {
55			return "\x1b" + plain, true
56		}
57		return "", false
58	}
59	if strings.HasPrefix(key, "Ctrl+") {
60		plain := strings.TrimPrefix(key, "Ctrl+")
61		if len(plain) == 1 && (plain[0] >= 'A' && plain[0] <= 'Z' || strings.ContainsRune("@[\\]^_", rune(plain[0]))) {
62			return string([]byte{plain[0] & 31}), true
63		}
64		return "", false
65	}
66	if len(key) == 1 && key[0] >= 0x20 && key[0] <= 0x7e {
67		return key, true
68	}
69	switch key {
70	case "Enter":
71		return "\r", true
72	case "Escape":
73		return "\x1b", true
74	case "Tab":
75		return "\t", true
76	case "Backspace":
77		return "\x7f", true
78	case "Space":
79		return " ", true
80	case "PageUp":
81		return "\x1b[5~", true
82	case "PageDown":
83		return "\x1b[6~", true
84	}
85	var final string
86	switch key {
87	case "Up":
88		final = "A"
89	case "Down":
90		final = "B"
91	case "Right":
92		final = "C"
93	case "Left":
94		final = "D"
95	case "Home":
96		final = "H"
97	case "End":
98		final = "F"
99	default:
100		return "", false
101	}
102	prefix := "\x1b["
103	if applicationCursor {
104		prefix = "\x1bO"
105	}
106	return prefix + final, true
107}
108
109func validUIKeyBatch(data []byte) bool {
110	for i := 0; i < len(data); i++ {
111		if data[i] != 0x1b {
112			continue
113		}
114		// A bare Escape is the only intentionally incomplete escape sequence.
115		if i+1 == len(data) {
116			continue
117		}
118		switch data[i+1] {
119		case ']', 'P', '_', '^', 'X':
120			// A lone Alt character is valid, but adding a suffix would turn
121			// it into an OSC/DCS/APC/string prefix.
122			if i+2 < len(data) {
123				return false
124			}
125			continue
126		case '[':
127			// Alt+[ is a valid one-character key. Once it has a suffix, only
128			// one of the finite CSI keyboard sequences is accepted.
129			if i+2 >= len(data) {
130				continue
131			}
132			if strings.ContainsRune("ABCDHF", rune(data[i+2])) {
133				i += 2
134				continue
135			}
136			if i+3 < len(data) && (data[i+2] == '5' || data[i+2] == '6') && data[i+3] == '~' {
137				i += 3
138				continue
139			}
140			return false
141		case 'O':
142			// Alt+O is valid on its own; otherwise accept only application
143			// cursor sequences.
144			if i+2 >= len(data) {
145				continue
146			}
147			if !strings.ContainsRune("ABCDHF", rune(data[i+2])) {
148				return false
149			}
150			i += 2
151		default:
152			// The remaining form is Alt+one character. Its second byte may be
153			// one of the explicit control-key aliases, but no other control
154			// sequence or C1 byte is admitted. A second Escape must be checked
155			// again as a possible sequence introducer; otherwise ESC ESC ]...
156			// could hide an OSC response behind a valid Alt+Escape token.
157			next := data[i+1]
158			if next == 0x1b {
159				continue
160			}
161			if next < 0x20 && next != '\r' && next != '\t' || next >= 0x80 && next <= 0x9f {
162				return false
163			}
164			i++
165		}
166	}
167	return true
168}

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