v0.11.2 619aabb

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	formatterName := "terminal256"
41	if th.Terminal.TrueColor {
42		formatterName = "terminal16m"
43	}
44	formatter := formatters.Get(formatterName)
45	if formatter == nil {
46		return strings.Split(src, "\n")
47	}
48
49	iterator, err := lexer.Tokenise(nil, src)
50	if err != nil {
51		out := strings.Split(src, "\n")
52		highlightCache.store(styleKey, lang, src, out)
53		return out
54	}
55	var buf bytes.Buffer
56	if err := formatter.Format(&buf, style, iterator); err != nil {
57		out := strings.Split(src, "\n")
58		highlightCache.store(styleKey, lang, src, out)
59		return out
60	}
61	out := strings.Split(strings.TrimRight(stripANSIBackgrounds(buf.String()), "\n"), "\n")
62	highlightCache.store(styleKey, lang, src, out)
63	return out
64}
65
66// highlightResultCache is a simple LRU-ish cache: when it exceeds its
67// capacity we drop half of the entries (the oldest, based on insertion
68// order). That's good enough since tool_result text doesn't change once
69// emitted, so cache hits are frequent and evictions rare.
70type highlightResultCache struct {
71	mu    sync.Mutex
72	max   int
73	data  map[string][]string
74	order []string
75}
76
77func (c *highlightResultCache) key(styleKey, lang, src string) string {
78	return styleKey + "\x00" + lang + "\x00" + src
79}
80
81func (c *highlightResultCache) lookup(styleKey, lang, src string) ([]string, bool) {
82	c.mu.Lock()
83	defer c.mu.Unlock()
84	v, ok := c.data[c.key(styleKey, lang, src)]
85	return v, ok
86}
87
88func (c *highlightResultCache) store(styleKey, lang, src string, out []string) {
89	c.mu.Lock()
90	defer c.mu.Unlock()
91	if c.data == nil {
92		c.data = make(map[string][]string)
93	}
94	k := c.key(styleKey, lang, src)
95	if _, ok := c.data[k]; !ok {
96		c.order = append(c.order, k)
97	}
98	c.data[k] = out
99	if c.max > 0 && len(c.order) > c.max {
100		// Evict the oldest half so we amortise the cost.
101		cut := len(c.order) / 2
102		for _, old := range c.order[:cut] {
103			delete(c.data, old)
104		}
105		c.order = append([]string(nil), c.order[cut:]...)
106	}
107}
108
109var highlightCache = &highlightResultCache{max: 512}
110
111// LanguageFromPath maps file extensions to chroma lexer names.
112func LanguageFromPath(p string) string {
113	ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(p)), ".")
114	base := strings.ToLower(filepath.Base(p))
115	if lang := filenameLang[base]; lang != "" {
116		return lang
117	}
118	return extLang[ext]
119}
120
121// chooseLexer picks the best lexer for a language hint; falls back to
122// nil (no highlighting) if the hint is empty or unknown.
123// stripANSIBackgrounds removes SGR background-color attributes emitted by
124// terminal syntax formatters while preserving foreground colors and styles.
125// Chroma's inherited styles can assign black backgrounds to a few tokens
126// (notably punctuation/error-ish spans), which looks like random black blocks
127// inside zut's dark-gray tool boxes. Foreground color is enough for code.
128func stripANSIBackgrounds(s string) string {
129	var out strings.Builder
130	out.Grow(len(s))
131	for i := 0; i < len(s); {
132		if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
133			out.WriteByte(s[i])
134			i++
135			continue
136		}
137		j := i + 2
138		for j < len(s) && s[j] != 'm' {
139			j++
140		}
141		if j >= len(s) {
142			out.WriteString(s[i:])
143			break
144		}
145		params := strings.Split(s[i+2:j], ";")
146		kept := make([]string, 0, len(params))
147		for p := 0; p < len(params); p++ {
148			param := params[p]
149			switch param {
150			case "48":
151				// 48;5;N or 48;2;R;G;B background color.
152				if p+1 < len(params) && params[p+1] == "5" {
153					p += 2
154					continue
155				}
156				if p+1 < len(params) && params[p+1] == "2" {
157					p += 4
158					continue
159				}
160				continue
161			case "49":
162				continue
163			}
164			// 40-47 and 100-107 are ANSI background colors.
165			if len(param) == 2 && param[0] == '4' && param[1] >= '0' && param[1] <= '7' {
166				continue
167			}
168			if len(param) == 3 && param[0] == '1' && param[1] == '0' && param[2] >= '0' && param[2] <= '7' {
169				continue
170			}
171			kept = append(kept, param)
172		}
173		if len(kept) > 0 {
174			out.WriteString("\x1b[")
175			out.WriteString(strings.Join(kept, ";"))
176			out.WriteByte('m')
177		}
178		i = j + 1
179	}
180	return out.String()
181}
182
183func chooseLexer(lang string) chroma.Lexer {
184	if lang == "" {
185		return nil
186	}
187	if l := lexers.Get(lang); l != nil {
188		return chroma.Coalesce(l)
189	}
190	// Chroma accepts aliases; try common spellings.
191	if l := lexers.Get(strings.ToLower(lang)); l != nil {
192		return chroma.Coalesce(l)
193	}
194	return nil
195}
196
197// chromaStyle builds a syntax style from the theme. Falls back to
198// chroma's bundled fallback style if the configured base style or
199// overrides cannot be built.
200func (th Theme) chromaStyle() *chroma.Style {
201	base := th.SyntaxBaseStyle
202	if base == "" {
203		base = "monokai"
204	}
205	style := styles.Get(base)
206	if style == nil {
207		style = styles.Fallback
208	}
209	builder := style.Builder()
210	add := func(tt chroma.TokenType, entry string) {
211		if strings.TrimSpace(entry) != "" {
212			builder.Add(tt, entry)
213		}
214	}
215	syn := th.Syntax
216	add(chroma.Keyword, syn.Keyword)
217	add(chroma.KeywordConstant, syn.KeywordConstant)
218	add(chroma.KeywordDeclaration, syn.KeywordDeclaration)
219	add(chroma.KeywordNamespace, syn.KeywordNamespace)
220	add(chroma.KeywordReserved, syn.KeywordReserved)
221	add(chroma.KeywordType, syn.KeywordType)
222	add(chroma.NameBuiltin, syn.NameBuiltin)
223	add(chroma.NameFunction, syn.NameFunction)
224	add(chroma.NameClass, syn.NameClass)
225	add(chroma.NameDecorator, syn.NameDecorator)
226	add(chroma.LiteralString, syn.LiteralString)
227	add(chroma.LiteralStringEscape, syn.LiteralStringEscape)
228	add(chroma.LiteralNumber, syn.LiteralNumber)
229	add(chroma.Comment, syn.Comment)
230	add(chroma.CommentPreproc, syn.CommentPreproc)
231	add(chroma.Operator, syn.Operator)
232	add(chroma.Punctuation, syn.Punctuation)
233	add(chroma.Text, syn.Text)
234	builder.Add(chroma.Background, " bg:")
235
236	s, err := builder.Build()
237	if err != nil {
238		return styles.Fallback
239	}
240	return s
241}
242
243func (th Theme) syntaxKey() string {
244	syn := th.Syntax
245	outputMode := "256"
246	if th.Terminal.TrueColor {
247		outputMode = "truecolor"
248	}
249	return strings.Join([]string{
250		outputMode,
251		th.SyntaxBaseStyle,
252		syn.Keyword,
253		syn.KeywordConstant,
254		syn.KeywordDeclaration,
255		syn.KeywordNamespace,
256		syn.KeywordReserved,
257		syn.KeywordType,
258		syn.NameBuiltin,
259		syn.NameFunction,
260		syn.NameClass,
261		syn.NameDecorator,
262		syn.LiteralString,
263		syn.LiteralStringEscape,
264		syn.LiteralNumber,
265		syn.Comment,
266		syn.CommentPreproc,
267		syn.Operator,
268		syn.Punctuation,
269		syn.Text,
270	}, "\x1f")
271}
272
273// extLang maps file extensions to chroma lexer names. Only
274// extensions that chroma supports are included.
275var extLang = map[string]string{
276	"ts":         "typescript",
277	"tsx":        "tsx",
278	"js":         "javascript",
279	"jsx":        "jsx",
280	"mjs":        "javascript",
281	"cjs":        "javascript",
282	"py":         "python",
283	"rb":         "ruby",
284	"rs":         "rust",
285	"go":         "go",
286	"java":       "java",
287	"kt":         "kotlin",
288	"swift":      "swift",
289	"c":          "c",
290	"h":          "c",
291	"cpp":        "cpp",
292	"cc":         "cpp",
293	"cxx":        "cpp",
294	"hpp":        "cpp",
295	"cs":         "csharp",
296	"php":        "php",
297	"sh":         "bash",
298	"bash":       "bash",
299	"zsh":        "bash",
300	"fish":       "fish",
301	"ps1":        "powershell",
302	"sql":        "sql",
303	"html":       "html",
304	"htm":        "html",
305	"css":        "css",
306	"scss":       "scss",
307	"sass":       "sass",
308	"less":       "less",
309	"json":       "json",
310	"yaml":       "yaml",
311	"yml":        "yaml",
312	"toml":       "toml",
313	"xml":        "xml",
314	"md":         "markdown",
315	"markdown":   "markdown",
316	"dockerfile": "docker",
317	"makefile":   "makefile",
318	"lua":        "lua",
319	"perl":       "perl",
320	"pl":         "perl",
321	"proto":      "protobuf",
322	"tf":         "terraform",
323	"hcl":        "terraform",
324	"graphql":    "graphql",
325	"gql":        "graphql",
326	"vue":        "vue",
327	"svelte":     "svelte",
328	"r":          "r",
329	"jl":         "julia",
330	"ex":         "elixir",
331	"exs":        "elixir",
332	"scala":      "scala",
333	"nix":        "nix",
334}
335
336var filenameLang = map[string]string{
337	"dockerfile":     "docker",
338	"makefile":       "makefile",
339	"gnumakefile":    "makefile",
340	".gitconfig":     "ini",
341	".gitattributes": "ini",
342	"cargo.toml":     "toml",
343	"go.mod":         "go-module",
344	"go.sum":         "text",
345	"package.json":   "json",
346	"tsconfig.json":  "json",
347}

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