v0.26.0 7e66a81

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// chooseLexer picks the best lexer for a language hint; falls back to
255// nil (no highlighting) if the hint is empty or unknown.
256// stripANSIBackgrounds removes SGR background-color attributes emitted by
257// terminal syntax formatters while preserving foreground colors and styles.
258// Chroma's inherited styles can assign black backgrounds to a few tokens
259// (notably punctuation/error-ish spans), which looks like random black blocks
260// inside zut's dark-gray tool boxes. Foreground color is enough for code.
261func stripANSIBackgrounds(s string) string {
262	var out strings.Builder
263	out.Grow(len(s))
264	for i := 0; i < len(s); {
265		if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
266			out.WriteByte(s[i])
267			i++
268			continue
269		}
270		j := i + 2
271		for j < len(s) && s[j] != 'm' {
272			j++
273		}
274		if j >= len(s) {
275			out.WriteString(s[i:])
276			break
277		}
278		params := strings.Split(s[i+2:j], ";")
279		kept := make([]string, 0, len(params))
280		for p := 0; p < len(params); p++ {
281			param := params[p]
282			switch param {
283			case "48":
284				// 48;5;N or 48;2;R;G;B background color.
285				if p+1 < len(params) && params[p+1] == "5" {
286					p += 2
287					continue
288				}
289				if p+1 < len(params) && params[p+1] == "2" {
290					p += 4
291					continue
292				}
293				continue
294			case "49":
295				continue
296			}
297			// 40-47 and 100-107 are ANSI background colors.
298			if len(param) == 2 && param[0] == '4' && param[1] >= '0' && param[1] <= '7' {
299				continue
300			}
301			if len(param) == 3 && param[0] == '1' && param[1] == '0' && param[2] >= '0' && param[2] <= '7' {
302				continue
303			}
304			kept = append(kept, param)
305		}
306		if len(kept) > 0 {
307			out.WriteString("\x1b[")
308			out.WriteString(strings.Join(kept, ";"))
309			out.WriteByte('m')
310		}
311		i = j + 1
312	}
313	return out.String()
314}
315
316func chooseLexer(lang string) chroma.Lexer {
317	if lang == "" {
318		return nil
319	}
320	if l := lexers.Get(lang); l != nil {
321		return chroma.Coalesce(l)
322	}
323	// Chroma accepts aliases; try common spellings.
324	if l := lexers.Get(strings.ToLower(lang)); l != nil {
325		return chroma.Coalesce(l)
326	}
327	return nil
328}
329
330// chromaStyle builds a syntax style from the theme. Falls back to
331// chroma's bundled fallback style if the configured base style or
332// overrides cannot be built.
333func (th Theme) chromaStyle() *chroma.Style {
334	base := th.SyntaxBaseStyle
335	if base == "" {
336		base = "monokai"
337	}
338	style := styles.Get(base)
339	if style == nil {
340		style = styles.Fallback
341	}
342	builder := style.Builder()
343	add := func(tt chroma.TokenType, entry string) {
344		if strings.TrimSpace(entry) != "" {
345			builder.Add(tt, entry)
346		}
347	}
348	syn := th.Syntax
349	add(chroma.Keyword, syn.Keyword)
350	add(chroma.KeywordConstant, syn.KeywordConstant)
351	add(chroma.KeywordDeclaration, syn.KeywordDeclaration)
352	add(chroma.KeywordNamespace, syn.KeywordNamespace)
353	add(chroma.KeywordReserved, syn.KeywordReserved)
354	add(chroma.KeywordType, syn.KeywordType)
355	add(chroma.NameBuiltin, syn.NameBuiltin)
356	add(chroma.NameFunction, syn.NameFunction)
357	add(chroma.NameClass, syn.NameClass)
358	add(chroma.NameDecorator, syn.NameDecorator)
359	add(chroma.LiteralString, syn.LiteralString)
360	add(chroma.LiteralStringEscape, syn.LiteralStringEscape)
361	add(chroma.LiteralNumber, syn.LiteralNumber)
362	add(chroma.Comment, syn.Comment)
363	add(chroma.CommentPreproc, syn.CommentPreproc)
364	add(chroma.Operator, syn.Operator)
365	add(chroma.Punctuation, syn.Punctuation)
366	add(chroma.Text, syn.Text)
367	builder.Add(chroma.Background, " bg:")
368
369	s, err := builder.Build()
370	if err != nil {
371		return styles.Fallback
372	}
373	return s
374}
375
376func (th Theme) syntaxKey() string {
377	syn := th.Syntax
378	return strings.Join([]string{
379		th.terminalSyntaxKeyPart(),
380		th.SyntaxBaseStyle,
381		syn.Keyword,
382		syn.KeywordConstant,
383		syn.KeywordDeclaration,
384		syn.KeywordNamespace,
385		syn.KeywordReserved,
386		syn.KeywordType,
387		syn.NameBuiltin,
388		syn.NameFunction,
389		syn.NameClass,
390		syn.NameDecorator,
391		syn.LiteralString,
392		syn.LiteralStringEscape,
393		syn.LiteralNumber,
394		syn.Comment,
395		syn.CommentPreproc,
396		syn.Operator,
397		syn.Punctuation,
398		syn.Text,
399	}, "\x1f")
400}
401
402func (th Theme) terminalSyntaxKeyPart() string {
403	h := fnv64aInit
404	h = fnv64aWriteByte(h, byte(th.Terminal.ColorDepth()))
405	if th.UseTerminalPalette {
406		h = fnv64aWriteByte(h, 1)
407	} else {
408		h = fnv64aWriteByte(h, 0)
409	}
410	h = fnv64aWriteUint64(h, uint64(th.Terminal.PaletteKnown))
411	for _, color := range th.Terminal.Palette {
412		h = hashThemeColor(h, color)
413	}
414	for _, color := range []TerminalColor{
415		th.Terminal.Foreground,
416		th.Terminal.Background,
417		th.Accent,
418		th.Muted,
419		th.Tool,
420		th.Warning,
421		th.Error,
422	} {
423		h = hashThemeColor(h, color)
424	}
425	for _, value := range []bool{th.Terminal.HasForeground, th.Terminal.HasBackground, th.Terminal.TrueColor, th.Terminal.Light, th.Terminal.SchemeKnown} {
426		if value {
427			h = fnv64aWriteByte(h, 1)
428		} else {
429			h = fnv64aWriteByte(h, 0)
430		}
431	}
432	return strconv.FormatUint(h, 16)
433}
434
435// extLang maps file extensions to chroma lexer names. Only
436// extensions that chroma supports are included.
437var extLang = map[string]string{
438	"ts":         "typescript",
439	"tsx":        "tsx",
440	"js":         "javascript",
441	"jsx":        "jsx",
442	"mjs":        "javascript",
443	"cjs":        "javascript",
444	"py":         "python",
445	"rb":         "ruby",
446	"rs":         "rust",
447	"go":         "go",
448	"java":       "java",
449	"kt":         "kotlin",
450	"swift":      "swift",
451	"c":          "c",
452	"h":          "c",
453	"cpp":        "cpp",
454	"cc":         "cpp",
455	"cxx":        "cpp",
456	"hpp":        "cpp",
457	"cs":         "csharp",
458	"php":        "php",
459	"sh":         "bash",
460	"bash":       "bash",
461	"zsh":        "bash",
462	"fish":       "fish",
463	"ps1":        "powershell",
464	"sql":        "sql",
465	"html":       "html",
466	"htm":        "html",
467	"css":        "css",
468	"scss":       "scss",
469	"sass":       "sass",
470	"less":       "less",
471	"json":       "json",
472	"yaml":       "yaml",
473	"yml":        "yaml",
474	"toml":       "toml",
475	"xml":        "xml",
476	"md":         "markdown",
477	"markdown":   "markdown",
478	"dockerfile": "docker",
479	"makefile":   "makefile",
480	"lua":        "lua",
481	"perl":       "perl",
482	"pl":         "perl",
483	"proto":      "protobuf",
484	"tf":         "terraform",
485	"hcl":        "terraform",
486	"graphql":    "graphql",
487	"gql":        "graphql",
488	"vue":        "vue",
489	"svelte":     "svelte",
490	"r":          "r",
491	"jl":         "julia",
492	"ex":         "elixir",
493	"exs":        "elixir",
494	"scala":      "scala",
495	"nix":        "nix",
496}
497
498var filenameLang = map[string]string{
499	"dockerfile":     "docker",
500	"makefile":       "makefile",
501	"gnumakefile":    "makefile",
502	".gitconfig":     "ini",
503	".gitattributes": "ini",
504	"cargo.toml":     "toml",
505	"go.mod":         "go-module",
506	"go.sum":         "text",
507	"package.json":   "json",
508	"tsconfig.json":  "json",
509}

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