main 4509ec7

highlight.go RawHistoryFolder

1package tui
2
3import (
4	"bytes"
5	"path/filepath"
6	"strconv"
7	"strings"
8	"sync"
9
10	"github.com/alecthomas/chroma/v2"
11	"github.com/alecthomas/chroma/v2/formatters"
12	"github.com/alecthomas/chroma/v2/lexers"
13	"github.com/alecthomas/chroma/v2/styles"
14)
15
16// HighlightCode syntax-colors src and returns the result split into lines,
17// ready for a line-based diff renderer. If no language is given or
18// chroma has no lexer for it, src is returned as-is (one entry per line).
19// Safe to call from multiple goroutines.
20//
21// Results are memoised by (lang, src) so repeated calls from the view
22// builder (which runs on every redraw) don't re-tokenise. Cache is
23// bounded and evicts oldest entries past its cap.
24func HighlightCode(src, lang string) []string {
25	return Dark.HighlightCode(src, lang)
26}
27
28// HighlightCode syntax-colors src using this theme's syntax palette.
29func (th Theme) HighlightCode(src, lang string) []string {
30	styleKey := th.syntaxKey()
31	if out, ok := highlightCache.lookup(styleKey, lang, src); ok {
32		return out
33	}
34	lexer := chooseLexer(lang)
35	if lexer == nil {
36		out := strings.Split(src, "\n")
37		highlightCache.store(styleKey, lang, src, out)
38		return out
39	}
40	iterator, err := lexer.Tokenise(nil, src)
41	if err != nil {
42		out := strings.Split(src, "\n")
43		highlightCache.store(styleKey, lang, src, out)
44		return out
45	}
46	if th.UseTerminalPalette {
47		out := formatTerminalTokens(th, iterator)
48		highlightCache.store(styleKey, lang, src, out)
49		return out
50	}
51	style := th.chromaStyle()
52	formatterName := "terminal256"
53	if th.Terminal.ColorDepth() == ColorDepthTrueColor {
54		formatterName = "terminal16m"
55	}
56	formatter := formatters.Get(formatterName)
57	if formatter == nil {
58		return strings.Split(src, "\n")
59	}
60	var buf bytes.Buffer
61	if err := formatter.Format(&buf, style, iterator); err != nil {
62		out := strings.Split(src, "\n")
63		highlightCache.store(styleKey, lang, src, out)
64		return out
65	}
66	out := strings.Split(strings.TrimRight(stripANSIBackgrounds(buf.String()), "\n"), "\n")
67	highlightCache.store(styleKey, lang, src, out)
68	return out
69}
70
71// formatTerminalTokens keeps Chroma's lexer but owns terminal formatting for
72// adaptive themes. Chroma's terminal formatters map duplicate ANSI RGB values
73// through the xterm cube, losing an intended palette slot such as bright blue.
74func formatTerminalTokens(th Theme, iterator chroma.Iterator) []string {
75	var out strings.Builder
76	for token := iterator(); token != chroma.EOF; token = iterator() {
77		color, bold, italic := terminalTokenStyle(th, token.Type)
78		prefix := th.fgPrefix(color)
79		if bold {
80			prefix += "\x1b[1m"
81		}
82		if italic {
83			prefix += "\x1b[3m"
84		}
85		// Coalesced Chroma tokens can span rows. Close the style before each
86		// newline and reopen it for the next segment so every rendered row is
87		// self-contained and cannot bleed into padding or following text.
88		for index, segment := range strings.Split(token.Value, "\n") {
89			if index > 0 {
90				out.WriteByte('\n')
91			}
92			if segment == "" {
93				continue
94			}
95			out.WriteString(prefix)
96			out.WriteString(segment)
97			out.WriteString(reset)
98		}
99	}
100	return strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
101}
102
103func terminalTokenStyle(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {
104	color, bold, italic := terminalTokenDefault(th, typ)
105	if entry, ok := terminalSyntaxEntry(th, typ); ok {
106		if entry.Colour.IsSet() {
107			color = ColorRGB(int(entry.Colour.Red()), int(entry.Colour.Green()), int(entry.Colour.Blue()))
108		}
109		if entry.Bold != chroma.Pass {
110			bold = entry.Bold == chroma.Yes
111		}
112		if entry.Italic != chroma.Pass {
113			italic = entry.Italic == chroma.Yes
114		}
115	}
116	return color, bold, italic
117}
118
119func terminalTokenDefault(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {
120	switch {
121	case typ.InCategory(chroma.Comment):
122		return th.Muted, false, true
123	case typ == chroma.LiteralStringEscape || typ == chroma.Error:
124		return th.Error, false, false
125	case typ.InSubCategory(chroma.LiteralString):
126		return th.Tool, false, false
127	case typ.InSubCategory(chroma.LiteralNumber):
128		return th.Warning, false, false
129	case typ.InCategory(chroma.Keyword):
130		return th.Accent, true, false
131	case typ.InCategory(chroma.Name):
132		return TerminalPaletteSlot(14), false, false
133	default:
134		return TerminalDefault(), false, false
135	}
136}
137
138func terminalSyntaxEntry(th Theme, typ chroma.TokenType) (chroma.StyleEntry, bool) {
139	if entry := terminalSyntaxOverride(th.Syntax, typ); entry != "" {
140		parsed, err := chroma.ParseStyleEntry(entry)
141		return parsed, err == nil
142	}
143	if th.SyntaxBaseStyle != "" {
144		return styles.Get(th.SyntaxBaseStyle).Get(typ), true
145	}
146	return chroma.StyleEntry{}, false
147}
148
149func terminalSyntaxOverride(s SyntaxTheme, typ chroma.TokenType) string {
150	switch {
151	case typ == chroma.KeywordConstant:
152		return firstSyntaxOverride(s.KeywordConstant, s.Keyword)
153	case typ == chroma.KeywordDeclaration:
154		return firstSyntaxOverride(s.KeywordDeclaration, s.Keyword)
155	case typ == chroma.KeywordNamespace:
156		return firstSyntaxOverride(s.KeywordNamespace, s.Keyword)
157	case typ == chroma.KeywordReserved:
158		return firstSyntaxOverride(s.KeywordReserved, s.Keyword)
159	case typ == chroma.KeywordType:
160		return firstSyntaxOverride(s.KeywordType, s.Keyword)
161	case typ.InCategory(chroma.Keyword):
162		return s.Keyword
163	case typ == chroma.NameBuiltin:
164		return firstSyntaxOverride(s.NameBuiltin, s.NameFunction)
165	case typ == chroma.NameFunction:
166		return s.NameFunction
167	case typ == chroma.NameClass:
168		return firstSyntaxOverride(s.NameClass, s.NameFunction)
169	case typ == chroma.NameDecorator:
170		return firstSyntaxOverride(s.NameDecorator, s.NameFunction)
171	case typ == chroma.LiteralStringEscape:
172		return firstSyntaxOverride(s.LiteralStringEscape, s.LiteralString)
173	case typ.InSubCategory(chroma.LiteralString):
174		return s.LiteralString
175	case typ.InSubCategory(chroma.LiteralNumber):
176		return s.LiteralNumber
177	case typ == chroma.CommentPreproc:
178		return firstSyntaxOverride(s.CommentPreproc, s.Comment)
179	case typ.InCategory(chroma.Comment):
180		return s.Comment
181	case typ.InCategory(chroma.Operator):
182		return s.Operator
183	case typ.InCategory(chroma.Punctuation):
184		return s.Punctuation
185	default:
186		return s.Text
187	}
188}
189
190func firstSyntaxOverride(entries ...string) string {
191	for _, entry := range entries {
192		if entry != "" {
193			return entry
194		}
195	}
196	return ""
197}
198
199// highlightResultCache is a simple LRU-ish cache: when it exceeds its
200// capacity we drop half of the entries (the oldest, based on insertion
201// order). That's good enough since tool_result text doesn't change once
202// emitted, so cache hits are frequent and evictions rare.
203type highlightResultCache struct {
204	mu    sync.Mutex
205	max   int
206	data  map[string][]string
207	order []string
208}
209
210func (c *highlightResultCache) key(styleKey, lang, src string) string {
211	return styleKey + "\x00" + lang + "\x00" + src
212}
213
214func (c *highlightResultCache) lookup(styleKey, lang, src string) ([]string, bool) {
215	c.mu.Lock()
216	defer c.mu.Unlock()
217	v, ok := c.data[c.key(styleKey, lang, src)]
218	return v, ok
219}
220
221func (c *highlightResultCache) store(styleKey, lang, src string, out []string) {
222	c.mu.Lock()
223	defer c.mu.Unlock()
224	if c.data == nil {
225		c.data = make(map[string][]string)
226	}
227	k := c.key(styleKey, lang, src)
228	if _, ok := c.data[k]; !ok {
229		c.order = append(c.order, k)
230	}
231	c.data[k] = out
232	if c.max > 0 && len(c.order) > c.max {
233		// Evict the oldest half so we amortise the cost.
234		cut := len(c.order) / 2
235		for _, old := range c.order[:cut] {
236			delete(c.data, old)
237		}
238		c.order = append([]string(nil), c.order[cut:]...)
239	}
240}
241
242var highlightCache = &highlightResultCache{max: 512}
243
244// LanguageFromPath maps file extensions to chroma lexer names.
245func LanguageFromPath(p string) string {
246	ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(p)), ".")
247	base := strings.ToLower(filepath.Base(p))
248	if lang := filenameLang[base]; lang != "" {
249		return lang
250	}
251	return extLang[ext]
252}
253
254// stripANSIBackgrounds removes SGR background-color attributes emitted by
255// terminal syntax formatters while preserving foreground colors and styles.
256// Chroma's inherited styles can assign black backgrounds to a few tokens
257// (notably punctuation/error-ish spans), which looks like random black blocks
258// inside zut's dark-gray tool boxes. Foreground color is enough for code.
259//
260// Extended color parameters ("38;5;N", "38;2;R;G;B" and their "48;..."
261// background twins) are consumed as whole units. The color index of an
262// extended foreground is a value, not a standalone ANSI code: stripping a
263// "45" or "101" out of "\x1b[38;5;45m" leaves the malformed "\x1b[38;5m",
264// whose leftover "5" terminals read as blink (SGR 5). A truncated extended
265// color cannot be rendered anyway, so it is dropped rather than forwarded.
266func stripANSIBackgrounds(s string) string {
267	var out strings.Builder
268	out.Grow(len(s))
269	for i := 0; i < len(s); {
270		if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
271			out.WriteByte(s[i])
272			i++
273			continue
274		}
275		j := i + 2
276		for j < len(s) && s[j] != 'm' {
277			j++
278		}
279		if j >= len(s) {
280			out.WriteString(s[i:])
281			break
282		}
283		params := strings.Split(s[i+2:j], ";")
284		kept := make([]string, 0, len(params))
285		for p := 0; p < len(params); p++ {
286			param := params[p]
287			switch param {
288			case "38", "48":
289				span, complete := extendedColorSpan(params, p)
290				if complete && param == "38" {
291					kept = append(kept, span...)
292				}
293				// Consume the whole unit either way: a truncated
294				// "38;5" must not leave its "5" to be read as blink.
295				p += len(span) - 1
296				continue
297			case "49":
298				continue
299			}
300			// 40-47 and 100-107 are ANSI background colors.
301			if len(param) == 2 && param[0] == '4' && param[1] >= '0' && param[1] <= '7' {
302				continue
303			}
304			if len(param) == 3 && param[0] == '1' && param[1] == '0' && param[2] >= '0' && param[2] <= '7' {
305				continue
306			}
307			kept = append(kept, param)
308		}
309		if len(kept) > 0 {
310			out.WriteString("\x1b[")
311			out.WriteString(strings.Join(kept, ";"))
312			out.WriteByte('m')
313		}
314		i = j + 1
315	}
316	return out.String()
317}
318
319// extendedColorSpan returns the parameter run that belongs to the extended
320// color starting at params[i] ("38" or "48"): "5;N" or "2;R;G;B". complete
321// reports whether the run can be rendered; a truncated run is still returned
322// so its parameters are consumed instead of being reprocessed as standalone
323// codes (a bare "5" would mean blink).
324func extendedColorSpan(params []string, i int) (span []string, complete bool) {
325	if i+1 >= len(params) {
326		return params[i : i+1], false
327	}
328	switch params[i+1] {
329	case "5":
330		if i+2 >= len(params) {
331			return params[i : i+2], false
332		}
333		return params[i : i+3], true
334	case "2":
335		if i+5 > len(params) {
336			return params[i:], false
337		}
338		return params[i : i+5], true
339	}
340	return params[i : i+1], false
341}
342
343// chooseLexer picks the best lexer for a language hint; falls back to
344// nil (no highlighting) if the hint is empty or unknown.
345func chooseLexer(lang string) chroma.Lexer {
346	if lang == "" {
347		return nil
348	}
349	if l := lexers.Get(lang); l != nil {
350		return chroma.Coalesce(l)
351	}
352	// Chroma accepts aliases; try common spellings.
353	if l := lexers.Get(strings.ToLower(lang)); l != nil {
354		return chroma.Coalesce(l)
355	}
356	return nil
357}
358
359// chromaStyle builds a syntax style from the theme. Falls back to
360// chroma's bundled fallback style if the configured base style or
361// overrides cannot be built.
362func (th Theme) chromaStyle() *chroma.Style {
363	base := th.SyntaxBaseStyle
364	if base == "" {
365		base = "monokai"
366	}
367	style := styles.Get(base)
368	if style == nil {
369		style = styles.Fallback
370	}
371	builder := style.Builder()
372	add := func(tt chroma.TokenType, entry string) {
373		if strings.TrimSpace(entry) != "" {
374			builder.Add(tt, entry)
375		}
376	}
377	syn := th.Syntax
378	add(chroma.Keyword, syn.Keyword)
379	add(chroma.KeywordConstant, syn.KeywordConstant)
380	add(chroma.KeywordDeclaration, syn.KeywordDeclaration)
381	add(chroma.KeywordNamespace, syn.KeywordNamespace)
382	add(chroma.KeywordReserved, syn.KeywordReserved)
383	add(chroma.KeywordType, syn.KeywordType)
384	add(chroma.NameBuiltin, syn.NameBuiltin)
385	add(chroma.NameFunction, syn.NameFunction)
386	add(chroma.NameClass, syn.NameClass)
387	add(chroma.NameDecorator, syn.NameDecorator)
388	add(chroma.LiteralString, syn.LiteralString)
389	add(chroma.LiteralStringEscape, syn.LiteralStringEscape)
390	add(chroma.LiteralNumber, syn.LiteralNumber)
391	add(chroma.Comment, syn.Comment)
392	add(chroma.CommentPreproc, syn.CommentPreproc)
393	add(chroma.Operator, syn.Operator)
394	add(chroma.Punctuation, syn.Punctuation)
395	add(chroma.Text, syn.Text)
396	builder.Add(chroma.Background, " bg:")
397
398	s, err := builder.Build()
399	if err != nil {
400		return styles.Fallback
401	}
402	return s
403}
404
405func (th Theme) syntaxKey() string {
406	syn := th.Syntax
407	return strings.Join([]string{
408		th.terminalSyntaxKeyPart(),
409		th.SyntaxBaseStyle,
410		syn.Keyword,
411		syn.KeywordConstant,
412		syn.KeywordDeclaration,
413		syn.KeywordNamespace,
414		syn.KeywordReserved,
415		syn.KeywordType,
416		syn.NameBuiltin,
417		syn.NameFunction,
418		syn.NameClass,
419		syn.NameDecorator,
420		syn.LiteralString,
421		syn.LiteralStringEscape,
422		syn.LiteralNumber,
423		syn.Comment,
424		syn.CommentPreproc,
425		syn.Operator,
426		syn.Punctuation,
427		syn.Text,
428	}, "\x1f")
429}
430
431func (th Theme) terminalSyntaxKeyPart() string {
432	h := fnv64aInit
433	h = fnv64aWriteByte(h, byte(th.Terminal.ColorDepth()))
434	if th.UseTerminalPalette {
435		h = fnv64aWriteByte(h, 1)
436	} else {
437		h = fnv64aWriteByte(h, 0)
438	}
439	h = fnv64aWriteUint64(h, uint64(th.Terminal.PaletteKnown))
440	for _, color := range th.Terminal.Palette {
441		h = hashThemeColor(h, color)
442	}
443	for _, color := range []TerminalColor{
444		th.Terminal.Foreground,
445		th.Terminal.Background,
446		th.Accent,
447		th.Muted,
448		th.Tool,
449		th.Warning,
450		th.Error,
451	} {
452		h = hashThemeColor(h, color)
453	}
454	for _, value := range []bool{th.Terminal.HasForeground, th.Terminal.HasBackground, th.Terminal.TrueColor, th.Terminal.Light, th.Terminal.SchemeKnown} {
455		if value {
456			h = fnv64aWriteByte(h, 1)
457		} else {
458			h = fnv64aWriteByte(h, 0)
459		}
460	}
461	return strconv.FormatUint(h, 16)
462}
463
464// extLang maps file extensions to chroma lexer names. Only
465// extensions that chroma supports are included.
466var extLang = map[string]string{
467	"ts":         "typescript",
468	"tsx":        "tsx",
469	"js":         "javascript",
470	"jsx":        "jsx",
471	"mjs":        "javascript",
472	"cjs":        "javascript",
473	"py":         "python",
474	"rb":         "ruby",
475	"rs":         "rust",
476	"go":         "go",
477	"java":       "java",
478	"kt":         "kotlin",
479	"swift":      "swift",
480	"c":          "c",
481	"h":          "c",
482	"cpp":        "cpp",
483	"cc":         "cpp",
484	"cxx":        "cpp",
485	"hpp":        "cpp",
486	"cs":         "csharp",
487	"php":        "php",
488	"sh":         "bash",
489	"bash":       "bash",
490	"zsh":        "bash",
491	"fish":       "fish",
492	"ps1":        "powershell",
493	"sql":        "sql",
494	"html":       "html",
495	"htm":        "html",
496	"css":        "css",
497	"scss":       "scss",
498	"sass":       "sass",
499	"less":       "less",
500	"json":       "json",
501	"yaml":       "yaml",
502	"yml":        "yaml",
503	"toml":       "toml",
504	"xml":        "xml",
505	"md":         "markdown",
506	"markdown":   "markdown",
507	"dockerfile": "docker",
508	"makefile":   "makefile",
509	"lua":        "lua",
510	"perl":       "perl",
511	"pl":         "perl",
512	"proto":      "protobuf",
513	"tf":         "terraform",
514	"hcl":        "terraform",
515	"graphql":    "graphql",
516	"gql":        "graphql",
517	"vue":        "vue",
518	"svelte":     "svelte",
519	"r":          "r",
520	"jl":         "julia",
521	"ex":         "elixir",
522	"exs":        "elixir",
523	"scala":      "scala",
524	"nix":        "nix",
525}
526
527var filenameLang = map[string]string{
528	"dockerfile":     "docker",
529	"makefile":       "makefile",
530	"gnumakefile":    "makefile",
531	".gitconfig":     "ini",
532	".gitattributes": "ini",
533	"cargo.toml":     "toml",
534	"go.mod":         "go-module",
535	"go.sum":         "text",
536	"package.json":   "json",
537	"tsconfig.json":  "json",
538}

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