v0.4.3 2a19df1

highlight_test.go RawHistoryFolder

1package tui
2
3import (
4	"regexp"
5	"strings"
6	"testing"
7)
8
9// The stripper must treat "38;5;N" / "38;2;R;G;B" as one unit. Pulling the
10// color index out on its own (when it happens to look like an ANSI
11// background code, e.g. 45, 48, 49 or 101) leaves "\x1b[38;5m", whose leftover
12// "5" terminals read as blink, so the token loses its color and blinks.
13func TestStripANSIBackgroundsKeepsExtendedColors(t *testing.T) {
14	cases := []struct{ name, in, want string }{
15		{"256 fg in background range", "\x1b[38;5;45mstring\x1b[0m", "\x1b[38;5;45mstring\x1b[0m"},
16		{"256 fg index 48", "\x1b[38;5;48mx\x1b[0m", "\x1b[38;5;48mx\x1b[0m"},
17		{"256 fg index 49", "\x1b[38;5;49mx\x1b[0m", "\x1b[38;5;49mx\x1b[0m"},
18		{"256 fg bright range", "\x1b[38;5;101mcomment\x1b[0m", "\x1b[38;5;101mcomment\x1b[0m"},
19		{"256 fg index 107", "\x1b[38;5;107mx\x1b[0m", "\x1b[38;5;107mx\x1b[0m"},
20		{"256 fg index 41", "\x1b[38;5;41mx\x1b[0m", "\x1b[38;5;41mx\x1b[0m"},
21		{"bold plus 256 fg", "\x1b[1;38;5;45mstring\x1b[0m", "\x1b[1;38;5;45mstring\x1b[0m"},
22		{"truecolor fg", "\x1b[38;2;0;229;255mstring\x1b[0m", "\x1b[38;2;0;229;255mstring\x1b[0m"},
23		{"plain text", "hello", "hello"},
24		{"reset", "\x1b[0m", "\x1b[0m"},
25
26		{"256 bg dropped", "\x1b[48;5;45mbg\x1b[0m", "bg\x1b[0m"},
27		{"256 bg dropped, fg kept", "\x1b[38;5;45m\x1b[48;5;0mtext", "\x1b[38;5;45mtext"},
28		{"truecolor bg dropped", "\x1b[48;2;9;0;21mbg\x1b[0m", "bg\x1b[0m"},
29		{"default bg dropped", "\x1b[49mbg", "bg"},
30		{"ansi bg 41 dropped", "\x1b[41mbg", "bg"},
31		{"ansi bg 101 dropped", "\x1b[101mbg", "bg"},
32		{"truncated fg dropped", "\x1b[38;5mstring", "string"},
33		{"truncated bg dropped", "\x1b[48;5mstring", "string"},
34		{"truncated fg inside row", "\x1b[1m\x1b[38;5;109mfunc\x1b[0m\x1b[38;5m(", "\x1b[1m\x1b[38;5;109mfunc\x1b[0m("},
35	}
36	for _, c := range cases {
37		if got := stripANSIBackgrounds(c.in); got != c.want {
38			t.Errorf("%s:\n in   %q\n got  %q\n want %q", c.name, c.in, got, c.want)
39		}
40	}
41}
42
43// incompleteExtendedColor returns the first SGR sequence in s whose extended
44// color introducer ("38" or "48") is missing part of its parameter run, for
45// example "\x1b[38;5m", which terminals read as a color-less SGR 38 followed
46// by SGR 5 (blink). Validation here is independent of the production helper.
47func incompleteExtendedColor(s string) string {
48	numeric := func(p string) bool {
49		if p == "" {
50			return false
51		}
52		for _, r := range p {
53			if r < '0' || r > '9' {
54				return false
55			}
56		}
57		return true
58	}
59	for i := 0; i < len(s); {
60		if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
61			i++
62			continue
63		}
64		j := i + 2
65		for j < len(s) && !(s[j] >= 0x40 && s[j] <= 0x7e) {
66			j++
67		}
68		if j >= len(s) {
69			return s[i:]
70		}
71		if s[j] == 'm' {
72			params := strings.Split(s[i+2:j], ";")
73			for p := 0; p < len(params); p++ {
74				if params[p] != "38" && params[p] != "48" {
75					continue
76				}
77				need := 0
78				switch {
79				case p+1 < len(params) && params[p+1] == "5":
80					need = 2
81				case p+1 < len(params) && params[p+1] == "2":
82					need = 4
83				default:
84					return s[i : j+1]
85				}
86				if p+need >= len(params) || !numeric(params[p+2]) {
87					return s[i : j+1]
88				}
89				if need == 4 {
90					for k := p + 2; k <= p+4 && k < len(params); k++ {
91						if !numeric(params[k]) {
92							return s[i : j+1]
93						}
94					}
95				}
96				p += need
97			}
98		}
99		i = j + 1
100	}
101	return ""
102}
103
104// A theme color that chroma maps to xterm index 45 ("#00e5ff") must survive
105// highlighting with a renderable sequence: a complete "\x1b[38;5;45m", never
106// the malformed "\x1b[38;5m".
107func TestHighlightCodeExtendedColorIsWellFormed(t *testing.T) {
108	th := Dark
109	th.Syntax = SyntaxTheme{
110		KeywordType: "#00e5ff",
111		NameBuiltin: "#00e5ff",
112	}
113	rows := th.HighlightCode("func splitFrontmatter(raw string) (string, string) {\n", "go")
114	joined := strings.Join(rows, "\n")
115	if strings.Contains(joined, "\x1b[38;5m") || strings.Contains(joined, "\x1b[48;5m") {
116		t.Fatalf("highlighting emitted a malformed extended color: %q", joined)
117	}
118	if bad := incompleteExtendedColor(joined); bad != "" {
119		t.Fatalf("highlighting emitted an incomplete extended color %q: %q", bad, joined)
120	}
121	// The type keyword keeps a full 256-color foreground.
122	idx := strings.Index(joined, "string")
123	if idx < 0 {
124		t.Fatalf("type keyword missing from highlighted output: %q", joined)
125	}
126	prefix := joined[:idx]
127	esc := prefix[strings.LastIndex(prefix, "\x1b["):]
128	if !regexp.MustCompile(`^\x1b\[(?:[0-9]+;)*38;5;[0-9]+m$`).MatchString(esc) {
129		t.Fatalf("type keyword lost its color, got %q in %q", esc, joined)
130	}
131}
132
133// Every syntax color in the built-in palettes must come out of the
134// highlighting path as a complete SGR sequence.
135func TestHighlightCodeAllPalettesWellFormed(t *testing.T) {
136	samples := map[string]string{
137		"go":       "package main\n\n// note\nfunc main() { s := \"x\"; _ = len(s) }\n",
138		"python":   "def f(x: int) -> str:\n    # note\n    return str(x)\n",
139		"markdown": "# Title\n\nSome *emphasis* and **bold** and `code`.\n",
140	}
141	for _, th := range []Theme{Dark, Light} {
142		for lang, src := range samples {
143			joined := strings.Join(th.HighlightCode(src, lang), "\n")
144			if bad := incompleteExtendedColor(joined); bad != "" {
145				t.Errorf("%s emitted an incomplete extended color %q: %q", lang, bad, joined)
146			}
147		}
148	}
149}

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 477

↑↓ moveenter openesc close