v0.1.62 380eca9

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

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