v0.1.33 998a062

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(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.
113func chooseLexer(lang string) chroma.Lexer {
114	if lang == "" {
115		return nil
116	}
117	if l := lexers.Get(lang); l != nil {
118		return chroma.Coalesce(l)
119	}
120	// Chroma accepts aliases; try common spellings.
121	if l := lexers.Get(strings.ToLower(lang)); l != nil {
122		return chroma.Coalesce(l)
123	}
124	return nil
125}
126
127// zotChromaStyle is a terminal style tuned for zot's dark palette.
128// Built lazily so the cost is paid once per process.
129var zotChromaStyle = func() *chroma.Style {
130	chromaStyleOnce.Do(func() {
131		chromaStyleCached = buildZotStyle()
132	})
133	return chromaStyleCached
134}()
135
136var (
137	chromaStyleOnce   sync.Once
138	chromaStyleCached *chroma.Style
139)
140
141// buildZotStyle returns a chroma style with colors that match zot's
142// theme: accent blue for keywords, green for strings, muted grey for
143// comments, user-tan for numbers. Falls back to chroma's bundled
144// "fruity" style if the builder fails for any reason.
145func buildZotStyle() *chroma.Style {
146	builder := styles.Get("monokai").Builder()
147	// Override the most visible tokens with explicit colors that
148	// read well on a dark terminal.
149	builder.Add(chroma.Keyword, "#81a1c1 bold")       // imports, funcs, control flow
150	builder.Add(chroma.KeywordConstant, "#81a1c1")    // true, false, null
151	builder.Add(chroma.KeywordDeclaration, "#81a1c1") // const, let, var, type
152	builder.Add(chroma.KeywordNamespace, "#81a1c1")   // import, from, package
153	builder.Add(chroma.KeywordReserved, "#81a1c1 bold")
154	builder.Add(chroma.KeywordType, "#88c0d0") // string, int, bool
155	builder.Add(chroma.NameBuiltin, "#88c0d0")
156	builder.Add(chroma.NameFunction, "#8fbcbb")
157	builder.Add(chroma.NameClass, "#a3be8c bold")
158	builder.Add(chroma.NameDecorator, "#b48ead")
159	builder.Add(chroma.LiteralString, "#a3be8c") // strings
160	builder.Add(chroma.LiteralStringEscape, "#bf616a")
161	builder.Add(chroma.LiteralNumber, "#d08770")
162	builder.Add(chroma.Comment, "#616e88 italic")
163	builder.Add(chroma.CommentPreproc, "#b48ead")
164	builder.Add(chroma.Operator, "#eceff4")
165	builder.Add(chroma.Punctuation, "#d8dee9")
166	builder.Add(chroma.Text, "#e5e9f0")
167	builder.Add(chroma.Background, " bg:")
168
169	s, err := builder.Build()
170	if err != nil {
171		return styles.Fallback
172	}
173	return s
174}
175
176// extLang maps file extensions to chroma lexer names. Only
177// extensions that chroma supports are included.
178var extLang = map[string]string{
179	"ts":         "typescript",
180	"tsx":        "tsx",
181	"js":         "javascript",
182	"jsx":        "jsx",
183	"mjs":        "javascript",
184	"cjs":        "javascript",
185	"py":         "python",
186	"rb":         "ruby",
187	"rs":         "rust",
188	"go":         "go",
189	"java":       "java",
190	"kt":         "kotlin",
191	"swift":      "swift",
192	"c":          "c",
193	"h":          "c",
194	"cpp":        "cpp",
195	"cc":         "cpp",
196	"cxx":        "cpp",
197	"hpp":        "cpp",
198	"cs":         "csharp",
199	"php":        "php",
200	"sh":         "bash",
201	"bash":       "bash",
202	"zsh":        "bash",
203	"fish":       "fish",
204	"ps1":        "powershell",
205	"sql":        "sql",
206	"html":       "html",
207	"htm":        "html",
208	"css":        "css",
209	"scss":       "scss",
210	"sass":       "sass",
211	"less":       "less",
212	"json":       "json",
213	"yaml":       "yaml",
214	"yml":        "yaml",
215	"toml":       "toml",
216	"xml":        "xml",
217	"md":         "markdown",
218	"markdown":   "markdown",
219	"dockerfile": "docker",
220	"makefile":   "makefile",
221	"lua":        "lua",
222	"perl":       "perl",
223	"pl":         "perl",
224	"proto":      "protobuf",
225	"tf":         "terraform",
226	"hcl":        "terraform",
227	"graphql":    "graphql",
228	"gql":        "graphql",
229	"vue":        "vue",
230	"svelte":     "svelte",
231	"r":          "r",
232	"jl":         "julia",
233	"ex":         "elixir",
234	"exs":        "elixir",
235	"scala":      "scala",
236	"nix":        "nix",
237}
238
239var filenameLang = map[string]string{
240	"dockerfile":     "docker",
241	"makefile":       "makefile",
242	"gnumakefile":    "makefile",
243	".gitconfig":     "ini",
244	".gitattributes": "ini",
245	"cargo.toml":     "toml",
246	"go.mod":         "go-module",
247	"go.sum":         "text",
248	"package.json":   "json",
249	"tsconfig.json":  "json",
250}

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