v0.2.89 eddf12c

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.
124func stripANSIBackgrounds(s string) string {
125	var out strings.Builder
126	out.Grow(len(s))
127	for i := 0; i < len(s); {
128		if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
129			out.WriteByte(s[i])
130			i++
131			continue
132		}
133		j := i + 2
134		for j < len(s) && s[j] != 'm' {
135			j++
136		}
137		if j >= len(s) {
138			out.WriteString(s[i:])
139			break
140		}
141		params := strings.Split(s[i+2:j], ";")
142		kept := make([]string, 0, len(params))
143		for p := 0; p < len(params); p++ {
144			param := params[p]
145			switch param {
146			case "48":
147				// 48;5;N or 48;2;R;G;B background color.
148				if p+1 < len(params) && params[p+1] == "5" {
149					p += 2
150					continue
151				}
152				if p+1 < len(params) && params[p+1] == "2" {
153					p += 4
154					continue
155				}
156				continue
157			case "49":
158				continue
159			}
160			// 40-47 and 100-107 are ANSI background colors.
161			if len(param) == 2 && param[0] == '4' && param[1] >= '0' && param[1] <= '7' {
162				continue
163			}
164			if len(param) == 3 && param[0] == '1' && param[1] == '0' && param[2] >= '0' && param[2] <= '7' {
165				continue
166			}
167			kept = append(kept, param)
168		}
169		if len(kept) > 0 {
170			out.WriteString("\x1b[")
171			out.WriteString(strings.Join(kept, ";"))
172			out.WriteByte('m')
173		}
174		i = j + 1
175	}
176	return out.String()
177}
178
179func chooseLexer(lang string) chroma.Lexer {
180	if lang == "" {
181		return nil
182	}
183	if l := lexers.Get(lang); l != nil {
184		return chroma.Coalesce(l)
185	}
186	// Chroma accepts aliases; try common spellings.
187	if l := lexers.Get(strings.ToLower(lang)); l != nil {
188		return chroma.Coalesce(l)
189	}
190	return nil
191}
192
193// chromaStyle builds a syntax style from the theme. Falls back to
194// chroma's bundled fallback style if the configured base style or
195// overrides cannot be built.
196func (th Theme) chromaStyle() *chroma.Style {
197	base := th.SyntaxBaseStyle
198	if base == "" {
199		base = "monokai"
200	}
201	style := styles.Get(base)
202	if style == nil {
203		style = styles.Fallback
204	}
205	builder := style.Builder()
206	add := func(tt chroma.TokenType, entry string) {
207		if strings.TrimSpace(entry) != "" {
208			builder.Add(tt, entry)
209		}
210	}
211	syn := th.Syntax
212	add(chroma.Keyword, syn.Keyword)
213	add(chroma.KeywordConstant, syn.KeywordConstant)
214	add(chroma.KeywordDeclaration, syn.KeywordDeclaration)
215	add(chroma.KeywordNamespace, syn.KeywordNamespace)
216	add(chroma.KeywordReserved, syn.KeywordReserved)
217	add(chroma.KeywordType, syn.KeywordType)
218	add(chroma.NameBuiltin, syn.NameBuiltin)
219	add(chroma.NameFunction, syn.NameFunction)
220	add(chroma.NameClass, syn.NameClass)
221	add(chroma.NameDecorator, syn.NameDecorator)
222	add(chroma.LiteralString, syn.LiteralString)
223	add(chroma.LiteralStringEscape, syn.LiteralStringEscape)
224	add(chroma.LiteralNumber, syn.LiteralNumber)
225	add(chroma.Comment, syn.Comment)
226	add(chroma.CommentPreproc, syn.CommentPreproc)
227	add(chroma.Operator, syn.Operator)
228	add(chroma.Punctuation, syn.Punctuation)
229	add(chroma.Text, syn.Text)
230	builder.Add(chroma.Background, " bg:")
231
232	s, err := builder.Build()
233	if err != nil {
234		return styles.Fallback
235	}
236	return s
237}
238
239func (th Theme) syntaxKey() string {
240	syn := th.Syntax
241	return strings.Join([]string{
242		th.SyntaxBaseStyle,
243		syn.Keyword,
244		syn.KeywordConstant,
245		syn.KeywordDeclaration,
246		syn.KeywordNamespace,
247		syn.KeywordReserved,
248		syn.KeywordType,
249		syn.NameBuiltin,
250		syn.NameFunction,
251		syn.NameClass,
252		syn.NameDecorator,
253		syn.LiteralString,
254		syn.LiteralStringEscape,
255		syn.LiteralNumber,
256		syn.Comment,
257		syn.CommentPreproc,
258		syn.Operator,
259		syn.Punctuation,
260		syn.Text,
261	}, "\x1f")
262}
263
264// extLang maps file extensions to chroma lexer names. Only
265// extensions that chroma supports are included.
266var extLang = map[string]string{
267	"ts":         "typescript",
268	"tsx":        "tsx",
269	"js":         "javascript",
270	"jsx":        "jsx",
271	"mjs":        "javascript",
272	"cjs":        "javascript",
273	"py":         "python",
274	"rb":         "ruby",
275	"rs":         "rust",
276	"go":         "go",
277	"java":       "java",
278	"kt":         "kotlin",
279	"swift":      "swift",
280	"c":          "c",
281	"h":          "c",
282	"cpp":        "cpp",
283	"cc":         "cpp",
284	"cxx":        "cpp",
285	"hpp":        "cpp",
286	"cs":         "csharp",
287	"php":        "php",
288	"sh":         "bash",
289	"bash":       "bash",
290	"zsh":        "bash",
291	"fish":       "fish",
292	"ps1":        "powershell",
293	"sql":        "sql",
294	"html":       "html",
295	"htm":        "html",
296	"css":        "css",
297	"scss":       "scss",
298	"sass":       "sass",
299	"less":       "less",
300	"json":       "json",
301	"yaml":       "yaml",
302	"yml":        "yaml",
303	"toml":       "toml",
304	"xml":        "xml",
305	"md":         "markdown",
306	"markdown":   "markdown",
307	"dockerfile": "docker",
308	"makefile":   "makefile",
309	"lua":        "lua",
310	"perl":       "perl",
311	"pl":         "perl",
312	"proto":      "protobuf",
313	"tf":         "terraform",
314	"hcl":        "terraform",
315	"graphql":    "graphql",
316	"gql":        "graphql",
317	"vue":        "vue",
318	"svelte":     "svelte",
319	"r":          "r",
320	"jl":         "julia",
321	"ex":         "elixir",
322	"exs":        "elixir",
323	"scala":      "scala",
324	"nix":        "nix",
325}
326
327var filenameLang = map[string]string{
328	"dockerfile":     "docker",
329	"makefile":       "makefile",
330	"gnumakefile":    "makefile",
331	".gitconfig":     "ini",
332	".gitattributes": "ini",
333	"cargo.toml":     "toml",
334	"go.mod":         "go-module",
335	"go.sum":         "text",
336	"package.json":   "json",
337	"tsconfig.json":  "json",
338}

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