v0.51.0 a9d674e

sanitize.go RawHistoryFolder

1package tui
2
3import (
4	"strings"
5	"unicode"
6	"unicode/utf8"
7
8	"github.com/mattn/go-runewidth"
9)
10
11// Tab stops used when expanding tabs. Code uses 4 columns so Go and
12// Makefiles stay compact; shell output keeps the terminal's 8 so tables
13// printed by commands such as ls or column stay aligned.
14const (
15	codeTabStop     = 4
16	terminalTabStop = 8
17)
18
19// Text from models, tools, and paste is untrusted terminal input. Layout
20// code measures rows with visibleWidth and pads box edges from that
21// measurement, so every such string must pass through sanitizeText or
22// sanitizeLine before layout. Tabs, carriage returns, escape sequences, and
23// other controls move the real cursor differently from what visibleWidth
24// counts, which misplaces box edges and leaves stale cells on screen.
25
26// splitLines splits s into rows, treating CRLF and lone CR as line breaks
27// so carriage-return progress output becomes separate rows.
28func splitLines(s string) []string {
29	if strings.IndexByte(s, '\r') >= 0 {
30		s = strings.ReplaceAll(s, "\r\n", "\n")
31		s = strings.ReplaceAll(s, "\r", "\n")
32	}
33	return strings.Split(s, "\n")
34}
35
36// sanitizeText returns s with every row passed through sanitizeLine.
37// Line breaks (LF, CRLF, CR) are kept as "\n".
38func sanitizeText(s string, tabStop int) string {
39	if isPlainText(s, true) {
40		return s
41	}
42	lines := splitLines(s)
43	for i, l := range lines {
44		lines[i] = sanitizeLine(l, tabStop)
45	}
46	return strings.Join(lines, "\n")
47}
48
49// sanitizeLine makes one row safe to measure and draw: tabs expand to
50// spaces at tabStop columns, line breaks become spaces, and escape
51// sequences, other control characters, and invalid UTF-8 are dropped.
52// Styling is applied by the renderer afterwards, so no incoming escape
53// sequence is kept.
54func sanitizeLine(s string, tabStop int) string {
55	if isPlainText(s, false) {
56		return s
57	}
58	var b strings.Builder
59	b.Grow(len(s))
60	col := 0
61	for i := 0; i < len(s); {
62		if s[i] == 0x1b {
63			i = skipEscapeSequence(s, i)
64			continue
65		}
66		r, size := utf8.DecodeRuneInString(s[i:])
67		i += size
68		switch {
69		case r == '\t':
70			n := tabStop - col%tabStop
71			b.WriteString(strings.Repeat(" ", n))
72			col += n
73		case r == '\n' || r == '\r':
74			b.WriteByte(' ')
75			col++
76		case r == utf8.RuneError && size == 1, r < 0x20, r >= 0x7f && r < 0xa0:
77			// Invalid byte, C0/C1 control, or DEL: not printable.
78		case unicode.Is(unicode.Cf, r):
79			// Invisible format characters (bidi marks, joiners, prepended
80			// marks). Terminals disagree on how they join neighbouring
81			// cells, so their drawn width cannot be predicted.
82		default:
83			b.WriteRune(r)
84			col += runewidth.RuneWidth(r)
85		}
86	}
87	return b.String()
88}
89
90// isPlainText reports whether s is printable ASCII (plus LF when
91// allowNewline), the common case that needs no rewriting.
92func isPlainText(s string, allowNewline bool) bool {
93	for i := 0; i < len(s); i++ {
94		c := s[i]
95		if c == '\n' && allowNewline {
96			continue
97		}
98		if c < 0x20 || c >= 0x7f {
99			return false
100		}
101	}
102	return true
103}
104
105// skipEscapeSequence returns the index just past the escape sequence
106// starting at s[i].
107func skipEscapeSequence(s string, i int) int {
108	if i >= len(s) || s[i] != 0x1b {
109		return i + 1
110	}
111	if i+1 >= len(s) {
112		return len(s)
113	}
114	switch s[i+1] {
115	case '[': // CSI: ESC [ ... final byte 0x40-0x7e
116		j := i + 2
117		for j < len(s) {
118			c := s[j]
119			j++
120			if c >= 0x40 && c <= 0x7e {
121				break
122			}
123		}
124		return j
125	case ']': // OSC: ESC ] ... BEL or ST
126		return skipStringEscape(s, i+2)
127	case 'P', '_', '^', 'X': // DCS/APC/PM/SOS: ESC P ... ST, etc.
128		return skipStringEscape(s, i+2)
129	default:
130		// Two-byte escape (cursor save/restore, charset select, etc.).
131		return i + 2
132	}
133}
134
135func skipStringEscape(s string, i int) int {
136	for i < len(s) {
137		if s[i] == 0x07 { // BEL
138			return i + 1
139		}
140		if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { // ST
141			return i + 2
142		}
143		i++
144	}
145	return len(s)
146}

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 1

Tags 476

↑↓ moveenter openesc close