v0.4.7 2ff82fa

highlight.go RawHistoryFolder

1package tui
2
3import (
4	"bytes"
5	"path/filepath"
6	"strings"
7	"sync"
8
9	"github.com/alecthomas/chroma/v2"
10	"github.com/alecthomas/chroma/v2/formatters"
11	"github.com/alecthomas/chroma/v2/lexers"
12	"github.com/alecthomas/chroma/v2/styles"
13)
14
15// HighlightCode syntax-colors src and returns the result split into lines,
16// ready for a line-based diff renderer. If no language is given or
17// chroma has no lexer for it, src is returned as-is (one entry per line).
18// Safe to call from multiple goroutines.
19//
20// Results are memoised by (lang, src) so repeated calls from the view
21// builder (which runs on every redraw) don't re-tokenise. Cache is
22// bounded and evicts oldest entries past its cap.
23func HighlightCode(src, lang string) []string {
24	return Dark.HighlightCode(src, lang)
25}
26
27// HighlightCode syntax-colors src using this theme's syntax palette.
28func (th Theme) HighlightCode(src, lang string) []string {
29	styleKey := th.syntaxKey()
30	if out, ok := highlightCache.lookup(styleKey, lang, src); ok {
31		return out
32	}
33	lexer := chooseLexer(lang)
34	if lexer == nil {
35		out := strings.Split(src, "\n")
36		highlightCache.store(styleKey, lang, src, out)
37		return out
38	}
39	style := th.chromaStyle()
40	formatter := formatters.Get("terminal256")
41	if formatter == nil {
42		return strings.Split(src, "\n")
43	}
44
45	iterator, err := lexer.Tokenise(nil, src)
46	if err != nil {
47		out := strings.Split(src, "\n")
48		highlightCache.store(styleKey, lang, src, out)
49		return out
50	}
51	var buf bytes.Buffer
52	if err := formatter.Format(&buf, style, iterator); err != nil {
53		out := strings.Split(src, "\n")
54		highlightCache.store(styleKey, lang, src, out)
55		return out
56	}
57	out := strings.Split(strings.TrimRight(stripANSIBackgrounds(buf.String()), "\n"), "\n")
58	highlightCache.store(styleKey, lang, src, out)
59	return out
60}
61
62// highlightResultCache is a simple LRU-ish cache: when it exceeds its
63// capacity we drop half of the entries (the oldest, based on insertion
64// order). That's good enough since tool_result text doesn't change once
65// emitted, so cache hits are frequent and evictions rare.
66type highlightResultCache struct {
67	mu    sync.Mutex
68	max   int
69	data  map[string][]string
70	order []string
71}
72
73func (c *highlightResultCache) key(styleKey, lang, src string) string {
74	return styleKey + "\x00" + lang + "\x00" + src
75}
76
77func (c *highlightResultCache) lookup(styleKey, lang, src string) ([]string, bool) {
78	c.mu.Lock()
79	defer c.mu.Unlock()
80	v, ok := c.data[c.key(styleKey, lang, src)]
81	return v, ok
82}
83
84func (c *highlightResultCache) store(styleKey, lang, src string, out []string) {
85	c.mu.Lock()
86	defer c.mu.Unlock()
87	if c.data == nil {
88		c.data = make(map[string][]string)
89	}
90	k := c.key(styleKey, lang, src)
91	if _, ok := c.data[k]; !ok {
92		c.order = append(c.order, k)
93	}
94	c.data[k] = out
95	if c.max > 0 && len(c.order) > c.max {
96		// Evict the oldest half so we amortise the cost.
97		cut := len(c.order) / 2
98		for _, old := range c.order[:cut] {
99			delete(c.data, old)
100		}
101		c.order = append([]string(nil), c.order[cut:]...)
102	}
103}
104
105var highlightCache = &highlightResultCache{max: 512}
106
107// LanguageFromPath maps file extensions to chroma lexer names.
108func LanguageFromPath(p string) string {
109	ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(p)), ".")
110	base := strings.ToLower(filepath.Base(p))
111	if lang := filenameLang[base]; lang != "" {
112		return lang
113	}
114	return extLang[ext]
115}
116
117// chooseLexer picks the best lexer for a language hint; falls back to
118// nil (no highlighting) if the hint is empty or unknown.
119// stripANSIBackgrounds removes SGR background-color attributes emitted by
120// terminal syntax formatters while preserving foreground colors and styles.
121// Chroma's inherited styles can assign black backgrounds to a few tokens
122// (notably punctuation/error-ish spans), which looks like random black blocks
123// inside zot's dark-gray tool boxes. Foreground color is enough for code.
124//
125// Extended color parameters ("38;5;N", "38;2;R;G;B" and their "48;..."
126// background twins) are consumed as whole units. The color index of an
127// extended foreground is a value, not a standalone ANSI code: stripping a
128// "45" or "101" out of "\x1b[38;5;45m" leaves the malformed "\x1b[38;5m",
129// whose leftover "5" terminals read as blink (SGR 5), so the token loses its
130// color and blinks. A truncated extended color cannot be rendered anyway,
131// so it is dropped rather than forwarded.
132func stripANSIBackgrounds(s string) string {
133	var out strings.Builder
134	out.Grow(len(s))
135	for i := 0; i < len(s); {
136		if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
137			out.WriteByte(s[i])
138			i++
139			continue
140		}
141		j := i + 2
142		for j < len(s) && s[j] != 'm' {
143			j++
144		}
145		if j >= len(s) {
146			out.WriteString(s[i:])
147			break
148		}
149		params := strings.Split(s[i+2:j], ";")
150		kept := make([]string, 0, len(params))
151		for p := 0; p < len(params); p++ {
152			param := params[p]
153			switch param {
154			case "38", "48":
155				span, complete := extendedColorSpan(params, p)
156				if complete && param == "38" {
157					kept = append(kept, span...)
158				}
159				// Consume the whole unit either way: a truncated
160				// "38;5" must not leave its "5" to be read as blink.
161				p += len(span) - 1
162				continue
163			case "49":
164				continue
165			}
166			// 40-47 and 100-107 are ANSI background colors. Bare codes only:
167			// extended colors were consumed above, so a color index in those
168			// ranges cannot be mistaken for one here.
169			if len(param) == 2 && param[0] == '4' && param[1] >= '0' && param[1] <= '7' {
170				continue
171			}
172			if len(param) == 3 && param[0] == '1' && param[1] == '0' && param[2] >= '0' && param[2] <= '7' {
173				continue
174			}
175			kept = append(kept, param)
176		}
177		if len(kept) > 0 {
178			out.WriteString("\x1b[")
179			out.WriteString(strings.Join(kept, ";"))
180			out.WriteByte('m')
181		}
182		i = j + 1
183	}
184	return out.String()
185}
186
187// extendedColorSpan returns the parameter run that belongs to the extended
188// color starting at params[i] ("38" or "48"): "5;N" or "2;R;G;B". complete
189// reports whether the run can be rendered; a truncated run is still returned
190// so its parameters are consumed instead of being reprocessed as standalone
191// codes (a bare "5" would mean blink).
192func extendedColorSpan(params []string, i int) (span []string, complete bool) {
193	if i+1 >= len(params) {
194		return params[i : i+1], false
195	}
196	switch params[i+1] {
197	case "5":
198		if i+2 >= len(params) {
199			return params[i : i+2], false
200		}
201		return params[i : i+3], true
202	case "2":
203		if i+5 > len(params) {
204			return params[i:], false
205		}
206		return params[i : i+5], true
207	}
208	return params[i : i+1], false
209}
210
211func chooseLexer(lang string) chroma.Lexer {
212	if lang == "" {
213		return nil
214	}
215	if l := lexers.Get(lang); l != nil {
216		return chroma.Coalesce(l)
217	}
218	// Chroma accepts aliases; try common spellings.
219	if l := lexers.Get(strings.ToLower(lang)); l != nil {
220		return chroma.Coalesce(l)
221	}
222	return nil
223}
224
225// chromaStyle builds a syntax style from the theme. Falls back to
226// chroma's bundled fallback style if the configured base style or
227// overrides cannot be built.
228func (th Theme) chromaStyle() *chroma.Style {
229	base := th.SyntaxBaseStyle
230	if base == "" {
231		base = "monokai"
232	}
233	style := styles.Get(base)
234	if style == nil {
235		style = styles.Fallback
236	}
237	builder := style.Builder()
238	add := func(tt chroma.TokenType, entry string) {
239		if strings.TrimSpace(entry) != "" {
240			builder.Add(tt, entry)
241		}
242	}
243	syn := th.Syntax
244	add(chroma.Keyword, syn.Keyword)
245	add(chroma.KeywordConstant, syn.KeywordConstant)
246	add(chroma.KeywordDeclaration, syn.KeywordDeclaration)
247	add(chroma.KeywordNamespace, syn.KeywordNamespace)
248	add(chroma.KeywordReserved, syn.KeywordReserved)
249	add(chroma.KeywordType, syn.KeywordType)
250	add(chroma.NameBuiltin, syn.NameBuiltin)
251	add(chroma.NameFunction, syn.NameFunction)
252	add(chroma.NameClass, syn.NameClass)
253	add(chroma.NameDecorator, syn.NameDecorator)
254	add(chroma.LiteralString, syn.LiteralString)
255	add(chroma.LiteralStringEscape, syn.LiteralStringEscape)
256	add(chroma.LiteralNumber, syn.LiteralNumber)
257	add(chroma.Comment, syn.Comment)
258	add(chroma.CommentPreproc, syn.CommentPreproc)
259	add(chroma.Operator, syn.Operator)
260	add(chroma.Punctuation, syn.Punctuation)
261	add(chroma.Text, syn.Text)
262	builder.Add(chroma.Background, " bg:")
263
264	s, err := builder.Build()
265	if err != nil {
266		return styles.Fallback
267	}
268	return s
269}
270
271func (th Theme) syntaxKey() string {
272	syn := th.Syntax
273	return strings.Join([]string{
274		th.SyntaxBaseStyle,
275		syn.Keyword,
276		syn.KeywordConstant,
277		syn.KeywordDeclaration,
278		syn.KeywordNamespace,
279		syn.KeywordReserved,
280		syn.KeywordType,
281		syn.NameBuiltin,
282		syn.NameFunction,
283		syn.NameClass,
284		syn.NameDecorator,
285		syn.LiteralString,
286		syn.LiteralStringEscape,
287		syn.LiteralNumber,
288		syn.Comment,
289		syn.CommentPreproc,
290		syn.Operator,
291		syn.Punctuation,
292		syn.Text,
293	}, "\x1f")
294}
295
296// extLang maps file extensions to chroma lexer names. Only
297// extensions that chroma supports are included.
298var extLang = map[string]string{
299	"ts":         "typescript",
300	"tsx":        "tsx",
301	"js":         "javascript",
302	"jsx":        "jsx",
303	"mjs":        "javascript",
304	"cjs":        "javascript",
305	"py":         "python",
306	"rb":         "ruby",
307	"rs":         "rust",
308	"go":         "go",
309	"java":       "java",
310	"kt":         "kotlin",
311	"swift":      "swift",
312	"c":          "c",
313	"h":          "c",
314	"cpp":        "cpp",
315	"cc":         "cpp",
316	"cxx":        "cpp",
317	"hpp":        "cpp",
318	"cs":         "csharp",
319	"php":        "php",
320	"sh":         "bash",
321	"bash":       "bash",
322	"zsh":        "bash",
323	"fish":       "fish",
324	"ps1":        "powershell",
325	"sql":        "sql",
326	"html":       "html",
327	"htm":        "html",
328	"css":        "css",
329	"scss":       "scss",
330	"sass":       "sass",
331	"less":       "less",
332	"json":       "json",
333	"yaml":       "yaml",
334	"yml":        "yaml",
335	"toml":       "toml",
336	"xml":        "xml",
337	"md":         "markdown",
338	"markdown":   "markdown",
339	"dockerfile": "docker",
340	"makefile":   "makefile",
341	"lua":        "lua",
342	"perl":       "perl",
343	"pl":         "perl",
344	"proto":      "protobuf",
345	"tf":         "terraform",
346	"hcl":        "terraform",
347	"graphql":    "graphql",
348	"gql":        "graphql",
349	"vue":        "vue",
350	"svelte":     "svelte",
351	"r":          "r",
352	"jl":         "julia",
353	"ex":         "elixir",
354	"exs":        "elixir",
355	"scala":      "scala",
356	"nix":        "nix",
357}
358
359var filenameLang = map[string]string{
360	"dockerfile":     "docker",
361	"makefile":       "makefile",
362	"gnumakefile":    "makefile",
363	".gitconfig":     "ini",
364	".gitattributes": "ini",
365	"cargo.toml":     "toml",
366	"go.mod":         "go-module",
367	"go.sum":         "text",
368	"package.json":   "json",
369	"tsconfig.json":  "json",
370}

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