detached 4492065

feat(web): syntax highlighting in diffs, cached by blob (#67)

commit
44920651c0208090ceac36b2dee6a31e1397c488
author
brice · · 2026-10-18 14:34 UTC
committer
bnema · · 2026-10-18 14:34 UTC
parent
79e542a
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.25.0
23 files · +1555 -104
M internal/adapters/web/commit.go +1 -1
@@ -120,7 +120,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
120120 NFiles: res.Stats.FilesChanged,
121121 Adds: res.Stats.Additions,
122122 Dels: res.Stats.Deletions,
123- Files: diffFiles(res.Diff.Files),
123+ Files: s.hl.files(ctx, res.Diff.Files),
124124 }
125125 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
126126 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
@@ -120,7 +120,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
120 NFiles: res.Stats.FilesChanged,120 NFiles: res.Stats.FilesChanged,
121 Adds: res.Stats.Additions,121 Adds: res.Stats.Additions,
122 Dels: res.Stats.Deletions,122 Dels: res.Stats.Deletions,
123- Files: diffFiles(res.Diff.Files),123+ Files: s.hl.files(ctx, res.Diff.Files),
124 }124 }
125 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))125 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
126 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {126 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
M internal/adapters/web/compare.go +1 -1
@@ -86,7 +86,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
8686 return nil, 0, err
8787 }
8888 p.HasDiff = true
89- p.Files = diffFiles(res.Diff.Files)
89+ p.Files = s.hl.files(ctx, res.Diff.Files)
9090 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
9191 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
9292 if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {
@@ -86,7 +86,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
86 return nil, 0, err86 return nil, 0, err
87 }87 }
88 p.HasDiff = true88 p.HasDiff = true
89- p.Files = diffFiles(res.Diff.Files)89+ p.Files = s.hl.files(ctx, res.Diff.Files)
90 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))90 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
91 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions91 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
92 if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {92 if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {
M internal/adapters/web/context.go +8 -1
@@ -81,11 +81,18 @@ func (s *Server) diffContext(ctx context.Context, r *http.Request) (templ.Compon
8181 }
8282 cut := min(end, newFrom-1+maxContextLines)
8383
84+ // The colours of the lines come from the file lexed down to the last of
85+ // them; plain where that is not possible (limits, no language).
86+ colours := s.hl.highlightContext(ctx, info.ID.String(), path, lines, newFrom-1, cut)
8487 var out ContextRows
8588 for i := newFrom - 1; i < cut; i++ {
8689 o, nw := oldFrom+i-(newFrom-1), i+1
90+ segs := segments(lines[i])
91+ if colours != nil && colours[i-(newFrom-1)] != nil {
92+ segs = colours[i-(newFrom-1)]
93+ }
8794 out.Lines = append(out.Lines, DiffLine{
88- Kind: "ctx", Mark: " ", Segs: segments(lines[i]),
95+ Kind: "ctx", Mark: " ", Segs: segs,
8996 Old: itoa(o), New: itoa(nw), oldN: o, newN: nw,
9097 NoEOL: noEOL && i == len(lines)-1,
9198 })
@@ -81,11 +81,18 @@ func (s *Server) diffContext(ctx context.Context, r *http.Request) (templ.Compon
81 }81 }
82 cut := min(end, newFrom-1+maxContextLines)82 cut := min(end, newFrom-1+maxContextLines)
83 83
84+ // The colours of the lines come from the file lexed down to the last of
85+ // them; plain where that is not possible (limits, no language).
86+ colours := s.hl.highlightContext(ctx, info.ID.String(), path, lines, newFrom-1, cut)
84 var out ContextRows87 var out ContextRows
85 for i := newFrom - 1; i < cut; i++ {88 for i := newFrom - 1; i < cut; i++ {
86 o, nw := oldFrom+i-(newFrom-1), i+189 o, nw := oldFrom+i-(newFrom-1), i+1
90+ segs := segments(lines[i])
91+ if colours != nil && colours[i-(newFrom-1)] != nil {
92+ segs = colours[i-(newFrom-1)]
93+ }
87 out.Lines = append(out.Lines, DiffLine{94 out.Lines = append(out.Lines, DiffLine{
88- Kind: "ctx", Mark: " ", Segs: segments(lines[i]),95+ Kind: "ctx", Mark: " ", Segs: segs,
89 Old: itoa(o), New: itoa(nw), oldN: o, newN: nw,96 Old: itoa(o), New: itoa(nw), oldN: o, newN: nw,
90 NoEOL: noEOL && i == len(lines)-1,97 NoEOL: noEOL && i == len(lines)-1,
91 })98 })
M internal/adapters/web/diff.go +18 -7
@@ -22,6 +22,9 @@ import (
2222 type Seg struct {
2323 Text string
2424 Mark bool
25+ // Class is the CSS class of the syntax token the text belongs to (the
26+ // classes of 56-chroma.css), "" for plain text and for markers.
27+ Class string
2528 }
2629
2730 // DiffLine is one line of a hunk, in the unified view and, paired, in the
@@ -226,24 +229,29 @@ func lineNumber(n int) string {
226229 // segments splits a line of code into plain text and markers. One trailing
227230 // carriage return, the terminator of a CRLF file, is dropped.
228231 func segments(text string) []Seg {
229- text = strings.TrimSuffix(text, "\r")
232+ return appendSegs(nil, strings.TrimSuffix(text, "\r"), "")
233+}
234+
235+// appendSegs appends to out the pieces of text, a piece of code of the
236+// syntax class class: plain text of that class, and the markers of its
237+// invisible characters.
238+func appendSegs(out []Seg, text, class string) []Seg {
230239 if !strings.ContainsFunc(text, invisible) {
231- return []Seg{{Text: text}}
240+ return append(out, Seg{Text: text, Class: class})
232241 }
233- var out []Seg
234242 start := 0
235243 for i, r := range text {
236244 if !invisible(r) {
237245 continue
238246 }
239247 if i > start {
240- out = append(out, Seg{Text: text[start:i]})
248+ out = append(out, Seg{Text: text[start:i], Class: class})
241249 }
242250 out = append(out, Seg{Text: marker(r), Mark: true})
243251 start = i + utf8.RuneLen(r)
244252 }
245253 if start < len(text) {
246- out = append(out, Seg{Text: text[start:]})
254+ out = append(out, Seg{Text: text[start:], Class: class})
247255 }
248256 return out
249257 }
@@ -424,9 +432,12 @@ func codeLine(ln DiffLine) templ.Component {
424432 return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
425433 var b strings.Builder
426434 for _, s := range ln.Segs {
427- if s.Mark {
435+ switch {
436+ case s.Mark:
428437 b.WriteString(`<span class="inv" title="invisible character">` + html.EscapeString(s.Text) + `</span>`)
429- } else {
438+ case s.Class != "":
439+ b.WriteString(`<span class="` + html.EscapeString(s.Class) + `">` + html.EscapeString(s.Text) + `</span>`)
440+ default:
430441 b.WriteString(html.EscapeString(s.Text))
431442 }
432443 }
@@ -22,6 +22,9 @@ import (
22 type Seg struct {22 type Seg struct {
23 Text string23 Text string
24 Mark bool24 Mark bool
25+ // Class is the CSS class of the syntax token the text belongs to (the
26+ // classes of 56-chroma.css), "" for plain text and for markers.
27+ Class string
25 }28 }
26 29
27 // DiffLine is one line of a hunk, in the unified view and, paired, in the30 // DiffLine is one line of a hunk, in the unified view and, paired, in the
@@ -226,24 +229,29 @@ func lineNumber(n int) string {
226 // segments splits a line of code into plain text and markers. One trailing229 // segments splits a line of code into plain text and markers. One trailing
227 // carriage return, the terminator of a CRLF file, is dropped.230 // carriage return, the terminator of a CRLF file, is dropped.
228 func segments(text string) []Seg {231 func segments(text string) []Seg {
229- text = strings.TrimSuffix(text, "\r")232+ return appendSegs(nil, strings.TrimSuffix(text, "\r"), "")
233+}
234+
235+// appendSegs appends to out the pieces of text, a piece of code of the
236+// syntax class class: plain text of that class, and the markers of its
237+// invisible characters.
238+func appendSegs(out []Seg, text, class string) []Seg {
230 if !strings.ContainsFunc(text, invisible) {239 if !strings.ContainsFunc(text, invisible) {
231- return []Seg{{Text: text}}240+ return append(out, Seg{Text: text, Class: class})
232 }241 }
233- var out []Seg
234 start := 0242 start := 0
235 for i, r := range text {243 for i, r := range text {
236 if !invisible(r) {244 if !invisible(r) {
237 continue245 continue
238 }246 }
239 if i > start {247 if i > start {
240- out = append(out, Seg{Text: text[start:i]})248+ out = append(out, Seg{Text: text[start:i], Class: class})
241 }249 }
242 out = append(out, Seg{Text: marker(r), Mark: true})250 out = append(out, Seg{Text: marker(r), Mark: true})
243 start = i + utf8.RuneLen(r)251 start = i + utf8.RuneLen(r)
244 }252 }
245 if start < len(text) {253 if start < len(text) {
246- out = append(out, Seg{Text: text[start:]})254+ out = append(out, Seg{Text: text[start:], Class: class})
247 }255 }
248 return out256 return out
249 }257 }
@@ -424,9 +432,12 @@ func codeLine(ln DiffLine) templ.Component {
424 return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {432 return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
425 var b strings.Builder433 var b strings.Builder
426 for _, s := range ln.Segs {434 for _, s := range ln.Segs {
427- if s.Mark {435+ switch {
436+ case s.Mark:
428 b.WriteString(`<span class="inv" title="invisible character">` + html.EscapeString(s.Text) + `</span>`)437 b.WriteString(`<span class="inv" title="invisible character">` + html.EscapeString(s.Text) + `</span>`)
429- } else {438+ case s.Class != "":
439+ b.WriteString(`<span class="` + html.EscapeString(s.Class) + `">` + html.EscapeString(s.Text) + `</span>`)
440+ default:
430 b.WriteString(html.EscapeString(s.Text))441 b.WriteString(html.EscapeString(s.Text))
431 }442 }
432 }443 }
M internal/adapters/web/diff.templ +3 -3
@@ -221,7 +221,7 @@ templ DiffFileView(f DiffFile, p DiffView) {
221221 <td class="ln">@gutter(ln.Old, ln.OldHref)</td>
222222 <td class="ln">@gutter(ln.New, ln.NewHref)</td>
223223 <td class="m">{ ln.Mark }</td>
224- <td class="tx">@codeLine(ln)</td>
224+ <td class="tx chroma">@codeLine(ln)</td>
225225 </tr>
226226 if len(ln.Notes) > 0 || ln.Form != nil {
227227 <tr class="fnote">
@@ -325,7 +325,7 @@ templ DiffContext(c ContextRows) {
325325 <td class="ln">{ ln.Old }</td>
326326 <td class="ln">{ ln.New }</td>
327327 <td class="m">{ ln.Mark }</td>
328- <td class="tx">@codeLine(ln)</td>
328+ <td class="tx chroma">@codeLine(ln)</td>
329329 </tr>
330330 }
331331 if c.More != nil {
@@ -362,7 +362,7 @@ templ splitSide(ln *DiffLine, old bool) {
362362 }
363363 </td>
364364 <td class={ "m", ln.Kind }>{ ln.Mark }</td>
365- <td class={ "tx", ln.Kind }>@codeLine(*ln)</td>
365+ <td class={ "tx", "chroma", ln.Kind }>@codeLine(*ln)</td>
366366 }
367367 }
368368
@@ -221,7 +221,7 @@ templ DiffFileView(f DiffFile, p DiffView) {
221 <td class="ln">@gutter(ln.Old, ln.OldHref)</td>221 <td class="ln">@gutter(ln.Old, ln.OldHref)</td>
222 <td class="ln">@gutter(ln.New, ln.NewHref)</td>222 <td class="ln">@gutter(ln.New, ln.NewHref)</td>
223 <td class="m">{ ln.Mark }</td>223 <td class="m">{ ln.Mark }</td>
224- <td class="tx">@codeLine(ln)</td>224+ <td class="tx chroma">@codeLine(ln)</td>
225 </tr>225 </tr>
226 if len(ln.Notes) > 0 || ln.Form != nil {226 if len(ln.Notes) > 0 || ln.Form != nil {
227 <tr class="fnote">227 <tr class="fnote">
@@ -325,7 +325,7 @@ templ DiffContext(c ContextRows) {
325 <td class="ln">{ ln.Old }</td>325 <td class="ln">{ ln.Old }</td>
326 <td class="ln">{ ln.New }</td>326 <td class="ln">{ ln.New }</td>
327 <td class="m">{ ln.Mark }</td>327 <td class="m">{ ln.Mark }</td>
328- <td class="tx">@codeLine(ln)</td>328+ <td class="tx chroma">@codeLine(ln)</td>
329 </tr>329 </tr>
330 }330 }
331 if c.More != nil {331 if c.More != nil {
@@ -362,7 +362,7 @@ templ splitSide(ln *DiffLine, old bool) {
362 }362 }
363 </td>363 </td>
364 <td class={ "m", ln.Kind }>{ ln.Mark }</td>364 <td class={ "m", ln.Kind }>{ ln.Mark }</td>
365- <td class={ "tx", ln.Kind }>@codeLine(*ln)</td>365+ <td class={ "tx", "chroma", ln.Kind }>@codeLine(*ln)</td>
366 }366 }
367 }367 }
368 368
A internal/adapters/web/diff_highlight.go +551 -0
@@ -0,0 +1,551 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "slices"
8+ "strconv"
9+ "strings"
10+ "sync"
11+ "time"
12+ "unicode/utf8"
13+
14+ "github.com/alecthomas/chroma/v2"
15+
16+ "bnema.dev/ermite/internal/domain"
17+)
18+
19+// Syntax highlighting of the diffs. The tokens are the ones of the code view
20+// (highlight.go, the same lexers, the same classes, the colours of
21+// static/css/56-chroma.css); what differs is the input. A diff holds pieces
22+// of files, not files, so each hunk is lexed on its own, as two sequences:
23+// the old side (context and deleted lines) and the new side (context and
24+// added lines). A multi-line construct that starts and ends inside a hunk
25+// (a block comment, a raw string) is then coloured on all its lines, and a
26+// deleted line gets the colours it had in the old file, not the ones the
27+// added lines around it would give it. A hunk is lexed from its first line,
28+// so a construct that opens above it is not known: the first lines may be
29+// coloured as code (the usual limit of a diff view). The lines that /context
30+// loads come from the whole file, which is lexed as a whole.
31+//
32+// The work is bounded, since a page is served under load and the lexers are
33+// not cheap: a line, a file and a request have size limits (over them
34+// the text stays plain), a request lexes with one of the lexing slots of the
35+// code view (none free: plain) and a time budget, and the language is the
36+// one of the file name (none: plain). Text that is not lexed, or whose tokens
37+// do not add up to it, is shown as before.
38+//
39+// What a lexer finds is kept (hlcache.go) by the blobs the diff compares, so
40+// that the visitors of one merge request or commit, and its live updates, lex
41+// each file once. The colours are stored as runs (an end offset and a class a
42+// piece of line), not as text: they are put back on the lines of the diff
43+// that is being rendered.
44+
45+// maxDiffHLTotalBytes is the most text lexed for one request, the files of
46+// the diff together: the files after it are plain. A variable for the tests.
47+var maxDiffHLTotalBytes = 256 << 10
48+
49+const (
50+ // maxDiffHLFileBytes is the most text of one file of a diff that is
51+ // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 lines a
52+ // file; this keeps a file of very long lines out.
53+ maxDiffHLFileBytes = 128 << 10
54+ // maxDiffHLLine is the longest line that is lexed, in bytes: a minified
55+ // file is the slowest input for a lexer and gains nothing from colour.
56+ maxDiffHLLine = 2000
57+ // diffHLBudget is the time a request spends lexing; once over, the rest
58+ // of the diff is plain.
59+ diffHLBudget = 2 * time.Second
60+)
61+
62+// styledClasses are the token classes that 56-chroma.css gives a style. A
63+// token of any other class has none worth sending: its text joins the plain
64+// text around it. TestStyledClassesMatchCSS keeps it equal to the style sheet.
65+var styledClasses = map[string]bool{
66+ "c": true, "c1": true, "ch": true, "cm": true, "cs": true, "dl": true,
67+ "k": true, "kc": true, "kd": true, "kn": true, "kr": true,
68+ "nc": true, "ne": true, "nn": true, "ow": true,
69+ "s": true, "s1": true, "s2": true, "sa": true, "sb": true, "sc": true, "sd": true,
70+ "se": true, "sh": true, "si": true, "sr": true, "ss": true, "sx": true,
71+}
72+
73+// diffClass is the CSS class of a token type, "" when it has no style.
74+func diffClass(t chroma.TokenType) string {
75+ if c := tokenClass(t); styledClasses[c] {
76+ return c
77+ }
78+ return ""
79+}
80+
81+// lexerMemoMax bounds the memo of lexers by file name: the names come from
82+// the repositories, so it is emptied when full.
83+const lexerMemoMax = 1024
84+
85+var lexerMemo struct {
86+ sync.Mutex
87+ m map[string]chroma.Lexer
88+}
89+
90+// diffLexer is the lexer of a file name, nil for plain text. A name without
91+// a language, or whose lexer only lexes plain text, has none. Finding a lexer
92+// matches the name against every pattern chroma knows (it is as dear as lexing
93+// a few hundred bytes), so the answer is kept by file name; lexers are safe to
94+// share.
95+func diffLexer(name string) chroma.Lexer {
96+ lexerMemo.Lock()
97+ lx, ok := lexerMemo.m[name]
98+ lexerMemo.Unlock()
99+ if ok {
100+ return lx
101+ }
102+ if lx = lexerFor(name); lx != nil && lx.Config().Name == "plaintext" {
103+ lx = nil
104+ }
105+ lexerMemo.Lock()
106+ if lexerMemo.m == nil || len(lexerMemo.m) >= lexerMemoMax {
107+ lexerMemo.m = map[string]chroma.Lexer{}
108+ }
109+ lexerMemo.m[name] = lx
110+ lexerMemo.Unlock()
111+ return lx
112+}
113+
114+// lexingSlot takes one of the lexing slots of the code view without waiting;
115+// release gives it back. ok is false when they are all taken.
116+func lexingSlot() (release func(), ok bool) {
117+ select {
118+ case lexSlots <- struct{}{}:
119+ return func() { <-lexSlots }, true
120+ default:
121+ return nil, false
122+ }
123+}
124+
125+// lexable reports whether lines can be lexed and shown with the colours put
126+// back on them: valid UTF-8, no carriage return inside a line (the lexer
127+// would take it for a line end and the lines would no longer line up), no
128+// line too long.
129+func lexable(lines []string) bool {
130+ for _, l := range lines {
131+ if len(l) > maxDiffHLLine || strings.ContainsRune(l, '\r') || !utf8.ValidString(l) {
132+ return false
133+ }
134+ }
135+ return true
136+}
137+
138+// tokenBufs are the token buffers of lexLines.
139+var tokenBufs = sync.Pool{New: func() any { b := make([]chroma.Token, 0, 4096); return &b }}
140+
141+// lexLines lexes lines, texts without their line end, as one sequence and
142+// returns the tokens of each line. nil means no colours: the lexer failed,
143+// ran out of time, or its tokens do not line up with the lines.
144+func lexLines(ctx context.Context, lx chroma.Lexer, lines []string) [][]chroma.Token {
145+ if len(lines) == 0 || !lexable(lines) {
146+ return nil
147+ }
148+ n := 0
149+ for _, l := range lines {
150+ n += len(l) + 1
151+ }
152+ var b strings.Builder
153+ b.Grow(n)
154+ for _, l := range lines {
155+ b.WriteString(l)
156+ b.WriteByte('\n')
157+ }
158+ // The tokens of the whole text are a big slice, grown by doubling: one
159+ // buffer is reused from a lexing to the next. The lines get copies of the
160+ // tokens, so it is free again once they are split.
161+ buf := tokenBufs.Get().(*[]chroma.Token)
162+ toks, ok := tokeniseInto(ctx, lx, b.String(), (*buf)[:0])
163+ if !ok {
164+ tokenBufs.Put(buf)
165+ return nil
166+ }
167+ per := chroma.SplitTokensIntoLines(toks)
168+ clear(toks)
169+ *buf = toks[:0]
170+ tokenBufs.Put(buf)
171+ if len(per) != len(lines) {
172+ return nil
173+ }
174+ return per
175+}
176+
177+// classList are the classes a run can have, "" (plain) first; a run holds the
178+// index.
179+var classList = func() []string {
180+ l := []string{""}
181+ for c := range styledClasses {
182+ l = append(l, c)
183+ }
184+ slices.Sort(l[1:])
185+ return l
186+}()
187+
188+var classIndex = func() map[string]uint8 {
189+ m := make(map[string]uint8, len(classList))
190+ for i, c := range classList {
191+ m[c] = uint8(i)
192+ }
193+ return m
194+}()
195+
196+// hlRun is a piece of a line of one class: it ends at byte end.
197+type hlRun struct {
198+ end uint16
199+ class uint8
200+}
201+
202+// hlData are the colours of the lines of a file, the lines of its hunks in
203+// order or all of them: the runs of line i are runs[lineEnd[i-1]:lineEnd[i]],
204+// none for a plain line. It is read only once built, so it is shared by the
205+// requests that use it.
206+type hlData struct {
207+ lineEnd []uint32
208+ runs []hlRun
209+}
210+
211+// size is what the data weighs, for the budget of the cache: what its
212+// arrays hold, not what they use (computeFile trims them).
213+func (d *hlData) size() int { return 64 + 4*cap(d.lineEnd) + 4*cap(d.runs) }
214+
215+// line is the runs of line i.
216+func (d *hlData) line(i int) []hlRun {
217+ lo := uint32(0)
218+ if i > 0 {
219+ lo = d.lineEnd[i-1]
220+ }
221+ return d.runs[lo:d.lineEnd[i]]
222+}
223+
224+// segs puts the runs on the text of a line, in the room buf gives (nil: it
225+// allocates); a line with invisible characters needs more and grows apart.
226+// nil means plain: no run, or runs that do not cover the text (data of
227+// another text, which no key of the cache lets through, but a wrong colour is
228+// worse than none).
229+func segs(text string, runs []hlRun, buf []Seg) []Seg {
230+ if len(runs) == 0 || int(runs[len(runs)-1].end) != len(text) {
231+ return nil
232+ }
233+ var out []Seg
234+ if buf == nil {
235+ out = make([]Seg, 0, len(runs))
236+ } else {
237+ out = buf[:0:len(runs)]
238+ }
239+ start := 0
240+ for _, r := range runs {
241+ end := int(r.end)
242+ if end < start || end > len(text) || int(r.class) >= len(classList) {
243+ return nil
244+ }
245+ out = appendSegs(out, text[start:end], classList[r.class])
246+ start = end
247+ }
248+ return out
249+}
250+
251+// hlBuilder builds the hlData of a file, line by line.
252+type hlBuilder struct{ d hlData }
253+
254+// line adds the next line: its text and its tokens (nil for none). Tokens
255+// that do not add up to the text leave the line plain.
256+func (b *hlBuilder) line(text string, toks []chroma.Token) {
257+ mark := len(b.d.runs)
258+ if text != "" && len(toks) > 0 && !b.runs(text, toks) {
259+ b.d.runs = b.d.runs[:mark]
260+ }
261+ b.d.lineEnd = append(b.d.lineEnd, uint32(len(b.d.runs)))
262+}
263+
264+// runs appends the runs of a line, one for each stretch of tokens of the same
265+// class; false if the tokens do not add up to the text.
266+func (b *hlBuilder) runs(text string, toks []chroma.Token) bool {
267+ pos, class := 0, ""
268+ for _, t := range toks {
269+ v := strings.TrimSuffix(t.Value, "\n")
270+ if v == "" {
271+ continue
272+ }
273+ if c := diffClass(t.Type); c != class {
274+ if pos > 0 {
275+ b.d.runs = append(b.d.runs, hlRun{end: uint16(pos), class: classIndex[class]})
276+ }
277+ class = c
278+ }
279+ pos += len(v)
280+ if pos > len(text) {
281+ return false
282+ }
283+ }
284+ if pos != len(text) {
285+ return false
286+ }
287+ b.d.runs = append(b.d.runs, hlRun{end: uint16(pos), class: classIndex[class]})
288+ return true
289+}
290+
291+// hunk adds the lines of a hunk, lexed as two sequences, one a side. Context
292+// lines take their colours from the new side, or from the old side when the
293+// hunk adds nothing: a side that no line needs is not lexed.
294+func (b *hlBuilder) hunk(ctx context.Context, lx chroma.Lexer, src []domain.Line) {
295+ var oldTexts, newTexts []string
296+ hasAdd, hasDel := false, false
297+ for _, l := range src {
298+ t := strings.TrimSuffix(l.Text, "\r")
299+ if l.Kind != domain.LineAddition {
300+ oldTexts = append(oldTexts, t)
301+ } else {
302+ hasAdd = true
303+ }
304+ if l.Kind != domain.LineDeletion {
305+ newTexts = append(newTexts, t)
306+ } else {
307+ hasDel = true
308+ }
309+ }
310+ var oldToks, newToks [][]chroma.Token
311+ if hasDel || !hasAdd {
312+ oldToks = lexLines(ctx, lx, oldTexts)
313+ }
314+ if hasAdd {
315+ newToks = lexLines(ctx, lx, newTexts)
316+ }
317+ tokens := func(all [][]chroma.Token, i int) []chroma.Token {
318+ if i < len(all) {
319+ return all[i]
320+ }
321+ return nil
322+ }
323+ oi, ni := 0, 0
324+ for _, l := range src {
325+ switch l.Kind {
326+ case domain.LineDeletion:
327+ b.line(oldTexts[oi], tokens(oldToks, oi))
328+ oi++
329+ case domain.LineAddition:
330+ b.line(newTexts[ni], tokens(newToks, ni))
331+ ni++
332+ default:
333+ if hasAdd {
334+ b.line(newTexts[ni], tokens(newToks, ni))
335+ } else {
336+ b.line(oldTexts[oi], tokens(oldToks, oi))
337+ }
338+ oi++
339+ ni++
340+ }
341+ }
342+}
343+
344+// computeFile lexes the hunks of a file. ok is false when ctx ended, whatever
345+// was lexed is then not the whole of it and is not kept.
346+//
347+//hexcheck:ignore suspicious-business-logic-in-adapter cuts the lines of a diff into coloured runs for the page; no rule is decided here
348+func computeFile(ctx context.Context, lx chroma.Lexer, hunks []domain.Hunk) (d *hlData, ok bool) {
349+ n := 0
350+ for _, h := range hunks {
351+ n += len(h.Lines)
352+ }
353+ b := hlBuilder{d: hlData{lineEnd: make([]uint32, 0, n), runs: make([]hlRun, 0, 4*n)}}
354+ for _, h := range hunks {
355+ if ctx.Err() != nil {
356+ return nil, false
357+ }
358+ b.hunk(ctx, lx, h.Lines)
359+ }
360+ if ctx.Err() != nil {
361+ return nil, false
362+ }
363+ // The runs were reserved for four a line; a cached entry keeps only what
364+ // it uses (size counts the capacity).
365+ if cap(b.d.runs) > len(b.d.runs)+len(b.d.runs)/4+16 {
366+ b.d.runs = slices.Clone(b.d.runs)
367+ }
368+ return &b.d, true
369+}
370+
371+// applyFile puts the colours on the lines of a file; false when the data is
372+// not the one of this file (another number of lines), nothing is changed.
373+//
374+//hexcheck:ignore suspicious-business-logic-in-adapter puts coloured runs on the lines of a diff for the page; no rule is decided here
375+func applyFile(d *hlData, f *DiffFile, hunks []domain.Hunk) bool {
376+ n := 0
377+ for _, h := range hunks {
378+ n += len(h.Lines)
379+ }
380+ if len(d.lineEnd) != n || len(f.Hunks) != len(hunks) {
381+ return false
382+ }
383+ i := 0
384+ // The segments of all the lines come from one slab, not one allocation a
385+ // line: that is most of what a cached page costs.
386+ slab := make([]Seg, len(d.runs))
387+ for j, h := range hunks {
388+ if len(f.Hunks[j].Lines) != len(h.Lines) {
389+ return false
390+ }
391+ for k, l := range h.Lines {
392+ runs := d.line(i)
393+ if s := segs(strings.TrimSuffix(l.Text, "\r"), runs, slab[:len(runs)]); s != nil {
394+ f.Hunks[j].Lines[k].Segs = s
395+ slab = slab[len(runs):]
396+ }
397+ i++
398+ }
399+ }
400+ return true
401+}
402+
403+// diffHLBytes is the size of the text of the lines of a file.
404+func diffHLBytes(f domain.FileDiff) int {
405+ n, lines := 0, 0
406+ for _, h := range f.Hunks {
407+ for _, l := range h.Lines {
408+ n += len(l.Text) + 1
409+ }
410+ lines += len(h.Lines)
411+ }
412+ return n
413+}
414+
415+// diffKey names the colours of a file of a diff: its blobs name the text of
416+// both sides, so the hunks, and the language names the lexer. The number of
417+// hunks and lines is a check on top. "" for a file with no blob ids, which is
418+// not kept.
419+func diffKey(lang string, f domain.FileDiff) string {
420+ if f.OldID.IsZero() && f.NewID.IsZero() {
421+ return ""
422+ }
423+ lines := 0
424+ for _, h := range f.Hunks {
425+ lines += len(h.Lines)
426+ }
427+ return "d\x00" + lang + "\x00" + f.OldID.String() + "\x00" + f.NewID.String() + "\x00" +
428+ strconv.Itoa(len(f.Hunks)) + ":" + strconv.Itoa(lines)
429+}
430+
431+// files is diffFiles with the syntax colours: the files of a diff for the
432+// page, each one coloured from the cache or lexed. A nil cache lexes every
433+// time.
434+//
435+// A file is left plain when it is binary, truncated, of no known language or
436+// over a limit, and when it cannot be lexed now: every lexing slot is taken,
437+// the request has used up its share of text or its time. A file that is in
438+// the cache costs none of these.
439+func (c *hlCache) files(ctx context.Context, files []domain.FileDiff) []DiffFile {
440+ out := diffFiles(files)
441+ deadline := time.Now().Add(diffHLBudget)
442+ ctx, cancel := context.WithDeadline(ctx, deadline)
443+ defer cancel()
444+ // A lexing outlives the request that started it: the others that wait for
445+ // it, and the cache, want the result. It ends at the same deadline.
446+ bctx, bcancel := context.WithDeadline(context.WithoutCancel(ctx), deadline)
447+ defer bcancel()
448+
449+ left := maxDiffHLTotalBytes
450+ for i, f := range files {
451+ if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
452+ continue
453+ }
454+ if ctx.Err() != nil {
455+ return out
456+ }
457+ name := f.NewPath
458+ if f.Status == domain.FileDeleted {
459+ name = f.OldPath
460+ }
461+ // The size first: the language lookup is not free, and a file over a
462+ // limit does not need it.
463+ size := diffHLBytes(f)
464+ if size > maxDiffHLFileBytes {
465+ continue
466+ }
467+ // One lexer a file, set up once for all its lines and hunks.
468+ lx := diffLexer(name)
469+ if lx == nil {
470+ continue
471+ }
472+ d := c.load(ctx, diffKey(lx.Config().Name, f), func() *hlData {
473+ if size > left {
474+ return nil
475+ }
476+ release, ok := lexingSlot()
477+ if !ok {
478+ return nil
479+ }
480+ defer release()
481+ left -= size
482+ d, _ := computeFile(bctx, lx, f.Hunks)
483+ return d
484+ })
485+ if d != nil {
486+ applyFile(d, &out[i], f.Hunks)
487+ }
488+ }
489+ return out
490+}
491+
492+// highlightContext colours the lines [from, to) of a file, the lines that
493+// /context loads. The whole file is lexed (it is in memory already), not only
494+// the lines shown: a comment or a string that opens above them, or closes
495+// below them, is known. blob is the id of the file, what its colours are kept
496+// under. A file over the limits is plain, as is nil.
497+func (c *hlCache) highlightContext(ctx context.Context, blob, name string, lines []string, from, to int) [][]Seg {
498+ if from >= to || to > len(lines) || len(lines) > maxViewLines {
499+ return nil
500+ }
501+ lx := diffLexer(name)
502+ if lx == nil {
503+ return nil
504+ }
505+ size := 0
506+ trimmed := make([]string, len(lines))
507+ for i := range lines {
508+ trimmed[i] = strings.TrimSuffix(lines[i], "\r")
509+ size += len(trimmed[i]) + 1
510+ }
511+ if size > maxDiffHLFileBytes {
512+ return nil
513+ }
514+ key := ""
515+ if blob != "" {
516+ key = "c\x00" + lx.Config().Name + "\x00" + blob + "\x00" + strconv.Itoa(len(lines))
517+ }
518+ deadline := time.Now().Add(diffHLBudget)
519+ ctx, cancel := context.WithDeadline(ctx, deadline)
520+ defer cancel()
521+ bctx, bcancel := context.WithDeadline(context.WithoutCancel(ctx), deadline)
522+ defer bcancel()
523+ d := c.load(ctx, key, func() *hlData {
524+ release, ok := lexingSlot()
525+ if !ok {
526+ return nil
527+ }
528+ defer release()
529+ toks := lexLines(bctx, lx, trimmed)
530+ if bctx.Err() != nil {
531+ return nil
532+ }
533+ b := hlBuilder{d: hlData{lineEnd: make([]uint32, 0, len(lines)), runs: make([]hlRun, 0, 4*len(lines))}}
534+ for i, t := range trimmed {
535+ if toks != nil {
536+ b.line(t, toks[i])
537+ } else {
538+ b.line(t, nil)
539+ }
540+ }
541+ return &b.d
542+ })
543+ if d == nil || len(d.lineEnd) != len(lines) {
544+ return nil
545+ }
546+ out := make([][]Seg, to-from)
547+ for i := from; i < to; i++ {
548+ out[i-from] = segs(trimmed[i], d.line(i), nil)
549+ }
550+ return out
551+}
@@ -0,0 +1,551 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "slices"
8+ "strconv"
9+ "strings"
10+ "sync"
11+ "time"
12+ "unicode/utf8"
13+
14+ "github.com/alecthomas/chroma/v2"
15+
16+ "bnema.dev/ermite/internal/domain"
17+)
18+
19+// Syntax highlighting of the diffs. The tokens are the ones of the code view
20+// (highlight.go, the same lexers, the same classes, the colours of
21+// static/css/56-chroma.css); what differs is the input. A diff holds pieces
22+// of files, not files, so each hunk is lexed on its own, as two sequences:
23+// the old side (context and deleted lines) and the new side (context and
24+// added lines). A multi-line construct that starts and ends inside a hunk
25+// (a block comment, a raw string) is then coloured on all its lines, and a
26+// deleted line gets the colours it had in the old file, not the ones the
27+// added lines around it would give it. A hunk is lexed from its first line,
28+// so a construct that opens above it is not known: the first lines may be
29+// coloured as code (the usual limit of a diff view). The lines that /context
30+// loads come from the whole file, which is lexed as a whole.
31+//
32+// The work is bounded, since a page is served under load and the lexers are
33+// not cheap: a line, a file and a request have size limits (over them
34+// the text stays plain), a request lexes with one of the lexing slots of the
35+// code view (none free: plain) and a time budget, and the language is the
36+// one of the file name (none: plain). Text that is not lexed, or whose tokens
37+// do not add up to it, is shown as before.
38+//
39+// What a lexer finds is kept (hlcache.go) by the blobs the diff compares, so
40+// that the visitors of one merge request or commit, and its live updates, lex
41+// each file once. The colours are stored as runs (an end offset and a class a
42+// piece of line), not as text: they are put back on the lines of the diff
43+// that is being rendered.
44+
45+// maxDiffHLTotalBytes is the most text lexed for one request, the files of
46+// the diff together: the files after it are plain. A variable for the tests.
47+var maxDiffHLTotalBytes = 256 << 10
48+
49+const (
50+ // maxDiffHLFileBytes is the most text of one file of a diff that is
51+ // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 lines a
52+ // file; this keeps a file of very long lines out.
53+ maxDiffHLFileBytes = 128 << 10
54+ // maxDiffHLLine is the longest line that is lexed, in bytes: a minified
55+ // file is the slowest input for a lexer and gains nothing from colour.
56+ maxDiffHLLine = 2000
57+ // diffHLBudget is the time a request spends lexing; once over, the rest
58+ // of the diff is plain.
59+ diffHLBudget = 2 * time.Second
60+)
61+
62+// styledClasses are the token classes that 56-chroma.css gives a style. A
63+// token of any other class has none worth sending: its text joins the plain
64+// text around it. TestStyledClassesMatchCSS keeps it equal to the style sheet.
65+var styledClasses = map[string]bool{
66+ "c": true, "c1": true, "ch": true, "cm": true, "cs": true, "dl": true,
67+ "k": true, "kc": true, "kd": true, "kn": true, "kr": true,
68+ "nc": true, "ne": true, "nn": true, "ow": true,
69+ "s": true, "s1": true, "s2": true, "sa": true, "sb": true, "sc": true, "sd": true,
70+ "se": true, "sh": true, "si": true, "sr": true, "ss": true, "sx": true,
71+}
72+
73+// diffClass is the CSS class of a token type, "" when it has no style.
74+func diffClass(t chroma.TokenType) string {
75+ if c := tokenClass(t); styledClasses[c] {
76+ return c
77+ }
78+ return ""
79+}
80+
81+// lexerMemoMax bounds the memo of lexers by file name: the names come from
82+// the repositories, so it is emptied when full.
83+const lexerMemoMax = 1024
84+
85+var lexerMemo struct {
86+ sync.Mutex
87+ m map[string]chroma.Lexer
88+}
89+
90+// diffLexer is the lexer of a file name, nil for plain text. A name without
91+// a language, or whose lexer only lexes plain text, has none. Finding a lexer
92+// matches the name against every pattern chroma knows (it is as dear as lexing
93+// a few hundred bytes), so the answer is kept by file name; lexers are safe to
94+// share.
95+func diffLexer(name string) chroma.Lexer {
96+ lexerMemo.Lock()
97+ lx, ok := lexerMemo.m[name]
98+ lexerMemo.Unlock()
99+ if ok {
100+ return lx
101+ }
102+ if lx = lexerFor(name); lx != nil && lx.Config().Name == "plaintext" {
103+ lx = nil
104+ }
105+ lexerMemo.Lock()
106+ if lexerMemo.m == nil || len(lexerMemo.m) >= lexerMemoMax {
107+ lexerMemo.m = map[string]chroma.Lexer{}
108+ }
109+ lexerMemo.m[name] = lx
110+ lexerMemo.Unlock()
111+ return lx
112+}
113+
114+// lexingSlot takes one of the lexing slots of the code view without waiting;
115+// release gives it back. ok is false when they are all taken.
116+func lexingSlot() (release func(), ok bool) {
117+ select {
118+ case lexSlots <- struct{}{}:
119+ return func() { <-lexSlots }, true
120+ default:
121+ return nil, false
122+ }
123+}
124+
125+// lexable reports whether lines can be lexed and shown with the colours put
126+// back on them: valid UTF-8, no carriage return inside a line (the lexer
127+// would take it for a line end and the lines would no longer line up), no
128+// line too long.
129+func lexable(lines []string) bool {
130+ for _, l := range lines {
131+ if len(l) > maxDiffHLLine || strings.ContainsRune(l, '\r') || !utf8.ValidString(l) {
132+ return false
133+ }
134+ }
135+ return true
136+}
137+
138+// tokenBufs are the token buffers of lexLines.
139+var tokenBufs = sync.Pool{New: func() any { b := make([]chroma.Token, 0, 4096); return &b }}
140+
141+// lexLines lexes lines, texts without their line end, as one sequence and
142+// returns the tokens of each line. nil means no colours: the lexer failed,
143+// ran out of time, or its tokens do not line up with the lines.
144+func lexLines(ctx context.Context, lx chroma.Lexer, lines []string) [][]chroma.Token {
145+ if len(lines) == 0 || !lexable(lines) {
146+ return nil
147+ }
148+ n := 0
149+ for _, l := range lines {
150+ n += len(l) + 1
151+ }
152+ var b strings.Builder
153+ b.Grow(n)
154+ for _, l := range lines {
155+ b.WriteString(l)
156+ b.WriteByte('\n')
157+ }
158+ // The tokens of the whole text are a big slice, grown by doubling: one
159+ // buffer is reused from a lexing to the next. The lines get copies of the
160+ // tokens, so it is free again once they are split.
161+ buf := tokenBufs.Get().(*[]chroma.Token)
162+ toks, ok := tokeniseInto(ctx, lx, b.String(), (*buf)[:0])
163+ if !ok {
164+ tokenBufs.Put(buf)
165+ return nil
166+ }
167+ per := chroma.SplitTokensIntoLines(toks)
168+ clear(toks)
169+ *buf = toks[:0]
170+ tokenBufs.Put(buf)
171+ if len(per) != len(lines) {
172+ return nil
173+ }
174+ return per
175+}
176+
177+// classList are the classes a run can have, "" (plain) first; a run holds the
178+// index.
179+var classList = func() []string {
180+ l := []string{""}
181+ for c := range styledClasses {
182+ l = append(l, c)
183+ }
184+ slices.Sort(l[1:])
185+ return l
186+}()
187+
188+var classIndex = func() map[string]uint8 {
189+ m := make(map[string]uint8, len(classList))
190+ for i, c := range classList {
191+ m[c] = uint8(i)
192+ }
193+ return m
194+}()
195+
196+// hlRun is a piece of a line of one class: it ends at byte end.
197+type hlRun struct {
198+ end uint16
199+ class uint8
200+}
201+
202+// hlData are the colours of the lines of a file, the lines of its hunks in
203+// order or all of them: the runs of line i are runs[lineEnd[i-1]:lineEnd[i]],
204+// none for a plain line. It is read only once built, so it is shared by the
205+// requests that use it.
206+type hlData struct {
207+ lineEnd []uint32
208+ runs []hlRun
209+}
210+
211+// size is what the data weighs, for the budget of the cache: what its
212+// arrays hold, not what they use (computeFile trims them).
213+func (d *hlData) size() int { return 64 + 4*cap(d.lineEnd) + 4*cap(d.runs) }
214+
215+// line is the runs of line i.
216+func (d *hlData) line(i int) []hlRun {
217+ lo := uint32(0)
218+ if i > 0 {
219+ lo = d.lineEnd[i-1]
220+ }
221+ return d.runs[lo:d.lineEnd[i]]
222+}
223+
224+// segs puts the runs on the text of a line, in the room buf gives (nil: it
225+// allocates); a line with invisible characters needs more and grows apart.
226+// nil means plain: no run, or runs that do not cover the text (data of
227+// another text, which no key of the cache lets through, but a wrong colour is
228+// worse than none).
229+func segs(text string, runs []hlRun, buf []Seg) []Seg {
230+ if len(runs) == 0 || int(runs[len(runs)-1].end) != len(text) {
231+ return nil
232+ }
233+ var out []Seg
234+ if buf == nil {
235+ out = make([]Seg, 0, len(runs))
236+ } else {
237+ out = buf[:0:len(runs)]
238+ }
239+ start := 0
240+ for _, r := range runs {
241+ end := int(r.end)
242+ if end < start || end > len(text) || int(r.class) >= len(classList) {
243+ return nil
244+ }
245+ out = appendSegs(out, text[start:end], classList[r.class])
246+ start = end
247+ }
248+ return out
249+}
250+
251+// hlBuilder builds the hlData of a file, line by line.
252+type hlBuilder struct{ d hlData }
253+
254+// line adds the next line: its text and its tokens (nil for none). Tokens
255+// that do not add up to the text leave the line plain.
256+func (b *hlBuilder) line(text string, toks []chroma.Token) {
257+ mark := len(b.d.runs)
258+ if text != "" && len(toks) > 0 && !b.runs(text, toks) {
259+ b.d.runs = b.d.runs[:mark]
260+ }
261+ b.d.lineEnd = append(b.d.lineEnd, uint32(len(b.d.runs)))
262+}
263+
264+// runs appends the runs of a line, one for each stretch of tokens of the same
265+// class; false if the tokens do not add up to the text.
266+func (b *hlBuilder) runs(text string, toks []chroma.Token) bool {
267+ pos, class := 0, ""
268+ for _, t := range toks {
269+ v := strings.TrimSuffix(t.Value, "\n")
270+ if v == "" {
271+ continue
272+ }
273+ if c := diffClass(t.Type); c != class {
274+ if pos > 0 {
275+ b.d.runs = append(b.d.runs, hlRun{end: uint16(pos), class: classIndex[class]})
276+ }
277+ class = c
278+ }
279+ pos += len(v)
280+ if pos > len(text) {
281+ return false
282+ }
283+ }
284+ if pos != len(text) {
285+ return false
286+ }
287+ b.d.runs = append(b.d.runs, hlRun{end: uint16(pos), class: classIndex[class]})
288+ return true
289+}
290+
291+// hunk adds the lines of a hunk, lexed as two sequences, one a side. Context
292+// lines take their colours from the new side, or from the old side when the
293+// hunk adds nothing: a side that no line needs is not lexed.
294+func (b *hlBuilder) hunk(ctx context.Context, lx chroma.Lexer, src []domain.Line) {
295+ var oldTexts, newTexts []string
296+ hasAdd, hasDel := false, false
297+ for _, l := range src {
298+ t := strings.TrimSuffix(l.Text, "\r")
299+ if l.Kind != domain.LineAddition {
300+ oldTexts = append(oldTexts, t)
301+ } else {
302+ hasAdd = true
303+ }
304+ if l.Kind != domain.LineDeletion {
305+ newTexts = append(newTexts, t)
306+ } else {
307+ hasDel = true
308+ }
309+ }
310+ var oldToks, newToks [][]chroma.Token
311+ if hasDel || !hasAdd {
312+ oldToks = lexLines(ctx, lx, oldTexts)
313+ }
314+ if hasAdd {
315+ newToks = lexLines(ctx, lx, newTexts)
316+ }
317+ tokens := func(all [][]chroma.Token, i int) []chroma.Token {
318+ if i < len(all) {
319+ return all[i]
320+ }
321+ return nil
322+ }
323+ oi, ni := 0, 0
324+ for _, l := range src {
325+ switch l.Kind {
326+ case domain.LineDeletion:
327+ b.line(oldTexts[oi], tokens(oldToks, oi))
328+ oi++
329+ case domain.LineAddition:
330+ b.line(newTexts[ni], tokens(newToks, ni))
331+ ni++
332+ default:
333+ if hasAdd {
334+ b.line(newTexts[ni], tokens(newToks, ni))
335+ } else {
336+ b.line(oldTexts[oi], tokens(oldToks, oi))
337+ }
338+ oi++
339+ ni++
340+ }
341+ }
342+}
343+
344+// computeFile lexes the hunks of a file. ok is false when ctx ended, whatever
345+// was lexed is then not the whole of it and is not kept.
346+//
347+//hexcheck:ignore suspicious-business-logic-in-adapter cuts the lines of a diff into coloured runs for the page; no rule is decided here
348+func computeFile(ctx context.Context, lx chroma.Lexer, hunks []domain.Hunk) (d *hlData, ok bool) {
349+ n := 0
350+ for _, h := range hunks {
351+ n += len(h.Lines)
352+ }
353+ b := hlBuilder{d: hlData{lineEnd: make([]uint32, 0, n), runs: make([]hlRun, 0, 4*n)}}
354+ for _, h := range hunks {
355+ if ctx.Err() != nil {
356+ return nil, false
357+ }
358+ b.hunk(ctx, lx, h.Lines)
359+ }
360+ if ctx.Err() != nil {
361+ return nil, false
362+ }
363+ // The runs were reserved for four a line; a cached entry keeps only what
364+ // it uses (size counts the capacity).
365+ if cap(b.d.runs) > len(b.d.runs)+len(b.d.runs)/4+16 {
366+ b.d.runs = slices.Clone(b.d.runs)
367+ }
368+ return &b.d, true
369+}
370+
371+// applyFile puts the colours on the lines of a file; false when the data is
372+// not the one of this file (another number of lines), nothing is changed.
373+//
374+//hexcheck:ignore suspicious-business-logic-in-adapter puts coloured runs on the lines of a diff for the page; no rule is decided here
375+func applyFile(d *hlData, f *DiffFile, hunks []domain.Hunk) bool {
376+ n := 0
377+ for _, h := range hunks {
378+ n += len(h.Lines)
379+ }
380+ if len(d.lineEnd) != n || len(f.Hunks) != len(hunks) {
381+ return false
382+ }
383+ i := 0
384+ // The segments of all the lines come from one slab, not one allocation a
385+ // line: that is most of what a cached page costs.
386+ slab := make([]Seg, len(d.runs))
387+ for j, h := range hunks {
388+ if len(f.Hunks[j].Lines) != len(h.Lines) {
389+ return false
390+ }
391+ for k, l := range h.Lines {
392+ runs := d.line(i)
393+ if s := segs(strings.TrimSuffix(l.Text, "\r"), runs, slab[:len(runs)]); s != nil {
394+ f.Hunks[j].Lines[k].Segs = s
395+ slab = slab[len(runs):]
396+ }
397+ i++
398+ }
399+ }
400+ return true
401+}
402+
403+// diffHLBytes is the size of the text of the lines of a file.
404+func diffHLBytes(f domain.FileDiff) int {
405+ n, lines := 0, 0
406+ for _, h := range f.Hunks {
407+ for _, l := range h.Lines {
408+ n += len(l.Text) + 1
409+ }
410+ lines += len(h.Lines)
411+ }
412+ return n
413+}
414+
415+// diffKey names the colours of a file of a diff: its blobs name the text of
416+// both sides, so the hunks, and the language names the lexer. The number of
417+// hunks and lines is a check on top. "" for a file with no blob ids, which is
418+// not kept.
419+func diffKey(lang string, f domain.FileDiff) string {
420+ if f.OldID.IsZero() && f.NewID.IsZero() {
421+ return ""
422+ }
423+ lines := 0
424+ for _, h := range f.Hunks {
425+ lines += len(h.Lines)
426+ }
427+ return "d\x00" + lang + "\x00" + f.OldID.String() + "\x00" + f.NewID.String() + "\x00" +
428+ strconv.Itoa(len(f.Hunks)) + ":" + strconv.Itoa(lines)
429+}
430+
431+// files is diffFiles with the syntax colours: the files of a diff for the
432+// page, each one coloured from the cache or lexed. A nil cache lexes every
433+// time.
434+//
435+// A file is left plain when it is binary, truncated, of no known language or
436+// over a limit, and when it cannot be lexed now: every lexing slot is taken,
437+// the request has used up its share of text or its time. A file that is in
438+// the cache costs none of these.
439+func (c *hlCache) files(ctx context.Context, files []domain.FileDiff) []DiffFile {
440+ out := diffFiles(files)
441+ deadline := time.Now().Add(diffHLBudget)
442+ ctx, cancel := context.WithDeadline(ctx, deadline)
443+ defer cancel()
444+ // A lexing outlives the request that started it: the others that wait for
445+ // it, and the cache, want the result. It ends at the same deadline.
446+ bctx, bcancel := context.WithDeadline(context.WithoutCancel(ctx), deadline)
447+ defer bcancel()
448+
449+ left := maxDiffHLTotalBytes
450+ for i, f := range files {
451+ if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
452+ continue
453+ }
454+ if ctx.Err() != nil {
455+ return out
456+ }
457+ name := f.NewPath
458+ if f.Status == domain.FileDeleted {
459+ name = f.OldPath
460+ }
461+ // The size first: the language lookup is not free, and a file over a
462+ // limit does not need it.
463+ size := diffHLBytes(f)
464+ if size > maxDiffHLFileBytes {
465+ continue
466+ }
467+ // One lexer a file, set up once for all its lines and hunks.
468+ lx := diffLexer(name)
469+ if lx == nil {
470+ continue
471+ }
472+ d := c.load(ctx, diffKey(lx.Config().Name, f), func() *hlData {
473+ if size > left {
474+ return nil
475+ }
476+ release, ok := lexingSlot()
477+ if !ok {
478+ return nil
479+ }
480+ defer release()
481+ left -= size
482+ d, _ := computeFile(bctx, lx, f.Hunks)
483+ return d
484+ })
485+ if d != nil {
486+ applyFile(d, &out[i], f.Hunks)
487+ }
488+ }
489+ return out
490+}
491+
492+// highlightContext colours the lines [from, to) of a file, the lines that
493+// /context loads. The whole file is lexed (it is in memory already), not only
494+// the lines shown: a comment or a string that opens above them, or closes
495+// below them, is known. blob is the id of the file, what its colours are kept
496+// under. A file over the limits is plain, as is nil.
497+func (c *hlCache) highlightContext(ctx context.Context, blob, name string, lines []string, from, to int) [][]Seg {
498+ if from >= to || to > len(lines) || len(lines) > maxViewLines {
499+ return nil
500+ }
501+ lx := diffLexer(name)
502+ if lx == nil {
503+ return nil
504+ }
505+ size := 0
506+ trimmed := make([]string, len(lines))
507+ for i := range lines {
508+ trimmed[i] = strings.TrimSuffix(lines[i], "\r")
509+ size += len(trimmed[i]) + 1
510+ }
511+ if size > maxDiffHLFileBytes {
512+ return nil
513+ }
514+ key := ""
515+ if blob != "" {
516+ key = "c\x00" + lx.Config().Name + "\x00" + blob + "\x00" + strconv.Itoa(len(lines))
517+ }
518+ deadline := time.Now().Add(diffHLBudget)
519+ ctx, cancel := context.WithDeadline(ctx, deadline)
520+ defer cancel()
521+ bctx, bcancel := context.WithDeadline(context.WithoutCancel(ctx), deadline)
522+ defer bcancel()
523+ d := c.load(ctx, key, func() *hlData {
524+ release, ok := lexingSlot()
525+ if !ok {
526+ return nil
527+ }
528+ defer release()
529+ toks := lexLines(bctx, lx, trimmed)
530+ if bctx.Err() != nil {
531+ return nil
532+ }
533+ b := hlBuilder{d: hlData{lineEnd: make([]uint32, 0, len(lines)), runs: make([]hlRun, 0, 4*len(lines))}}
534+ for i, t := range trimmed {
535+ if toks != nil {
536+ b.line(t, toks[i])
537+ } else {
538+ b.line(t, nil)
539+ }
540+ }
541+ return &b.d
542+ })
543+ if d == nil || len(d.lineEnd) != len(lines) {
544+ return nil
545+ }
546+ out := make([][]Seg, to-from)
547+ for i := from; i < to; i++ {
548+ out[i-from] = segs(trimmed[i], d.line(i), nil)
549+ }
550+ return out
551+}
A internal/adapters/web/diff_highlight_test.go +374 -0
@@ -0,0 +1,374 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "regexp"
10+ "strings"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+)
18+
19+// hunkOf is a modified file with one hunk of the lines given as "+text",
20+// "-text" and " text".
21+func hunkOf(name string, lines ...string) domain.FileDiff {
22+ var ls []domain.Line
23+ o, n := 0, 0
24+ for _, s := range lines {
25+ switch s[0] {
26+ case '+':
27+ n++
28+ ls = append(ls, line(domain.LineAddition, 0, n, s[1:]))
29+ case '-':
30+ o++
31+ ls = append(ls, line(domain.LineDeletion, o, 0, s[1:]))
32+ default:
33+ o++
34+ n++
35+ ls = append(ls, line(domain.LineContext, o, n, s[1:]))
36+ }
37+ }
38+ return domain.FileDiff{
39+ Status: domain.FileModified, OldPath: name, NewPath: name,
40+ Hunks: []domain.Hunk{{OldStart: 1, OldLines: o, NewStart: 1, NewLines: n, Lines: ls}},
41+ }
42+}
43+
44+// rendered is the HTML of the code cell of every line of the first hunk.
45+func rendered(t *testing.T, f DiffFile) []string {
46+ t.Helper()
47+ var out []string
48+ for _, l := range f.Hunks[0].Lines {
49+ var b strings.Builder
50+ require.NoError(t, codeLine(l).Render(context.Background(), &b))
51+ out = append(out, b.String())
52+ }
53+ return out
54+}
55+
56+// uncachedFiles is the files of a diff coloured with no cache: every call
57+// lexes.
58+func uncachedFiles(ctx context.Context, files []domain.FileDiff) []DiffFile {
59+ return (*hlCache)(nil).files(ctx, files)
60+}
61+
62+func highlighted(t *testing.T, fd domain.FileDiff) DiffFile {
63+ t.Helper()
64+ return uncachedFiles(context.Background(), []domain.FileDiff{fd})[0]
65+}
66+
67+// A block comment that spans lines is a comment on each of them: the hunk is
68+// lexed as a sequence, not line by line.
69+func TestHighlightMultiLineComment(t *testing.T) {
70+ t.Parallel()
71+ f := highlighted(t, hunkOf("a.go",
72+ " package a",
73+ "+/* first",
74+ "+ second <b> */",
75+ "+var x = 1",
76+ ))
77+ got := rendered(t, f)
78+ assert.Equal(t, `<span class="kn">package</span> a`, got[0])
79+ assert.Equal(t, `<span class="cm">/* first</span>`, got[1])
80+ assert.Equal(t, `<span class="cm"> second &lt;b&gt; */</span>`, got[2])
81+ assert.Contains(t, got[3], `<span class="kd">var</span>`)
82+}
83+
84+// Each side is lexed on its own: a deleted line has the colours it had in
85+// the old file, whatever the added lines around it open.
86+func TestHighlightSidesApart(t *testing.T) {
87+ t.Parallel()
88+ f := highlighted(t, hunkOf("a.go",
89+ "-x := 1 // old",
90+ "-y := 2",
91+ "+/* new",
92+ "+y := 2 */",
93+ ))
94+ got := rendered(t, f)
95+ assert.NotContains(t, got[1], `class="cm"`, "the old side has no comment open")
96+ assert.Equal(t, `<span class="cm">/* new</span>`, got[2])
97+ assert.Equal(t, `<span class="cm">y := 2 */</span>`, got[3])
98+ assert.Contains(t, got[0], `<span class="c1">// old</span>`)
99+}
100+
101+// Whatever is coloured, the text of the line is untouched and escaped,
102+// invisible characters are markers, and the CR of a CRLF line is dropped.
103+func TestHighlightKeepsText(t *testing.T) {
104+ t.Parallel()
105+ texts := []string{
106+ `s := "<script>alert(1)</script>" // & "quoted"`,
107+ "if x { // \u202eadmin \u200b",
108+ "\tfunc() {}\r",
109+ "",
110+ `x := '<' + "é世"`,
111+ }
112+ var in []string
113+ for _, s := range texts {
114+ in = append(in, "+"+s)
115+ }
116+ f := highlighted(t, hunkOf("a.go", in...))
117+ plain := diffFiles([]domain.FileDiff{hunkOf("a.go", in...)})[0]
118+ for i, l := range f.Hunks[0].Lines {
119+ var got, want strings.Builder
120+ for _, s := range l.Segs {
121+ got.WriteString(s.Text)
122+ }
123+ for _, s := range plain.Hunks[0].Lines[i].Segs {
124+ want.WriteString(s.Text)
125+ }
126+ assert.Equal(t, want.String(), got.String(), "line %d", i)
127+ }
128+ for i, h := range rendered(t, f) {
129+ assert.NotContains(t, h, "<script>", "line %d", i)
130+ assert.NotContains(t, h, "\u202e")
131+ assert.NotContains(t, h, "\u200b")
132+ assert.NotContains(t, h, "style=")
133+ }
134+ assert.Contains(t, rendered(t, f)[1], `&lt;U+202E&gt;`)
135+ assert.Contains(t, rendered(t, f)[0], `&lt;script&gt;`)
136+}
137+
138+// Only the classes of the style sheet are ever written, and the sets agree.
139+func TestStyledClassesMatchCSS(t *testing.T) {
140+ t.Parallel()
141+ css, err := os.ReadFile("static/css/56-chroma.css")
142+ require.NoError(t, err)
143+ got := map[string]bool{}
144+ for _, m := range regexp.MustCompile(`(?m)^\s*\.chroma \.([a-z0-9]+) \{`).FindAllStringSubmatch(string(css), -1) {
145+ if m[1] != "line" && m[1] != "ctl" {
146+ got[m[1]] = true
147+ }
148+ }
149+ assert.Equal(t, got, styledClasses)
150+}
151+
152+// No language, a binary or a truncated file, a file over a limit, a line too
153+// long, a lone CR: the text stays as the plain diff has it.
154+func TestHighlightSkips(t *testing.T) {
155+ t.Parallel()
156+ plainOf := func(fd domain.FileDiff) []DiffFile { return diffFiles([]domain.FileDiff{fd}) }
157+ big := make([]string, 0, 6000)
158+ for range 6000 {
159+ big = append(big, "+var longer_name_of_a_variable = 12345 // and a comment")
160+ }
161+ longLine := "+x := \"" + strings.Repeat("a", maxDiffHLLine) + "\""
162+ binary := hunkOf("a.go", "+package a")
163+ binary.Binary = true
164+ truncated := hunkOf("a.go", "+package a")
165+ truncated.Truncated = true
166+ for name, fd := range map[string]domain.FileDiff{
167+ "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"),
168+ "plain text": hunkOf("notes.txt", "+package a"),
169+ "binary": binary,
170+ "truncated": truncated,
171+ "over the limit": hunkOf("big.go", big...),
172+ "long line": hunkOf("a.go", "+package a", longLine),
173+ "lone cr": hunkOf("a.go", "+package a", "+x := 1\r// y"),
174+ } {
175+ got := uncachedFiles(context.Background(), []domain.FileDiff{fd})
176+ assert.Equal(t, plainOf(fd), got, name)
177+ }
178+}
179+
180+// The files past the text budget of the request are plain. Small files and a
181+// small budget: only the byte accounting decides, never the time. Not
182+// parallel: it changes maxDiffHLTotalBytes.
183+func TestHighlightRequestBudget(t *testing.T) {
184+ saved := maxDiffHLTotalBytes
185+ t.Cleanup(func() { maxDiffHLTotalBytes = saved })
186+ line := "+var v = 1"
187+ one := diffHLBytes(hunkOf("a.go", line))
188+ maxDiffHLTotalBytes = 2 * one // two files fit, the third does not
189+ var files []domain.FileDiff
190+ for i := range 3 {
191+ files = append(files, hunkOf(fmt.Sprintf("f%d.go", i), line))
192+ }
193+ out := uncachedFiles(context.Background(), files)
194+ assert.Contains(t, rendered(t, out[0])[0], `class="kd"`)
195+ assert.Contains(t, rendered(t, out[1])[0], `class="kd"`)
196+ assert.NotContains(t, rendered(t, out[2])[0], `class=`, "over the budget of the request")
197+}
198+
199+// What a cached entry weighs is what it holds: a file of plain lines keeps
200+// no room reserved for runs it does not have.
201+func TestHighlightDataSize(t *testing.T) {
202+ t.Parallel()
203+ var lines []domain.Line
204+ for i := range 2000 {
205+ lines = append(lines, domain.Line{Kind: domain.LineAddition, NewNumber: i + 1})
206+ }
207+ d, ok := computeFile(context.Background(), diffLexer("a.go"), []domain.Hunk{{Lines: lines}})
208+ require.True(t, ok)
209+ assert.LessOrEqual(t, cap(d.runs), len(d.runs)+len(d.runs)/4+16, "the reserve is given back")
210+ assert.Equal(t, 64+4*cap(d.lineEnd)+4*cap(d.runs), d.size())
211+ assert.Less(t, d.size(), 10_000, "about 4 bytes a line, not 20")
212+}
213+
214+// With every lexing slot taken the diff is shown plain at once, not queued.
215+func TestHighlightNoSlot(t *testing.T) {
216+ for range cap(lexSlots) {
217+ lexSlots <- struct{}{}
218+ }
219+ defer func() {
220+ for range cap(lexSlots) {
221+ <-lexSlots
222+ }
223+ }()
224+ f := highlighted(t, hunkOf("a.go", "+package a"))
225+ assert.Equal(t, "package a", rendered(t, f)[0])
226+}
227+
228+// A cancelled request lexes nothing.
229+func TestHighlightCancelled(t *testing.T) {
230+ t.Parallel()
231+ ctx, cancel := context.WithCancel(context.Background())
232+ cancel()
233+ fd := hunkOf("a.go", "+package a")
234+ got := uncachedFiles(ctx, []domain.FileDiff{fd})
235+ assert.Equal(t, diffFiles([]domain.FileDiff{fd}), got)
236+}
237+
238+// The lines /context loads are coloured with the file lexed from its start:
239+// a comment that opens above them colours them.
240+func TestHighlightContext(t *testing.T) {
241+ t.Parallel()
242+ lines := []string{"package a", "/* open", "still open", "closed */", "var x = 1", "func f() {}"}
243+ segs := (*hlCache)(nil).highlightContext(context.Background(), "", "a.go", lines, 2, 5)
244+ require.Len(t, segs, 3)
245+ assert.Equal(t, []Seg{{Text: "still open", Class: "cm"}}, segs[0])
246+ assert.Equal(t, []Seg{{Text: "closed */", Class: "cm"}}, segs[1])
247+ assert.Equal(t, "var", segs[2][0].Text)
248+ assert.Equal(t, "kd", segs[2][0].Class)
249+
250+ assert.Nil(t, (*hlCache)(nil).highlightContext(context.Background(), "", "a.txt", lines, 2, 5), "no language")
251+ assert.Nil(t, (*hlCache)(nil).highlightContext(context.Background(), "", "a.go", lines, 2, 99), "past the end")
252+ assert.Nil(t, (*hlCache)(nil).highlightContext(context.Background(), "", "a.go", lines, 3, 3), "nothing to show")
253+}
254+
255+// goSource is n lines of Go with comments, strings and keywords.
256+func goSource(n int) []string {
257+ var out []string
258+ for i := 0; len(out) < n; i++ {
259+ out = append(out,
260+ fmt.Sprintf("func handler%d(w http.ResponseWriter, r *http.Request) error {", i),
261+ fmt.Sprintf("\t// serve the page number %d", i),
262+ fmt.Sprintf("\tname := r.URL.Query().Get(%q)", fmt.Sprint("key", i)),
263+ "\t/* a block",
264+ "\t comment */",
265+ "\tif name == \"\" {",
266+ "\t\treturn fmt.Errorf(\"missing %s: %w\", name, errNotFound)",
267+ "\t}",
268+ "\treturn nil",
269+ "}",
270+ )
271+ }
272+ return out[:n]
273+}
274+
275+// largeDiff is n files of 1,000 lines of Go with 250 lines changed in place
276+// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 lines a
277+// file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.
278+func largeDiff(n int) []domain.FileDiff {
279+ var files []domain.FileDiff
280+ for i := range n {
281+ src := goSource(1000)
282+ var ls []string
283+ for j, s := range src {
284+ switch j % 4 {
285+ case 0:
286+ ls = append(ls, "-"+s, "+"+s+" // changed")
287+ case 1:
288+ ls = append(ls, "+"+s)
289+ default:
290+ ls = append(ls, " "+s)
291+ }
292+ }
293+ f := hunkOf(fmt.Sprintf("pkg/f%d.go", i), ls...)
294+ f.OldID, f.NewID = fakeBlob(i, 0), fakeBlob(i, 1)
295+ files = append(files, f)
296+ }
297+ return files
298+}
299+
300+// blobID is a made-up blob id, a different one for each n and side.
301+func fakeBlob(n, side int) domain.ObjectID {
302+ id, err := domain.ParseObjectID(fmt.Sprintf("%038x%02x", n, side))
303+ if err != nil {
304+ panic(err)
305+ }
306+ return id
307+}
308+
309+// BenchmarkDiffHighlight measures the cost of the colours on a large diff:
310+// the conversion alone, the conversion with the colours, and the colours with
311+// the page rendering of every line; "capped" is a diff twice the size the
312+// request budget lexes, which shows the bound.
313+func BenchmarkDiffHighlight(b *testing.B) {
314+ files := largeDiff(6)
315+ ctx := context.Background()
316+ b.Run("plain", func(b *testing.B) {
317+ for b.Loop() {
318+ diffFiles(files)
319+ }
320+ })
321+ b.Run("highlighted", func(b *testing.B) {
322+ for b.Loop() {
323+ uncachedFiles(ctx, files)
324+ }
325+ })
326+ b.Run("highlighted+render", func(b *testing.B) {
327+ var w strings.Builder
328+ for b.Loop() {
329+ w.Reset()
330+ for _, f := range uncachedFiles(ctx, files) {
331+ for _, l := range f.Hunks[0].Lines {
332+ _ = codeLine(l).Render(ctx, &w)
333+ }
334+ }
335+ }
336+ })
337+ b.Run("cached", func(b *testing.B) {
338+ c := newHLCache(64 << 20)
339+ c.files(ctx, files) // the first visitor lexes
340+ for b.Loop() {
341+ c.files(ctx, files)
342+ }
343+ })
344+ b.Run("cached+render", func(b *testing.B) {
345+ c := newHLCache(64 << 20)
346+ c.files(ctx, files)
347+ var w strings.Builder
348+ for b.Loop() {
349+ w.Reset()
350+ for _, f := range c.files(ctx, files) {
351+ for _, l := range f.Hunks[0].Lines {
352+ _ = codeLine(l).Render(ctx, &w)
353+ }
354+ }
355+ }
356+ })
357+ b.Run("capped", func(b *testing.B) {
358+ over := largeDiff(14)
359+ for b.Loop() {
360+ uncachedFiles(ctx, over)
361+ }
362+ })
363+ b.Run("plain+render", func(b *testing.B) {
364+ var w strings.Builder
365+ for b.Loop() {
366+ w.Reset()
367+ for _, f := range diffFiles(files) {
368+ for _, l := range f.Hunks[0].Lines {
369+ _ = codeLine(l).Render(ctx, &w)
370+ }
371+ }
372+ }
373+ })
374+}
@@ -0,0 +1,374 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "regexp"
10+ "strings"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+)
18+
19+// hunkOf is a modified file with one hunk of the lines given as "+text",
20+// "-text" and " text".
21+func hunkOf(name string, lines ...string) domain.FileDiff {
22+ var ls []domain.Line
23+ o, n := 0, 0
24+ for _, s := range lines {
25+ switch s[0] {
26+ case '+':
27+ n++
28+ ls = append(ls, line(domain.LineAddition, 0, n, s[1:]))
29+ case '-':
30+ o++
31+ ls = append(ls, line(domain.LineDeletion, o, 0, s[1:]))
32+ default:
33+ o++
34+ n++
35+ ls = append(ls, line(domain.LineContext, o, n, s[1:]))
36+ }
37+ }
38+ return domain.FileDiff{
39+ Status: domain.FileModified, OldPath: name, NewPath: name,
40+ Hunks: []domain.Hunk{{OldStart: 1, OldLines: o, NewStart: 1, NewLines: n, Lines: ls}},
41+ }
42+}
43+
44+// rendered is the HTML of the code cell of every line of the first hunk.
45+func rendered(t *testing.T, f DiffFile) []string {
46+ t.Helper()
47+ var out []string
48+ for _, l := range f.Hunks[0].Lines {
49+ var b strings.Builder
50+ require.NoError(t, codeLine(l).Render(context.Background(), &b))
51+ out = append(out, b.String())
52+ }
53+ return out
54+}
55+
56+// uncachedFiles is the files of a diff coloured with no cache: every call
57+// lexes.
58+func uncachedFiles(ctx context.Context, files []domain.FileDiff) []DiffFile {
59+ return (*hlCache)(nil).files(ctx, files)
60+}
61+
62+func highlighted(t *testing.T, fd domain.FileDiff) DiffFile {
63+ t.Helper()
64+ return uncachedFiles(context.Background(), []domain.FileDiff{fd})[0]
65+}
66+
67+// A block comment that spans lines is a comment on each of them: the hunk is
68+// lexed as a sequence, not line by line.
69+func TestHighlightMultiLineComment(t *testing.T) {
70+ t.Parallel()
71+ f := highlighted(t, hunkOf("a.go",
72+ " package a",
73+ "+/* first",
74+ "+ second <b> */",
75+ "+var x = 1",
76+ ))
77+ got := rendered(t, f)
78+ assert.Equal(t, `<span class="kn">package</span> a`, got[0])
79+ assert.Equal(t, `<span class="cm">/* first</span>`, got[1])
80+ assert.Equal(t, `<span class="cm"> second &lt;b&gt; */</span>`, got[2])
81+ assert.Contains(t, got[3], `<span class="kd">var</span>`)
82+}
83+
84+// Each side is lexed on its own: a deleted line has the colours it had in
85+// the old file, whatever the added lines around it open.
86+func TestHighlightSidesApart(t *testing.T) {
87+ t.Parallel()
88+ f := highlighted(t, hunkOf("a.go",
89+ "-x := 1 // old",
90+ "-y := 2",
91+ "+/* new",
92+ "+y := 2 */",
93+ ))
94+ got := rendered(t, f)
95+ assert.NotContains(t, got[1], `class="cm"`, "the old side has no comment open")
96+ assert.Equal(t, `<span class="cm">/* new</span>`, got[2])
97+ assert.Equal(t, `<span class="cm">y := 2 */</span>`, got[3])
98+ assert.Contains(t, got[0], `<span class="c1">// old</span>`)
99+}
100+
101+// Whatever is coloured, the text of the line is untouched and escaped,
102+// invisible characters are markers, and the CR of a CRLF line is dropped.
103+func TestHighlightKeepsText(t *testing.T) {
104+ t.Parallel()
105+ texts := []string{
106+ `s := "<script>alert(1)</script>" // & "quoted"`,
107+ "if x { // \u202eadmin \u200b",
108+ "\tfunc() {}\r",
109+ "",
110+ `x := '<' + "é世"`,
111+ }
112+ var in []string
113+ for _, s := range texts {
114+ in = append(in, "+"+s)
115+ }
116+ f := highlighted(t, hunkOf("a.go", in...))
117+ plain := diffFiles([]domain.FileDiff{hunkOf("a.go", in...)})[0]
118+ for i, l := range f.Hunks[0].Lines {
119+ var got, want strings.Builder
120+ for _, s := range l.Segs {
121+ got.WriteString(s.Text)
122+ }
123+ for _, s := range plain.Hunks[0].Lines[i].Segs {
124+ want.WriteString(s.Text)
125+ }
126+ assert.Equal(t, want.String(), got.String(), "line %d", i)
127+ }
128+ for i, h := range rendered(t, f) {
129+ assert.NotContains(t, h, "<script>", "line %d", i)
130+ assert.NotContains(t, h, "\u202e")
131+ assert.NotContains(t, h, "\u200b")
132+ assert.NotContains(t, h, "style=")
133+ }
134+ assert.Contains(t, rendered(t, f)[1], `&lt;U+202E&gt;`)
135+ assert.Contains(t, rendered(t, f)[0], `&lt;script&gt;`)
136+}
137+
138+// Only the classes of the style sheet are ever written, and the sets agree.
139+func TestStyledClassesMatchCSS(t *testing.T) {
140+ t.Parallel()
141+ css, err := os.ReadFile("static/css/56-chroma.css")
142+ require.NoError(t, err)
143+ got := map[string]bool{}
144+ for _, m := range regexp.MustCompile(`(?m)^\s*\.chroma \.([a-z0-9]+) \{`).FindAllStringSubmatch(string(css), -1) {
145+ if m[1] != "line" && m[1] != "ctl" {
146+ got[m[1]] = true
147+ }
148+ }
149+ assert.Equal(t, got, styledClasses)
150+}
151+
152+// No language, a binary or a truncated file, a file over a limit, a line too
153+// long, a lone CR: the text stays as the plain diff has it.
154+func TestHighlightSkips(t *testing.T) {
155+ t.Parallel()
156+ plainOf := func(fd domain.FileDiff) []DiffFile { return diffFiles([]domain.FileDiff{fd}) }
157+ big := make([]string, 0, 6000)
158+ for range 6000 {
159+ big = append(big, "+var longer_name_of_a_variable = 12345 // and a comment")
160+ }
161+ longLine := "+x := \"" + strings.Repeat("a", maxDiffHLLine) + "\""
162+ binary := hunkOf("a.go", "+package a")
163+ binary.Binary = true
164+ truncated := hunkOf("a.go", "+package a")
165+ truncated.Truncated = true
166+ for name, fd := range map[string]domain.FileDiff{
167+ "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"),
168+ "plain text": hunkOf("notes.txt", "+package a"),
169+ "binary": binary,
170+ "truncated": truncated,
171+ "over the limit": hunkOf("big.go", big...),
172+ "long line": hunkOf("a.go", "+package a", longLine),
173+ "lone cr": hunkOf("a.go", "+package a", "+x := 1\r// y"),
174+ } {
175+ got := uncachedFiles(context.Background(), []domain.FileDiff{fd})
176+ assert.Equal(t, plainOf(fd), got, name)
177+ }
178+}
179+
180+// The files past the text budget of the request are plain. Small files and a
181+// small budget: only the byte accounting decides, never the time. Not
182+// parallel: it changes maxDiffHLTotalBytes.
183+func TestHighlightRequestBudget(t *testing.T) {
184+ saved := maxDiffHLTotalBytes
185+ t.Cleanup(func() { maxDiffHLTotalBytes = saved })
186+ line := "+var v = 1"
187+ one := diffHLBytes(hunkOf("a.go", line))
188+ maxDiffHLTotalBytes = 2 * one // two files fit, the third does not
189+ var files []domain.FileDiff
190+ for i := range 3 {
191+ files = append(files, hunkOf(fmt.Sprintf("f%d.go", i), line))
192+ }
193+ out := uncachedFiles(context.Background(), files)
194+ assert.Contains(t, rendered(t, out[0])[0], `class="kd"`)
195+ assert.Contains(t, rendered(t, out[1])[0], `class="kd"`)
196+ assert.NotContains(t, rendered(t, out[2])[0], `class=`, "over the budget of the request")
197+}
198+
199+// What a cached entry weighs is what it holds: a file of plain lines keeps
200+// no room reserved for runs it does not have.
201+func TestHighlightDataSize(t *testing.T) {
202+ t.Parallel()
203+ var lines []domain.Line
204+ for i := range 2000 {
205+ lines = append(lines, domain.Line{Kind: domain.LineAddition, NewNumber: i + 1})
206+ }
207+ d, ok := computeFile(context.Background(), diffLexer("a.go"), []domain.Hunk{{Lines: lines}})
208+ require.True(t, ok)
209+ assert.LessOrEqual(t, cap(d.runs), len(d.runs)+len(d.runs)/4+16, "the reserve is given back")
210+ assert.Equal(t, 64+4*cap(d.lineEnd)+4*cap(d.runs), d.size())
211+ assert.Less(t, d.size(), 10_000, "about 4 bytes a line, not 20")
212+}
213+
214+// With every lexing slot taken the diff is shown plain at once, not queued.
215+func TestHighlightNoSlot(t *testing.T) {
216+ for range cap(lexSlots) {
217+ lexSlots <- struct{}{}
218+ }
219+ defer func() {
220+ for range cap(lexSlots) {
221+ <-lexSlots
222+ }
223+ }()
224+ f := highlighted(t, hunkOf("a.go", "+package a"))
225+ assert.Equal(t, "package a", rendered(t, f)[0])
226+}
227+
228+// A cancelled request lexes nothing.
229+func TestHighlightCancelled(t *testing.T) {
230+ t.Parallel()
231+ ctx, cancel := context.WithCancel(context.Background())
232+ cancel()
233+ fd := hunkOf("a.go", "+package a")
234+ got := uncachedFiles(ctx, []domain.FileDiff{fd})
235+ assert.Equal(t, diffFiles([]domain.FileDiff{fd}), got)
236+}
237+
238+// The lines /context loads are coloured with the file lexed from its start:
239+// a comment that opens above them colours them.
240+func TestHighlightContext(t *testing.T) {
241+ t.Parallel()
242+ lines := []string{"package a", "/* open", "still open", "closed */", "var x = 1", "func f() {}"}
243+ segs := (*hlCache)(nil).highlightContext(context.Background(), "", "a.go", lines, 2, 5)
244+ require.Len(t, segs, 3)
245+ assert.Equal(t, []Seg{{Text: "still open", Class: "cm"}}, segs[0])
246+ assert.Equal(t, []Seg{{Text: "closed */", Class: "cm"}}, segs[1])
247+ assert.Equal(t, "var", segs[2][0].Text)
248+ assert.Equal(t, "kd", segs[2][0].Class)
249+
250+ assert.Nil(t, (*hlCache)(nil).highlightContext(context.Background(), "", "a.txt", lines, 2, 5), "no language")
251+ assert.Nil(t, (*hlCache)(nil).highlightContext(context.Background(), "", "a.go", lines, 2, 99), "past the end")
252+ assert.Nil(t, (*hlCache)(nil).highlightContext(context.Background(), "", "a.go", lines, 3, 3), "nothing to show")
253+}
254+
255+// goSource is n lines of Go with comments, strings and keywords.
256+func goSource(n int) []string {
257+ var out []string
258+ for i := 0; len(out) < n; i++ {
259+ out = append(out,
260+ fmt.Sprintf("func handler%d(w http.ResponseWriter, r *http.Request) error {", i),
261+ fmt.Sprintf("\t// serve the page number %d", i),
262+ fmt.Sprintf("\tname := r.URL.Query().Get(%q)", fmt.Sprint("key", i)),
263+ "\t/* a block",
264+ "\t comment */",
265+ "\tif name == \"\" {",
266+ "\t\treturn fmt.Errorf(\"missing %s: %w\", name, errNotFound)",
267+ "\t}",
268+ "\treturn nil",
269+ "}",
270+ )
271+ }
272+ return out[:n]
273+}
274+
275+// largeDiff is n files of 1,000 lines of Go with 250 lines changed in place
276+// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 lines a
277+// file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.
278+func largeDiff(n int) []domain.FileDiff {
279+ var files []domain.FileDiff
280+ for i := range n {
281+ src := goSource(1000)
282+ var ls []string
283+ for j, s := range src {
284+ switch j % 4 {
285+ case 0:
286+ ls = append(ls, "-"+s, "+"+s+" // changed")
287+ case 1:
288+ ls = append(ls, "+"+s)
289+ default:
290+ ls = append(ls, " "+s)
291+ }
292+ }
293+ f := hunkOf(fmt.Sprintf("pkg/f%d.go", i), ls...)
294+ f.OldID, f.NewID = fakeBlob(i, 0), fakeBlob(i, 1)
295+ files = append(files, f)
296+ }
297+ return files
298+}
299+
300+// blobID is a made-up blob id, a different one for each n and side.
301+func fakeBlob(n, side int) domain.ObjectID {
302+ id, err := domain.ParseObjectID(fmt.Sprintf("%038x%02x", n, side))
303+ if err != nil {
304+ panic(err)
305+ }
306+ return id
307+}
308+
309+// BenchmarkDiffHighlight measures the cost of the colours on a large diff:
310+// the conversion alone, the conversion with the colours, and the colours with
311+// the page rendering of every line; "capped" is a diff twice the size the
312+// request budget lexes, which shows the bound.
313+func BenchmarkDiffHighlight(b *testing.B) {
314+ files := largeDiff(6)
315+ ctx := context.Background()
316+ b.Run("plain", func(b *testing.B) {
317+ for b.Loop() {
318+ diffFiles(files)
319+ }
320+ })
321+ b.Run("highlighted", func(b *testing.B) {
322+ for b.Loop() {
323+ uncachedFiles(ctx, files)
324+ }
325+ })
326+ b.Run("highlighted+render", func(b *testing.B) {
327+ var w strings.Builder
328+ for b.Loop() {
329+ w.Reset()
330+ for _, f := range uncachedFiles(ctx, files) {
331+ for _, l := range f.Hunks[0].Lines {
332+ _ = codeLine(l).Render(ctx, &w)
333+ }
334+ }
335+ }
336+ })
337+ b.Run("cached", func(b *testing.B) {
338+ c := newHLCache(64 << 20)
339+ c.files(ctx, files) // the first visitor lexes
340+ for b.Loop() {
341+ c.files(ctx, files)
342+ }
343+ })
344+ b.Run("cached+render", func(b *testing.B) {
345+ c := newHLCache(64 << 20)
346+ c.files(ctx, files)
347+ var w strings.Builder
348+ for b.Loop() {
349+ w.Reset()
350+ for _, f := range c.files(ctx, files) {
351+ for _, l := range f.Hunks[0].Lines {
352+ _ = codeLine(l).Render(ctx, &w)
353+ }
354+ }
355+ }
356+ })
357+ b.Run("capped", func(b *testing.B) {
358+ over := largeDiff(14)
359+ for b.Loop() {
360+ uncachedFiles(ctx, over)
361+ }
362+ })
363+ b.Run("plain+render", func(b *testing.B) {
364+ var w strings.Builder
365+ for b.Loop() {
366+ w.Reset()
367+ for _, f := range diffFiles(files) {
368+ for _, l := range f.Hunks[0].Lines {
369+ _ = codeLine(l).Render(ctx, &w)
370+ }
371+ }
372+ }
373+ })
374+}
M internal/adapters/web/diff_templ.go +3 -3 generated file: collapsed
M internal/adapters/web/highlight.go +7 -0
@@ -144,6 +144,12 @@ func lexerFor(name string) chroma.Lexer {
144144 // lexer that gave up half-way (its regexp timeout) leaves behind. A lexer
145145 // may add a final line feed (EnsureNL), nothing else.
146146 func tokenise(ctx context.Context, lx chroma.Lexer, src string) (toks []chroma.Token, ok bool) {
147+ return tokeniseInto(ctx, lx, src, nil)
148+}
149+
150+// tokeniseInto is tokenise appending the tokens to buf (a buffer of length 0
151+// that the caller reuses); the result is nil when it reports false.
152+func tokeniseInto(ctx context.Context, lx chroma.Lexer, src string, buf []chroma.Token) (toks []chroma.Token, ok bool) {
147153 defer func() {
148154 if recover() != nil {
149155 toks, ok = nil, false
@@ -153,6 +159,7 @@ func tokenise(ctx context.Context, lx chroma.Lexer, src string) (toks []chroma.T
153159 if err != nil {
154160 return nil, false
155161 }
162+ toks = buf
156163 for t := it(); t != chroma.EOF; t = it() {
157164 if len(toks)%256 == 0 && ctx.Err() != nil {
158165 return nil, false
@@ -144,6 +144,12 @@ func lexerFor(name string) chroma.Lexer {
144 // lexer that gave up half-way (its regexp timeout) leaves behind. A lexer144 // lexer that gave up half-way (its regexp timeout) leaves behind. A lexer
145 // may add a final line feed (EnsureNL), nothing else.145 // may add a final line feed (EnsureNL), nothing else.
146 func tokenise(ctx context.Context, lx chroma.Lexer, src string) (toks []chroma.Token, ok bool) {146 func tokenise(ctx context.Context, lx chroma.Lexer, src string) (toks []chroma.Token, ok bool) {
147+ return tokeniseInto(ctx, lx, src, nil)
148+}
149+
150+// tokeniseInto is tokenise appending the tokens to buf (a buffer of length 0
151+// that the caller reuses); the result is nil when it reports false.
152+func tokeniseInto(ctx context.Context, lx chroma.Lexer, src string, buf []chroma.Token) (toks []chroma.Token, ok bool) {
147 defer func() {153 defer func() {
148 if recover() != nil {154 if recover() != nil {
149 toks, ok = nil, false155 toks, ok = nil, false
@@ -153,6 +159,7 @@ func tokenise(ctx context.Context, lx chroma.Lexer, src string) (toks []chroma.T
153 if err != nil {159 if err != nil {
154 return nil, false160 return nil, false
155 }161 }
162+ toks = buf
156 for t := it(); t != chroma.EOF; t = it() {163 for t := it(); t != chroma.EOF; t = it() {
157 if len(toks)%256 == 0 && ctx.Err() != nil {164 if len(toks)%256 == 0 && ctx.Err() != nil {
158 return nil, false165 return nil, false
A internal/adapters/web/hlcache.go +130 -0
@@ -0,0 +1,130 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "container/list"
7+ "context"
8+ "sync"
9+)
10+
11+// hlCacheBytes is the budget of the cache of highlighted diffs: a file of 36
12+// KB of code weighs about 50 KB there, so it holds a few hundred of them.
13+const hlCacheBytes = 8 << 20
14+
15+// hlCache keeps the colours of the files of diffs (hlData), least recently
16+// used out first, within a budget of bytes. A key names the blobs the colours
17+// come from (diffKey), so an entry is right for every visitor and every
18+// repository that has the blobs, forever; who may read the diff is decided
19+// before the cache is asked, and what is cached is runs of classes, never the
20+// text.
21+//
22+// Concurrent misses for one key share a single lexing: the first caller
23+// lexes, the others wait for it (or for their own ctx), as the figures of the
24+// home page do (usecase statsCache). A lexing that gave nothing (no free
25+// lexing slot, out of time or text, a failed lexer) is not kept: the next
26+// request tries again, and the callers that waited show the file plain.
27+//
28+// An entry over a quarter of the budget is not kept (one big file would push
29+// out most of the others).
30+//
31+// A nil *hlCache is a valid one that keeps nothing.
32+type hlCache struct {
33+ mu sync.Mutex
34+ budget int
35+ size int
36+ lru *list.List // of *hlEntry, most recently used first
37+ byKey map[string]*list.Element
38+ flight map[string]*hlFlight
39+}
40+
41+type hlEntry struct {
42+ key string
43+ data *hlData
44+ size int
45+}
46+
47+// hlFlight is a lexing in progress; done is closed when data is set.
48+type hlFlight struct {
49+ done chan struct{}
50+ data *hlData
51+ waiters int // callers that joined it, for the tests; guarded by hlCache.mu
52+}
53+
54+func newHLCache(budget int) *hlCache {
55+ return &hlCache{
56+ budget: budget, lru: list.New(),
57+ byKey: map[string]*list.Element{}, flight: map[string]*hlFlight{},
58+ }
59+}
60+
61+// entrySize is what an entry weighs against the budget: its data and its key.
62+func entrySize(key string, d *hlData) int { return d.size() + len(key) + 64 }
63+
64+// load returns the colours under key: cached, or from build, which runs once
65+// however many callers ask at the same time, in the goroutine of the first.
66+// build returns nil for no colours. An empty key is not cached: build runs.
67+func (c *hlCache) load(ctx context.Context, key string, build func() *hlData) *hlData {
68+ if c == nil || key == "" {
69+ return build()
70+ }
71+ c.mu.Lock()
72+ if el, ok := c.byKey[key]; ok {
73+ c.lru.MoveToFront(el)
74+ d := el.Value.(*hlEntry).data
75+ c.mu.Unlock()
76+ return d
77+ }
78+ if f, ok := c.flight[key]; ok {
79+ f.waiters++
80+ c.mu.Unlock()
81+ select {
82+ case <-f.done:
83+ return f.data
84+ case <-ctx.Done():
85+ return nil
86+ }
87+ }
88+ f := &hlFlight{done: make(chan struct{})}
89+ c.flight[key] = f
90+ c.mu.Unlock()
91+
92+ // Whatever build does, the flight ends: the callers that wait are freed,
93+ // by a panic too (which then goes on up to the server's recovery).
94+ defer func() {
95+ c.mu.Lock()
96+ delete(c.flight, key)
97+ if f.data != nil {
98+ c.putLocked(key, f.data)
99+ }
100+ c.mu.Unlock()
101+ close(f.done)
102+ }()
103+ f.data = build()
104+ return f.data
105+}
106+
107+// putLocked stores colours, evicting the least recently used ones to stay in
108+// budget. Colours over a quarter of the budget are not stored.
109+func (c *hlCache) putLocked(key string, d *hlData) {
110+ n := entrySize(key, d)
111+ if n > c.budget/4 {
112+ return
113+ }
114+ if el, ok := c.byKey[key]; ok {
115+ e := el.Value.(*hlEntry)
116+ c.size += n - e.size
117+ e.data, e.size = d, n
118+ c.lru.MoveToFront(el)
119+ } else {
120+ c.byKey[key] = c.lru.PushFront(&hlEntry{key: key, data: d, size: n})
121+ c.size += n
122+ }
123+ for c.size > c.budget {
124+ el := c.lru.Back()
125+ e := el.Value.(*hlEntry)
126+ c.lru.Remove(el)
127+ delete(c.byKey, e.key)
128+ c.size -= e.size
129+ }
130+}
@@ -0,0 +1,130 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "container/list"
7+ "context"
8+ "sync"
9+)
10+
11+// hlCacheBytes is the budget of the cache of highlighted diffs: a file of 36
12+// KB of code weighs about 50 KB there, so it holds a few hundred of them.
13+const hlCacheBytes = 8 << 20
14+
15+// hlCache keeps the colours of the files of diffs (hlData), least recently
16+// used out first, within a budget of bytes. A key names the blobs the colours
17+// come from (diffKey), so an entry is right for every visitor and every
18+// repository that has the blobs, forever; who may read the diff is decided
19+// before the cache is asked, and what is cached is runs of classes, never the
20+// text.
21+//
22+// Concurrent misses for one key share a single lexing: the first caller
23+// lexes, the others wait for it (or for their own ctx), as the figures of the
24+// home page do (usecase statsCache). A lexing that gave nothing (no free
25+// lexing slot, out of time or text, a failed lexer) is not kept: the next
26+// request tries again, and the callers that waited show the file plain.
27+//
28+// An entry over a quarter of the budget is not kept (one big file would push
29+// out most of the others).
30+//
31+// A nil *hlCache is a valid one that keeps nothing.
32+type hlCache struct {
33+ mu sync.Mutex
34+ budget int
35+ size int
36+ lru *list.List // of *hlEntry, most recently used first
37+ byKey map[string]*list.Element
38+ flight map[string]*hlFlight
39+}
40+
41+type hlEntry struct {
42+ key string
43+ data *hlData
44+ size int
45+}
46+
47+// hlFlight is a lexing in progress; done is closed when data is set.
48+type hlFlight struct {
49+ done chan struct{}
50+ data *hlData
51+ waiters int // callers that joined it, for the tests; guarded by hlCache.mu
52+}
53+
54+func newHLCache(budget int) *hlCache {
55+ return &hlCache{
56+ budget: budget, lru: list.New(),
57+ byKey: map[string]*list.Element{}, flight: map[string]*hlFlight{},
58+ }
59+}
60+
61+// entrySize is what an entry weighs against the budget: its data and its key.
62+func entrySize(key string, d *hlData) int { return d.size() + len(key) + 64 }
63+
64+// load returns the colours under key: cached, or from build, which runs once
65+// however many callers ask at the same time, in the goroutine of the first.
66+// build returns nil for no colours. An empty key is not cached: build runs.
67+func (c *hlCache) load(ctx context.Context, key string, build func() *hlData) *hlData {
68+ if c == nil || key == "" {
69+ return build()
70+ }
71+ c.mu.Lock()
72+ if el, ok := c.byKey[key]; ok {
73+ c.lru.MoveToFront(el)
74+ d := el.Value.(*hlEntry).data
75+ c.mu.Unlock()
76+ return d
77+ }
78+ if f, ok := c.flight[key]; ok {
79+ f.waiters++
80+ c.mu.Unlock()
81+ select {
82+ case <-f.done:
83+ return f.data
84+ case <-ctx.Done():
85+ return nil
86+ }
87+ }
88+ f := &hlFlight{done: make(chan struct{})}
89+ c.flight[key] = f
90+ c.mu.Unlock()
91+
92+ // Whatever build does, the flight ends: the callers that wait are freed,
93+ // by a panic too (which then goes on up to the server's recovery).
94+ defer func() {
95+ c.mu.Lock()
96+ delete(c.flight, key)
97+ if f.data != nil {
98+ c.putLocked(key, f.data)
99+ }
100+ c.mu.Unlock()
101+ close(f.done)
102+ }()
103+ f.data = build()
104+ return f.data
105+}
106+
107+// putLocked stores colours, evicting the least recently used ones to stay in
108+// budget. Colours over a quarter of the budget are not stored.
109+func (c *hlCache) putLocked(key string, d *hlData) {
110+ n := entrySize(key, d)
111+ if n > c.budget/4 {
112+ return
113+ }
114+ if el, ok := c.byKey[key]; ok {
115+ e := el.Value.(*hlEntry)
116+ c.size += n - e.size
117+ e.data, e.size = d, n
118+ c.lru.MoveToFront(el)
119+ } else {
120+ c.byKey[key] = c.lru.PushFront(&hlEntry{key: key, data: d, size: n})
121+ c.size += n
122+ }
123+ for c.size > c.budget {
124+ el := c.lru.Back()
125+ e := el.Value.(*hlEntry)
126+ c.lru.Remove(el)
127+ delete(c.byKey, e.key)
128+ c.size -= e.size
129+ }
130+}
A internal/adapters/web/hlcache_test.go +288 -0
@@ -0,0 +1,288 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+// dataOf is colours of n lines, about 8n+64 bytes.
21+func dataOf(n int) *hlData {
22+ d := &hlData{lineEnd: make([]uint32, n), runs: make([]hlRun, n)}
23+ for i := range n {
24+ d.runs[i] = hlRun{end: 3, class: 1}
25+ d.lineEnd[i] = uint32(i + 1)
26+ }
27+ return d
28+}
29+
30+func withID(f domain.FileDiff, n int) domain.FileDiff {
31+ f.OldID, f.NewID = fakeBlob(n, 0), fakeBlob(n, 1)
32+ return f
33+}
34+
35+// A file whose colours are in the cache is coloured with no lexing: with
36+// every lexing slot taken, a miss is plain and a hit is not.
37+func TestHLCacheHit(t *testing.T) {
38+ fd := withID(hunkOf("a.go", " package a", "+var x = 1 // c"), 1)
39+ c := newHLCache(1 << 20)
40+
41+ first := c.files(context.Background(), []domain.FileDiff{fd})
42+ assert.Contains(t, rendered(t, first[0])[1], `class="kd"`)
43+ assert.Len(t, c.byKey, 1)
44+
45+ for range cap(lexSlots) {
46+ lexSlots <- struct{}{}
47+ }
48+ defer func() {
49+ for range cap(lexSlots) {
50+ <-lexSlots
51+ }
52+ }()
53+ hit := c.files(context.Background(), []domain.FileDiff{fd})
54+ assert.Equal(t, first, hit, "same page from the cache")
55+
56+ other := c.files(context.Background(), []domain.FileDiff{withID(fd, 2)})
57+ assert.NotContains(t, rendered(t, other[0])[1], `class=`, "another blob: a miss, and no slot to lex")
58+ assert.Len(t, c.byKey, 1, "a failed lexing is not kept")
59+}
60+
61+// The cache holds colours, not text: the text of the page is the one of the
62+// diff being rendered, escaped as ever.
63+func TestHLCacheKeepsNoText(t *testing.T) {
64+ c := newHLCache(1 << 20)
65+ a := withID(hunkOf("a.go", "+x := \"<b>one</b>\""), 1)
66+ b := withID(hunkOf("a.go", "+x := \"<i>two</i>\""), 1) // same key, other text: cannot happen, but must not leak
67+ c.files(context.Background(), []domain.FileDiff{a})
68+ got := c.files(context.Background(), []domain.FileDiff{b})
69+ h := rendered(t, got[0])[0]
70+ assert.Contains(t, h, "&lt;i&gt;two&lt;/i&gt;")
71+ assert.NotContains(t, h, "one")
72+}
73+
74+// The budget is in bytes: the least recently used entries go first, a hit
75+// makes an entry young, and an entry over a quarter of the budget is not kept.
76+func TestHLCacheEvictsByBytes(t *testing.T) {
77+ t.Parallel()
78+ one := entrySize("k0", dataOf(100))
79+ c := newHLCache(one*4 + one/2) // four entries fit, not five; one is under a quarter
80+ ctx := context.Background()
81+ add := func(k string) {
82+ c.load(ctx, k, func() *hlData { return dataOf(100) })
83+ }
84+ add("k0")
85+ add("k1")
86+ add("k2")
87+ add("k3")
88+ assert.Len(t, c.byKey, 4)
89+ c.load(ctx, "k0", func() *hlData { t.Fatal("k0 is cached"); return nil }) // k0 is the youngest now
90+ add("k4") // pushes out k1, the oldest
91+ assert.Len(t, c.byKey, 4)
92+ assert.Contains(t, c.byKey, "k0")
93+ assert.NotContains(t, c.byKey, "k1")
94+ assert.LessOrEqual(t, c.size, c.budget)
95+
96+ // the sum of the sizes of the entries is what is counted
97+ sum := 0
98+ for _, el := range c.byKey {
99+ sum += el.Value.(*hlEntry).size
100+ }
101+ assert.Equal(t, sum, c.size)
102+
103+ // over a quarter of the budget: served, not kept, nothing pushed out
104+ big := 0
105+ c.load(ctx, "big", func() *hlData { big++; return dataOf(1000) })
106+ c.load(ctx, "big", func() *hlData { big++; return dataOf(1000) })
107+ assert.Equal(t, 2, big)
108+ assert.NotContains(t, c.byKey, "big")
109+ assert.Len(t, c.byKey, 4)
110+
111+ // many entries: the size never goes over the budget
112+ for i := range 200 {
113+ add(fmt.Sprintf("n%d", i))
114+ require.LessOrEqual(t, c.size, c.budget)
115+ }
116+}
117+
118+// A nil cache and an empty key keep nothing; a build that gives nothing is
119+// not kept and is tried again.
120+func TestHLCacheNotKept(t *testing.T) {
121+ t.Parallel()
122+ ctx := context.Background()
123+ n := 0
124+ build := func() *hlData { n++; return nil }
125+ var nilCache *hlCache
126+ nilCache.load(ctx, "k", build)
127+ newHLCache(1<<20).load(ctx, "", build)
128+ c := newHLCache(1 << 20)
129+ c.load(ctx, "k", build)
130+ c.load(ctx, "k", build)
131+ assert.Equal(t, 4, n)
132+ assert.Empty(t, c.byKey)
133+ assert.Empty(t, c.flight)
134+}
135+
136+// N concurrent first requests for one key lex once: the others wait for the
137+// first, and all get its data. Run with -race.
138+func TestHLCacheDedup(t *testing.T) {
139+ t.Parallel()
140+ c := newHLCache(1 << 20)
141+ const n = 32
142+ var builds atomic.Int32
143+ release := make(chan struct{})
144+ var wg sync.WaitGroup
145+ got := make([]*hlData, n)
146+ for i := range n {
147+ wg.Add(1)
148+ go func() {
149+ defer wg.Done()
150+ got[i] = c.load(context.Background(), "k", func() *hlData {
151+ builds.Add(1)
152+ <-release // hold the build until every other caller has joined
153+ return dataOf(10)
154+ })
155+ }()
156+ }
157+ require.Eventually(t, func() bool {
158+ c.mu.Lock()
159+ defer c.mu.Unlock()
160+ f := c.flight["k"]
161+ return f != nil && f.waiters == n-1
162+ }, 5*time.Second, time.Millisecond)
163+ close(release)
164+ wg.Wait()
165+
166+ assert.EqualValues(t, 1, builds.Load())
167+ for i := range n {
168+ require.NotNil(t, got[i])
169+ assert.Same(t, got[0], got[i], "one result for all")
170+ }
171+ assert.Empty(t, c.flight)
172+ assert.Len(t, c.byKey, 1)
173+}
174+
175+// The same through the diff: concurrent pages of one commit lex its file once.
176+func TestHLCacheDedupFiles(t *testing.T) {
177+ c := newHLCache(1 << 20)
178+ var lines []string
179+ for range 300 {
180+ lines = append(lines, "+var v = 1 // 0123456789")
181+ }
182+ fd := withID(hunkOf("a.go", lines...), 7)
183+ const n = 16
184+ var wg sync.WaitGroup
185+ pages := make([][]DiffFile, n)
186+ for i := range n {
187+ wg.Add(1)
188+ go func() {
189+ defer wg.Done()
190+ pages[i] = c.files(context.Background(), []domain.FileDiff{fd})
191+ }()
192+ }
193+ wg.Wait()
194+ for i := range n {
195+ assert.Equal(t, pages[0], pages[i])
196+ assert.Contains(t, rendered(t, pages[i][0])[0], `class="kd"`)
197+ }
198+ assert.Len(t, c.byKey, 1)
199+}
200+
201+// A waiter that gives up is freed at once and shows the file plain; the
202+// build goes on and its result is kept for the next request.
203+func TestHLCacheWaiterCancelled(t *testing.T) {
204+ t.Parallel()
205+ c := newHLCache(1 << 20)
206+ release := make(chan struct{})
207+ started := make(chan struct{})
208+ done := make(chan struct{})
209+ go func() {
210+ defer close(done)
211+ c.load(context.Background(), "k", func() *hlData { close(started); <-release; return dataOf(5) })
212+ }()
213+ <-started
214+ ctx, cancel := context.WithCancel(context.Background())
215+ cancel()
216+ assert.Nil(t, c.load(ctx, "k", func() *hlData { t.Error("joins the build"); return nil }))
217+ close(release)
218+ <-done
219+ assert.NotNil(t, c.load(context.Background(), "k", func() *hlData { t.Error("kept"); return nil }))
220+}
221+
222+// A build that panics frees the callers that wait and is not kept.
223+func TestHLCacheBuildPanics(t *testing.T) {
224+ t.Parallel()
225+ c := newHLCache(1 << 20)
226+ started := make(chan struct{})
227+ release := make(chan struct{})
228+ res := make(chan *hlData, 1)
229+ go func() {
230+ defer func() { _ = recover() }()
231+ c.load(context.Background(), "k", func() *hlData { close(started); <-release; panic("lexer bug") })
232+ }()
233+ <-started
234+ go func() { res <- c.load(context.Background(), "k", func() *hlData { return dataOf(1) }) }()
235+ require.Eventually(t, func() bool {
236+ c.mu.Lock()
237+ defer c.mu.Unlock()
238+ return c.flight["k"] != nil && c.flight["k"].waiters == 1
239+ }, 5*time.Second, time.Millisecond)
240+ close(release)
241+ select {
242+ case d := <-res:
243+ assert.Nil(t, d)
244+ case <-time.After(5 * time.Second):
245+ t.Fatal("the waiter is stuck")
246+ }
247+ assert.Empty(t, c.flight)
248+ assert.Empty(t, c.byKey)
249+}
250+
251+// The key tells apart what changes the colours: the blobs, the language, the
252+// shape of the hunks.
253+func TestDiffKey(t *testing.T) {
254+ t.Parallel()
255+ f := withID(hunkOf("a.go", " a", "+b"), 1)
256+ k := diffKey("Go", f)
257+ assert.NotEmpty(t, k)
258+ assert.Equal(t, k, diffKey("Go", f))
259+ assert.NotEqual(t, k, diffKey("C", f))
260+ assert.NotEqual(t, k, diffKey("Go", withID(f, 2)))
261+ assert.NotEqual(t, k, diffKey("Go", withID(hunkOf("a.go", " a", "+b", "+c"), 1)))
262+ assert.Empty(t, diffKey("Go", hunkOf("a.go", " a")), "no blob ids: not kept")
263+ assert.False(t, strings.Contains(k, "a.go"), "the path is not in the key: two paths with the same blobs share")
264+}
265+
266+// The lines /context loads are cached under the blob of the file.
267+func TestHLCacheContext(t *testing.T) {
268+ c := newHLCache(1 << 20)
269+ lines := []string{"package a", "/* open", "still open", "closed */", "var x = 1"}
270+ blob := fakeBlob(3, 1).String()
271+ ctx := context.Background()
272+ first := c.highlightContext(ctx, blob, "a.go", lines, 1, 4)
273+ require.Len(t, first, 3)
274+ assert.Equal(t, []Seg{{Text: "still open", Class: "cm"}}, first[1])
275+ assert.Len(t, c.byKey, 1)
276+
277+ for range cap(lexSlots) {
278+ lexSlots <- struct{}{}
279+ }
280+ defer func() {
281+ for range cap(lexSlots) {
282+ <-lexSlots
283+ }
284+ }()
285+ assert.Equal(t, first, c.highlightContext(ctx, blob, "a.go", lines, 1, 4), "from the cache")
286+ assert.Equal(t, first[1:], c.highlightContext(ctx, blob, "a.go", lines, 2, 4), "other window, same colours")
287+ assert.Nil(t, c.highlightContext(ctx, fakeBlob(4, 1).String(), "a.go", lines, 1, 4), "other blob: no slot, plain")
288+}
@@ -0,0 +1,288 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+// dataOf is colours of n lines, about 8n+64 bytes.
21+func dataOf(n int) *hlData {
22+ d := &hlData{lineEnd: make([]uint32, n), runs: make([]hlRun, n)}
23+ for i := range n {
24+ d.runs[i] = hlRun{end: 3, class: 1}
25+ d.lineEnd[i] = uint32(i + 1)
26+ }
27+ return d
28+}
29+
30+func withID(f domain.FileDiff, n int) domain.FileDiff {
31+ f.OldID, f.NewID = fakeBlob(n, 0), fakeBlob(n, 1)
32+ return f
33+}
34+
35+// A file whose colours are in the cache is coloured with no lexing: with
36+// every lexing slot taken, a miss is plain and a hit is not.
37+func TestHLCacheHit(t *testing.T) {
38+ fd := withID(hunkOf("a.go", " package a", "+var x = 1 // c"), 1)
39+ c := newHLCache(1 << 20)
40+
41+ first := c.files(context.Background(), []domain.FileDiff{fd})
42+ assert.Contains(t, rendered(t, first[0])[1], `class="kd"`)
43+ assert.Len(t, c.byKey, 1)
44+
45+ for range cap(lexSlots) {
46+ lexSlots <- struct{}{}
47+ }
48+ defer func() {
49+ for range cap(lexSlots) {
50+ <-lexSlots
51+ }
52+ }()
53+ hit := c.files(context.Background(), []domain.FileDiff{fd})
54+ assert.Equal(t, first, hit, "same page from the cache")
55+
56+ other := c.files(context.Background(), []domain.FileDiff{withID(fd, 2)})
57+ assert.NotContains(t, rendered(t, other[0])[1], `class=`, "another blob: a miss, and no slot to lex")
58+ assert.Len(t, c.byKey, 1, "a failed lexing is not kept")
59+}
60+
61+// The cache holds colours, not text: the text of the page is the one of the
62+// diff being rendered, escaped as ever.
63+func TestHLCacheKeepsNoText(t *testing.T) {
64+ c := newHLCache(1 << 20)
65+ a := withID(hunkOf("a.go", "+x := \"<b>one</b>\""), 1)
66+ b := withID(hunkOf("a.go", "+x := \"<i>two</i>\""), 1) // same key, other text: cannot happen, but must not leak
67+ c.files(context.Background(), []domain.FileDiff{a})
68+ got := c.files(context.Background(), []domain.FileDiff{b})
69+ h := rendered(t, got[0])[0]
70+ assert.Contains(t, h, "&lt;i&gt;two&lt;/i&gt;")
71+ assert.NotContains(t, h, "one")
72+}
73+
74+// The budget is in bytes: the least recently used entries go first, a hit
75+// makes an entry young, and an entry over a quarter of the budget is not kept.
76+func TestHLCacheEvictsByBytes(t *testing.T) {
77+ t.Parallel()
78+ one := entrySize("k0", dataOf(100))
79+ c := newHLCache(one*4 + one/2) // four entries fit, not five; one is under a quarter
80+ ctx := context.Background()
81+ add := func(k string) {
82+ c.load(ctx, k, func() *hlData { return dataOf(100) })
83+ }
84+ add("k0")
85+ add("k1")
86+ add("k2")
87+ add("k3")
88+ assert.Len(t, c.byKey, 4)
89+ c.load(ctx, "k0", func() *hlData { t.Fatal("k0 is cached"); return nil }) // k0 is the youngest now
90+ add("k4") // pushes out k1, the oldest
91+ assert.Len(t, c.byKey, 4)
92+ assert.Contains(t, c.byKey, "k0")
93+ assert.NotContains(t, c.byKey, "k1")
94+ assert.LessOrEqual(t, c.size, c.budget)
95+
96+ // the sum of the sizes of the entries is what is counted
97+ sum := 0
98+ for _, el := range c.byKey {
99+ sum += el.Value.(*hlEntry).size
100+ }
101+ assert.Equal(t, sum, c.size)
102+
103+ // over a quarter of the budget: served, not kept, nothing pushed out
104+ big := 0
105+ c.load(ctx, "big", func() *hlData { big++; return dataOf(1000) })
106+ c.load(ctx, "big", func() *hlData { big++; return dataOf(1000) })
107+ assert.Equal(t, 2, big)
108+ assert.NotContains(t, c.byKey, "big")
109+ assert.Len(t, c.byKey, 4)
110+
111+ // many entries: the size never goes over the budget
112+ for i := range 200 {
113+ add(fmt.Sprintf("n%d", i))
114+ require.LessOrEqual(t, c.size, c.budget)
115+ }
116+}
117+
118+// A nil cache and an empty key keep nothing; a build that gives nothing is
119+// not kept and is tried again.
120+func TestHLCacheNotKept(t *testing.T) {
121+ t.Parallel()
122+ ctx := context.Background()
123+ n := 0
124+ build := func() *hlData { n++; return nil }
125+ var nilCache *hlCache
126+ nilCache.load(ctx, "k", build)
127+ newHLCache(1<<20).load(ctx, "", build)
128+ c := newHLCache(1 << 20)
129+ c.load(ctx, "k", build)
130+ c.load(ctx, "k", build)
131+ assert.Equal(t, 4, n)
132+ assert.Empty(t, c.byKey)
133+ assert.Empty(t, c.flight)
134+}
135+
136+// N concurrent first requests for one key lex once: the others wait for the
137+// first, and all get its data. Run with -race.
138+func TestHLCacheDedup(t *testing.T) {
139+ t.Parallel()
140+ c := newHLCache(1 << 20)
141+ const n = 32
142+ var builds atomic.Int32
143+ release := make(chan struct{})
144+ var wg sync.WaitGroup
145+ got := make([]*hlData, n)
146+ for i := range n {
147+ wg.Add(1)
148+ go func() {
149+ defer wg.Done()
150+ got[i] = c.load(context.Background(), "k", func() *hlData {
151+ builds.Add(1)
152+ <-release // hold the build until every other caller has joined
153+ return dataOf(10)
154+ })
155+ }()
156+ }
157+ require.Eventually(t, func() bool {
158+ c.mu.Lock()
159+ defer c.mu.Unlock()
160+ f := c.flight["k"]
161+ return f != nil && f.waiters == n-1
162+ }, 5*time.Second, time.Millisecond)
163+ close(release)
164+ wg.Wait()
165+
166+ assert.EqualValues(t, 1, builds.Load())
167+ for i := range n {
168+ require.NotNil(t, got[i])
169+ assert.Same(t, got[0], got[i], "one result for all")
170+ }
171+ assert.Empty(t, c.flight)
172+ assert.Len(t, c.byKey, 1)
173+}
174+
175+// The same through the diff: concurrent pages of one commit lex its file once.
176+func TestHLCacheDedupFiles(t *testing.T) {
177+ c := newHLCache(1 << 20)
178+ var lines []string
179+ for range 300 {
180+ lines = append(lines, "+var v = 1 // 0123456789")
181+ }
182+ fd := withID(hunkOf("a.go", lines...), 7)
183+ const n = 16
184+ var wg sync.WaitGroup
185+ pages := make([][]DiffFile, n)
186+ for i := range n {
187+ wg.Add(1)
188+ go func() {
189+ defer wg.Done()
190+ pages[i] = c.files(context.Background(), []domain.FileDiff{fd})
191+ }()
192+ }
193+ wg.Wait()
194+ for i := range n {
195+ assert.Equal(t, pages[0], pages[i])
196+ assert.Contains(t, rendered(t, pages[i][0])[0], `class="kd"`)
197+ }
198+ assert.Len(t, c.byKey, 1)
199+}
200+
201+// A waiter that gives up is freed at once and shows the file plain; the
202+// build goes on and its result is kept for the next request.
203+func TestHLCacheWaiterCancelled(t *testing.T) {
204+ t.Parallel()
205+ c := newHLCache(1 << 20)
206+ release := make(chan struct{})
207+ started := make(chan struct{})
208+ done := make(chan struct{})
209+ go func() {
210+ defer close(done)
211+ c.load(context.Background(), "k", func() *hlData { close(started); <-release; return dataOf(5) })
212+ }()
213+ <-started
214+ ctx, cancel := context.WithCancel(context.Background())
215+ cancel()
216+ assert.Nil(t, c.load(ctx, "k", func() *hlData { t.Error("joins the build"); return nil }))
217+ close(release)
218+ <-done
219+ assert.NotNil(t, c.load(context.Background(), "k", func() *hlData { t.Error("kept"); return nil }))
220+}
221+
222+// A build that panics frees the callers that wait and is not kept.
223+func TestHLCacheBuildPanics(t *testing.T) {
224+ t.Parallel()
225+ c := newHLCache(1 << 20)
226+ started := make(chan struct{})
227+ release := make(chan struct{})
228+ res := make(chan *hlData, 1)
229+ go func() {
230+ defer func() { _ = recover() }()
231+ c.load(context.Background(), "k", func() *hlData { close(started); <-release; panic("lexer bug") })
232+ }()
233+ <-started
234+ go func() { res <- c.load(context.Background(), "k", func() *hlData { return dataOf(1) }) }()
235+ require.Eventually(t, func() bool {
236+ c.mu.Lock()
237+ defer c.mu.Unlock()
238+ return c.flight["k"] != nil && c.flight["k"].waiters == 1
239+ }, 5*time.Second, time.Millisecond)
240+ close(release)
241+ select {
242+ case d := <-res:
243+ assert.Nil(t, d)
244+ case <-time.After(5 * time.Second):
245+ t.Fatal("the waiter is stuck")
246+ }
247+ assert.Empty(t, c.flight)
248+ assert.Empty(t, c.byKey)
249+}
250+
251+// The key tells apart what changes the colours: the blobs, the language, the
252+// shape of the hunks.
253+func TestDiffKey(t *testing.T) {
254+ t.Parallel()
255+ f := withID(hunkOf("a.go", " a", "+b"), 1)
256+ k := diffKey("Go", f)
257+ assert.NotEmpty(t, k)
258+ assert.Equal(t, k, diffKey("Go", f))
259+ assert.NotEqual(t, k, diffKey("C", f))
260+ assert.NotEqual(t, k, diffKey("Go", withID(f, 2)))
261+ assert.NotEqual(t, k, diffKey("Go", withID(hunkOf("a.go", " a", "+b", "+c"), 1)))
262+ assert.Empty(t, diffKey("Go", hunkOf("a.go", " a")), "no blob ids: not kept")
263+ assert.False(t, strings.Contains(k, "a.go"), "the path is not in the key: two paths with the same blobs share")
264+}
265+
266+// The lines /context loads are cached under the blob of the file.
267+func TestHLCacheContext(t *testing.T) {
268+ c := newHLCache(1 << 20)
269+ lines := []string{"package a", "/* open", "still open", "closed */", "var x = 1"}
270+ blob := fakeBlob(3, 1).String()
271+ ctx := context.Background()
272+ first := c.highlightContext(ctx, blob, "a.go", lines, 1, 4)
273+ require.Len(t, first, 3)
274+ assert.Equal(t, []Seg{{Text: "still open", Class: "cm"}}, first[1])
275+ assert.Len(t, c.byKey, 1)
276+
277+ for range cap(lexSlots) {
278+ lexSlots <- struct{}{}
279+ }
280+ defer func() {
281+ for range cap(lexSlots) {
282+ <-lexSlots
283+ }
284+ }()
285+ assert.Equal(t, first, c.highlightContext(ctx, blob, "a.go", lines, 1, 4), "from the cache")
286+ assert.Equal(t, first[1:], c.highlightContext(ctx, blob, "a.go", lines, 2, 4), "other window, same colours")
287+ assert.Nil(t, c.highlightContext(ctx, fakeBlob(4, 1).String(), "a.go", lines, 1, 4), "other blob: no slot, plain")
288+}
M internal/adapters/web/mr.go +1 -1
@@ -476,7 +476,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
476476 NFiles: res.Stats.FilesChanged,
477477 Adds: res.Stats.Additions,
478478 Dels: res.Stats.Deletions,
479- Files: diffFiles(res.Diff.Files),
479+ Files: s.hl.files(ctx, res.Diff.Files),
480480 }
481481 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
482482 if desc := m.Description.String(); strings.TrimSpace(desc) != "" {
@@ -476,7 +476,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
476 NFiles: res.Stats.FilesChanged,476 NFiles: res.Stats.FilesChanged,
477 Adds: res.Stats.Additions,477 Adds: res.Stats.Additions,
478 Dels: res.Stats.Deletions,478 Dels: res.Stats.Deletions,
479- Files: diffFiles(res.Diff.Files),479+ Files: s.hl.files(ctx, res.Diff.Files),
480 }480 }
481 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))481 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
482 if desc := m.Description.String(); strings.TrimSpace(desc) != "" {482 if desc := m.Description.String(); strings.TrimSpace(desc) != "" {
M internal/adapters/web/mr_files.go +1 -1
@@ -196,7 +196,7 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
196196 m := res.MergeRequest
197197 signed := SignedIn(ctx)
198198 canWrite := signed && m.State == domain.MRStateOpen && !since
199- p := MRPage{Files: diffFiles(res.Diff.Files), LooseInFiles: true}
199+ p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true}
200200 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
201201 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
202202 window := p.Files[from:min(from+mrWindow, len(p.Files))]
@@ -196,7 +196,7 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
196 m := res.MergeRequest196 m := res.MergeRequest
197 signed := SignedIn(ctx)197 signed := SignedIn(ctx)
198 canWrite := signed && m.State == domain.MRStateOpen && !since198 canWrite := signed && m.State == domain.MRStateOpen && !since
199- p := MRPage{Files: diffFiles(res.Diff.Files), LooseInFiles: true}199+ p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true}
200 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))200 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
201 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)201 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
202 window := p.Files[from:min(from+mrWindow, len(p.Files))]202 window := p.Files[from:min(from+mrWindow, len(p.Files))]
M internal/adapters/web/static/css/20-shell.css +3 -0
@@ -44,6 +44,9 @@
4444 --hdr-h is its height, the offset of the sticky bars of the diffs. */
4545 body { --hdr-h: 57px; }
4646 body:has(.hdr .tabs) { --hdr-h: 101px; }
47+ /* A row the cursor scrolls to (j / k, scrollIntoView) stops below the
48+ sticky header, not under it. */
49+ [data-rows] > * { scroll-margin-top: calc(var(--hdr-h) + var(--s3)); scroll-margin-bottom: var(--s3); }
4750 .hdr {
4851 position: sticky; top: 0; z-index: 20;
4952 background: var(--bg-surface); border-bottom: 1px solid var(--border);
@@ -44,6 +44,9 @@
44 --hdr-h is its height, the offset of the sticky bars of the diffs. */44 --hdr-h is its height, the offset of the sticky bars of the diffs. */
45 body { --hdr-h: 57px; }45 body { --hdr-h: 57px; }
46 body:has(.hdr .tabs) { --hdr-h: 101px; }46 body:has(.hdr .tabs) { --hdr-h: 101px; }
47+ /* A row the cursor scrolls to (j / k, scrollIntoView) stops below the
48+ sticky header, not under it. */
49+ [data-rows] > * { scroll-margin-top: calc(var(--hdr-h) + var(--s3)); scroll-margin-bottom: var(--s3); }
47 .hdr {50 .hdr {
48 position: sticky; top: 0; z-index: 20;51 position: sticky; top: 0; z-index: 20;
49 background: var(--bg-surface); border-bottom: 1px solid var(--border);52 background: var(--bg-surface); border-bottom: 1px solid var(--border);
M internal/adapters/web/web.go +4 -1
@@ -108,7 +108,9 @@ type Server struct {
108108 md *markdown
109109 // pathCache holds the path lists the file finder fetches.
110110 pathCache *pathCache
111- mux *http.ServeMux
111+ // hl holds the colours of highlighted diffs.
112+ hl *hlCache
113+ mux *http.ServeMux
112114 // ownerRepo is the owner login as a repository name, zero when it is not
113115 // a valid one (then no repository can be named like the owner).
114116 ownerRepo domain.RepoName
@@ -141,6 +143,7 @@ func New(cfg Config) (*Server, error) {
141143 s := &Server{
142144 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
143145 pathCache: newPathCache(pathCacheBytes, pathBuilds),
146+ hl: newHLCache(hlCacheBytes),
144147 origin: originOf(u),
145148 }
146149 s.ownerRepo, _ = domain.ParseRepoName(cfg.Owner)
@@ -108,7 +108,9 @@ type Server struct {
108 md *markdown108 md *markdown
109 // pathCache holds the path lists the file finder fetches.109 // pathCache holds the path lists the file finder fetches.
110 pathCache *pathCache110 pathCache *pathCache
111- mux *http.ServeMux111+ // hl holds the colours of highlighted diffs.
112+ hl *hlCache
113+ mux *http.ServeMux
112 // ownerRepo is the owner login as a repository name, zero when it is not114 // ownerRepo is the owner login as a repository name, zero when it is not
113 // a valid one (then no repository can be named like the owner).115 // a valid one (then no repository can be named like the owner).
114 ownerRepo domain.RepoName116 ownerRepo domain.RepoName
@@ -141,6 +143,7 @@ func New(cfg Config) (*Server, error) {
141 s := &Server{143 s := &Server{
142 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),144 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
143 pathCache: newPathCache(pathCacheBytes, pathBuilds),145 pathCache: newPathCache(pathCacheBytes, pathBuilds),
146+ hl: newHLCache(hlCacheBytes),
144 origin: originOf(u),147 origin: originOf(u),
145 }148 }
146 s.ownerRepo, _ = domain.ParseRepoName(cfg.Owner)149 s.ownerRepo, _ = domain.ParseRepoName(cfg.Owner)
M internal/app/testdata/web/commit-merge.html +2 -2
@@ -142,7 +142,7 @@
142142 </td>
143143 <td class="ln">1</td>
144144 <td class="m">+</td>
145-<td class="tx">from the side</td>
145+<td class="tx chroma">from the side</td>
146146 </tr>
147147 </tbody>
148148 </table>
@@ -168,7 +168,7 @@
168168 </td>
169169 <td class="ln add">1</td>
170170 <td class="m add">+</td>
171-<td class="tx add">from the side</td>
171+<td class="tx chroma add">from the side</td>
172172 </tr>
173173 </tbody>
174174 </table>
@@ -142,7 +142,7 @@
142 </td>142 </td>
143 <td class="ln">1</td>143 <td class="ln">1</td>
144 <td class="m">+</td>144 <td class="m">+</td>
145-<td class="tx">from the side</td>145+<td class="tx chroma">from the side</td>
146 </tr>146 </tr>
147 </tbody>147 </tbody>
148 </table>148 </table>
@@ -168,7 +168,7 @@
168 </td>168 </td>
169 <td class="ln add">1</td>169 <td class="ln add">1</td>
170 <td class="m add">+</td>170 <td class="m add">+</td>
171-<td class="tx add">from the side</td>171+<td class="tx chroma add">from the side</td>
172 </tr>172 </tr>
173 </tbody>173 </tbody>
174 </table>174 </table>
M internal/app/testdata/web/commit-modify-split.html +18 -18
@@ -149,49 +149,49 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
149149 <td class="ln">1</td>
150150 <td class="m">
151151 </td>
152-<td class="tx">one</td>
152+<td class="tx chroma">one</td>
153153 </tr>
154154 <tr class="del">
155155 <td class="ln">2</td>
156156 <td class="ln">
157157 </td>
158158 <td class="m">-</td>
159-<td class="tx">two</td>
159+<td class="tx chroma">two</td>
160160 </tr>
161161 <tr class="add">
162162 <td class="ln">
163163 </td>
164164 <td class="ln">2</td>
165165 <td class="m">+</td>
166-<td class="tx">2</td>
166+<td class="tx chroma">2</td>
167167 </tr>
168168 <tr class="ctx">
169169 <td class="ln">3</td>
170170 <td class="ln">3</td>
171171 <td class="m">
172172 </td>
173-<td class="tx">three</td>
173+<td class="tx chroma">three</td>
174174 </tr>
175175 <tr class="ctx">
176176 <td class="ln">4</td>
177177 <td class="ln">4</td>
178178 <td class="m">
179179 </td>
180-<td class="tx">four</td>
180+<td class="tx chroma">four</td>
181181 </tr>
182182 <tr class="ctx">
183183 <td class="ln">5</td>
184184 <td class="ln">5</td>
185185 <td class="m">
186186 </td>
187-<td class="tx">five</td>
187+<td class="tx chroma">five</td>
188188 </tr>
189189 <tr class="add">
190190 <td class="ln">
191191 </td>
192192 <td class="ln">6</td>
193193 <td class="m">+</td>
194-<td class="tx">six</td>
194+<td class="tx chroma">six</td>
195195 </tr>
196196 </tbody>
197197 </table>
@@ -212,49 +212,49 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
212212 <td class="ln ctx">1</td>
213213 <td class="m ctx">
214214 </td>
215-<td class="tx ctx">one</td>
215+<td class="tx chroma ctx">one</td>
216216 <td class="ln ctx">1</td>
217217 <td class="m ctx">
218218 </td>
219-<td class="tx ctx">one</td>
219+<td class="tx chroma ctx">one</td>
220220 </tr>
221221 <tr>
222222 <td class="ln del">2</td>
223223 <td class="m del">-</td>
224-<td class="tx del">two</td>
224+<td class="tx chroma del">two</td>
225225 <td class="ln add">2</td>
226226 <td class="m add">+</td>
227-<td class="tx add">2</td>
227+<td class="tx chroma add">2</td>
228228 </tr>
229229 <tr>
230230 <td class="ln ctx">3</td>
231231 <td class="m ctx">
232232 </td>
233-<td class="tx ctx">three</td>
233+<td class="tx chroma ctx">three</td>
234234 <td class="ln ctx">3</td>
235235 <td class="m ctx">
236236 </td>
237-<td class="tx ctx">three</td>
237+<td class="tx chroma ctx">three</td>
238238 </tr>
239239 <tr>
240240 <td class="ln ctx">4</td>
241241 <td class="m ctx">
242242 </td>
243-<td class="tx ctx">four</td>
243+<td class="tx chroma ctx">four</td>
244244 <td class="ln ctx">4</td>
245245 <td class="m ctx">
246246 </td>
247-<td class="tx ctx">four</td>
247+<td class="tx chroma ctx">four</td>
248248 </tr>
249249 <tr>
250250 <td class="ln ctx">5</td>
251251 <td class="m ctx">
252252 </td>
253-<td class="tx ctx">five</td>
253+<td class="tx chroma ctx">five</td>
254254 <td class="ln ctx">5</td>
255255 <td class="m ctx">
256256 </td>
257-<td class="tx ctx">five</td>
257+<td class="tx chroma ctx">five</td>
258258 </tr>
259259 <tr>
260260 <td class="ln none">
@@ -265,7 +265,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
265265 </td>
266266 <td class="ln add">6</td>
267267 <td class="m add">+</td>
268-<td class="tx add">six</td>
268+<td class="tx chroma add">six</td>
269269 </tr>
270270 </tbody>
271271 </table>
@@ -149,49 +149,49 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
149 <td class="ln">1</td>149 <td class="ln">1</td>
150 <td class="m">150 <td class="m">
151 </td>151 </td>
152-<td class="tx">one</td>152+<td class="tx chroma">one</td>
153 </tr>153 </tr>
154 <tr class="del">154 <tr class="del">
155 <td class="ln">2</td>155 <td class="ln">2</td>
156 <td class="ln">156 <td class="ln">
157 </td>157 </td>
158 <td class="m">-</td>158 <td class="m">-</td>
159-<td class="tx">two</td>159+<td class="tx chroma">two</td>
160 </tr>160 </tr>
161 <tr class="add">161 <tr class="add">
162 <td class="ln">162 <td class="ln">
163 </td>163 </td>
164 <td class="ln">2</td>164 <td class="ln">2</td>
165 <td class="m">+</td>165 <td class="m">+</td>
166-<td class="tx">2</td>166+<td class="tx chroma">2</td>
167 </tr>167 </tr>
168 <tr class="ctx">168 <tr class="ctx">
169 <td class="ln">3</td>169 <td class="ln">3</td>
170 <td class="ln">3</td>170 <td class="ln">3</td>
171 <td class="m">171 <td class="m">
172 </td>172 </td>
173-<td class="tx">three</td>173+<td class="tx chroma">three</td>
174 </tr>174 </tr>
175 <tr class="ctx">175 <tr class="ctx">
176 <td class="ln">4</td>176 <td class="ln">4</td>
177 <td class="ln">4</td>177 <td class="ln">4</td>
178 <td class="m">178 <td class="m">
179 </td>179 </td>
180-<td class="tx">four</td>180+<td class="tx chroma">four</td>
181 </tr>181 </tr>
182 <tr class="ctx">182 <tr class="ctx">
183 <td class="ln">5</td>183 <td class="ln">5</td>
184 <td class="ln">5</td>184 <td class="ln">5</td>
185 <td class="m">185 <td class="m">
186 </td>186 </td>
187-<td class="tx">five</td>187+<td class="tx chroma">five</td>
188 </tr>188 </tr>
189 <tr class="add">189 <tr class="add">
190 <td class="ln">190 <td class="ln">
191 </td>191 </td>
192 <td class="ln">6</td>192 <td class="ln">6</td>
193 <td class="m">+</td>193 <td class="m">+</td>
194-<td class="tx">six</td>194+<td class="tx chroma">six</td>
195 </tr>195 </tr>
196 </tbody>196 </tbody>
197 </table>197 </table>
@@ -212,49 +212,49 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
212 <td class="ln ctx">1</td>212 <td class="ln ctx">1</td>
213 <td class="m ctx">213 <td class="m ctx">
214 </td>214 </td>
215-<td class="tx ctx">one</td>215+<td class="tx chroma ctx">one</td>
216 <td class="ln ctx">1</td>216 <td class="ln ctx">1</td>
217 <td class="m ctx">217 <td class="m ctx">
218 </td>218 </td>
219-<td class="tx ctx">one</td>219+<td class="tx chroma ctx">one</td>
220 </tr>220 </tr>
221 <tr>221 <tr>
222 <td class="ln del">2</td>222 <td class="ln del">2</td>
223 <td class="m del">-</td>223 <td class="m del">-</td>
224-<td class="tx del">two</td>224+<td class="tx chroma del">two</td>
225 <td class="ln add">2</td>225 <td class="ln add">2</td>
226 <td class="m add">+</td>226 <td class="m add">+</td>
227-<td class="tx add">2</td>227+<td class="tx chroma add">2</td>
228 </tr>228 </tr>
229 <tr>229 <tr>
230 <td class="ln ctx">3</td>230 <td class="ln ctx">3</td>
231 <td class="m ctx">231 <td class="m ctx">
232 </td>232 </td>
233-<td class="tx ctx">three</td>233+<td class="tx chroma ctx">three</td>
234 <td class="ln ctx">3</td>234 <td class="ln ctx">3</td>
235 <td class="m ctx">235 <td class="m ctx">
236 </td>236 </td>
237-<td class="tx ctx">three</td>237+<td class="tx chroma ctx">three</td>
238 </tr>238 </tr>
239 <tr>239 <tr>
240 <td class="ln ctx">4</td>240 <td class="ln ctx">4</td>
241 <td class="m ctx">241 <td class="m ctx">
242 </td>242 </td>
243-<td class="tx ctx">four</td>243+<td class="tx chroma ctx">four</td>
244 <td class="ln ctx">4</td>244 <td class="ln ctx">4</td>
245 <td class="m ctx">245 <td class="m ctx">
246 </td>246 </td>
247-<td class="tx ctx">four</td>247+<td class="tx chroma ctx">four</td>
248 </tr>248 </tr>
249 <tr>249 <tr>
250 <td class="ln ctx">5</td>250 <td class="ln ctx">5</td>
251 <td class="m ctx">251 <td class="m ctx">
252 </td>252 </td>
253-<td class="tx ctx">five</td>253+<td class="tx chroma ctx">five</td>
254 <td class="ln ctx">5</td>254 <td class="ln ctx">5</td>
255 <td class="m ctx">255 <td class="m ctx">
256 </td>256 </td>
257-<td class="tx ctx">five</td>257+<td class="tx chroma ctx">five</td>
258 </tr>258 </tr>
259 <tr>259 <tr>
260 <td class="ln none">260 <td class="ln none">
@@ -265,7 +265,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
265 </td>265 </td>
266 <td class="ln add">6</td>266 <td class="ln add">6</td>
267 <td class="m add">+</td>267 <td class="m add">+</td>
268-<td class="tx add">six</td>268+<td class="tx chroma add">six</td>
269 </tr>269 </tr>
270 </tbody>270 </tbody>
271 </table>271 </table>
M internal/app/testdata/web/commit-modify.html +18 -18
@@ -149,49 +149,49 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
149149 <td class="ln">1</td>
150150 <td class="m">
151151 </td>
152-<td class="tx">one</td>
152+<td class="tx chroma">one</td>
153153 </tr>
154154 <tr class="del">
155155 <td class="ln">2</td>
156156 <td class="ln">
157157 </td>
158158 <td class="m">-</td>
159-<td class="tx">two</td>
159+<td class="tx chroma">two</td>
160160 </tr>
161161 <tr class="add">
162162 <td class="ln">
163163 </td>
164164 <td class="ln">2</td>
165165 <td class="m">+</td>
166-<td class="tx">2</td>
166+<td class="tx chroma">2</td>
167167 </tr>
168168 <tr class="ctx">
169169 <td class="ln">3</td>
170170 <td class="ln">3</td>
171171 <td class="m">
172172 </td>
173-<td class="tx">three</td>
173+<td class="tx chroma">three</td>
174174 </tr>
175175 <tr class="ctx">
176176 <td class="ln">4</td>
177177 <td class="ln">4</td>
178178 <td class="m">
179179 </td>
180-<td class="tx">four</td>
180+<td class="tx chroma">four</td>
181181 </tr>
182182 <tr class="ctx">
183183 <td class="ln">5</td>
184184 <td class="ln">5</td>
185185 <td class="m">
186186 </td>
187-<td class="tx">five</td>
187+<td class="tx chroma">five</td>
188188 </tr>
189189 <tr class="add">
190190 <td class="ln">
191191 </td>
192192 <td class="ln">6</td>
193193 <td class="m">+</td>
194-<td class="tx">six</td>
194+<td class="tx chroma">six</td>
195195 </tr>
196196 </tbody>
197197 </table>
@@ -212,49 +212,49 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
212212 <td class="ln ctx">1</td>
213213 <td class="m ctx">
214214 </td>
215-<td class="tx ctx">one</td>
215+<td class="tx chroma ctx">one</td>
216216 <td class="ln ctx">1</td>
217217 <td class="m ctx">
218218 </td>
219-<td class="tx ctx">one</td>
219+<td class="tx chroma ctx">one</td>
220220 </tr>
221221 <tr>
222222 <td class="ln del">2</td>
223223 <td class="m del">-</td>
224-<td class="tx del">two</td>
224+<td class="tx chroma del">two</td>
225225 <td class="ln add">2</td>
226226 <td class="m add">+</td>
227-<td class="tx add">2</td>
227+<td class="tx chroma add">2</td>
228228 </tr>
229229 <tr>
230230 <td class="ln ctx">3</td>
231231 <td class="m ctx">
232232 </td>
233-<td class="tx ctx">three</td>
233+<td class="tx chroma ctx">three</td>
234234 <td class="ln ctx">3</td>
235235 <td class="m ctx">
236236 </td>
237-<td class="tx ctx">three</td>
237+<td class="tx chroma ctx">three</td>
238238 </tr>
239239 <tr>
240240 <td class="ln ctx">4</td>
241241 <td class="m ctx">
242242 </td>
243-<td class="tx ctx">four</td>
243+<td class="tx chroma ctx">four</td>
244244 <td class="ln ctx">4</td>
245245 <td class="m ctx">
246246 </td>
247-<td class="tx ctx">four</td>
247+<td class="tx chroma ctx">four</td>
248248 </tr>
249249 <tr>
250250 <td class="ln ctx">5</td>
251251 <td class="m ctx">
252252 </td>
253-<td class="tx ctx">five</td>
253+<td class="tx chroma ctx">five</td>
254254 <td class="ln ctx">5</td>
255255 <td class="m ctx">
256256 </td>
257-<td class="tx ctx">five</td>
257+<td class="tx chroma ctx">five</td>
258258 </tr>
259259 <tr>
260260 <td class="ln none">
@@ -265,7 +265,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
265265 </td>
266266 <td class="ln add">6</td>
267267 <td class="m add">+</td>
268-<td class="tx add">six</td>
268+<td class="tx chroma add">six</td>
269269 </tr>
270270 </tbody>
271271 </table>
@@ -149,49 +149,49 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
149 <td class="ln">1</td>149 <td class="ln">1</td>
150 <td class="m">150 <td class="m">
151 </td>151 </td>
152-<td class="tx">one</td>152+<td class="tx chroma">one</td>
153 </tr>153 </tr>
154 <tr class="del">154 <tr class="del">
155 <td class="ln">2</td>155 <td class="ln">2</td>
156 <td class="ln">156 <td class="ln">
157 </td>157 </td>
158 <td class="m">-</td>158 <td class="m">-</td>
159-<td class="tx">two</td>159+<td class="tx chroma">two</td>
160 </tr>160 </tr>
161 <tr class="add">161 <tr class="add">
162 <td class="ln">162 <td class="ln">
163 </td>163 </td>
164 <td class="ln">2</td>164 <td class="ln">2</td>
165 <td class="m">+</td>165 <td class="m">+</td>
166-<td class="tx">2</td>166+<td class="tx chroma">2</td>
167 </tr>167 </tr>
168 <tr class="ctx">168 <tr class="ctx">
169 <td class="ln">3</td>169 <td class="ln">3</td>
170 <td class="ln">3</td>170 <td class="ln">3</td>
171 <td class="m">171 <td class="m">
172 </td>172 </td>
173-<td class="tx">three</td>173+<td class="tx chroma">three</td>
174 </tr>174 </tr>
175 <tr class="ctx">175 <tr class="ctx">
176 <td class="ln">4</td>176 <td class="ln">4</td>
177 <td class="ln">4</td>177 <td class="ln">4</td>
178 <td class="m">178 <td class="m">
179 </td>179 </td>
180-<td class="tx">four</td>180+<td class="tx chroma">four</td>
181 </tr>181 </tr>
182 <tr class="ctx">182 <tr class="ctx">
183 <td class="ln">5</td>183 <td class="ln">5</td>
184 <td class="ln">5</td>184 <td class="ln">5</td>
185 <td class="m">185 <td class="m">
186 </td>186 </td>
187-<td class="tx">five</td>187+<td class="tx chroma">five</td>
188 </tr>188 </tr>
189 <tr class="add">189 <tr class="add">
190 <td class="ln">190 <td class="ln">
191 </td>191 </td>
192 <td class="ln">6</td>192 <td class="ln">6</td>
193 <td class="m">+</td>193 <td class="m">+</td>
194-<td class="tx">six</td>194+<td class="tx chroma">six</td>
195 </tr>195 </tr>
196 </tbody>196 </tbody>
197 </table>197 </table>
@@ -212,49 +212,49 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
212 <td class="ln ctx">1</td>212 <td class="ln ctx">1</td>
213 <td class="m ctx">213 <td class="m ctx">
214 </td>214 </td>
215-<td class="tx ctx">one</td>215+<td class="tx chroma ctx">one</td>
216 <td class="ln ctx">1</td>216 <td class="ln ctx">1</td>
217 <td class="m ctx">217 <td class="m ctx">
218 </td>218 </td>
219-<td class="tx ctx">one</td>219+<td class="tx chroma ctx">one</td>
220 </tr>220 </tr>
221 <tr>221 <tr>
222 <td class="ln del">2</td>222 <td class="ln del">2</td>
223 <td class="m del">-</td>223 <td class="m del">-</td>
224-<td class="tx del">two</td>224+<td class="tx chroma del">two</td>
225 <td class="ln add">2</td>225 <td class="ln add">2</td>
226 <td class="m add">+</td>226 <td class="m add">+</td>
227-<td class="tx add">2</td>227+<td class="tx chroma add">2</td>
228 </tr>228 </tr>
229 <tr>229 <tr>
230 <td class="ln ctx">3</td>230 <td class="ln ctx">3</td>
231 <td class="m ctx">231 <td class="m ctx">
232 </td>232 </td>
233-<td class="tx ctx">three</td>233+<td class="tx chroma ctx">three</td>
234 <td class="ln ctx">3</td>234 <td class="ln ctx">3</td>
235 <td class="m ctx">235 <td class="m ctx">
236 </td>236 </td>
237-<td class="tx ctx">three</td>237+<td class="tx chroma ctx">three</td>
238 </tr>238 </tr>
239 <tr>239 <tr>
240 <td class="ln ctx">4</td>240 <td class="ln ctx">4</td>
241 <td class="m ctx">241 <td class="m ctx">
242 </td>242 </td>
243-<td class="tx ctx">four</td>243+<td class="tx chroma ctx">four</td>
244 <td class="ln ctx">4</td>244 <td class="ln ctx">4</td>
245 <td class="m ctx">245 <td class="m ctx">
246 </td>246 </td>
247-<td class="tx ctx">four</td>247+<td class="tx chroma ctx">four</td>
248 </tr>248 </tr>
249 <tr>249 <tr>
250 <td class="ln ctx">5</td>250 <td class="ln ctx">5</td>
251 <td class="m ctx">251 <td class="m ctx">
252 </td>252 </td>
253-<td class="tx ctx">five</td>253+<td class="tx chroma ctx">five</td>
254 <td class="ln ctx">5</td>254 <td class="ln ctx">5</td>
255 <td class="m ctx">255 <td class="m ctx">
256 </td>256 </td>
257-<td class="tx ctx">five</td>257+<td class="tx chroma ctx">five</td>
258 </tr>258 </tr>
259 <tr>259 <tr>
260 <td class="ln none">260 <td class="ln none">
@@ -265,7 +265,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
265 </td>265 </td>
266 <td class="ln add">6</td>266 <td class="ln add">6</td>
267 <td class="m add">+</td>267 <td class="m add">+</td>
268-<td class="tx add">six</td>268+<td class="tx chroma add">six</td>
269 </tr>269 </tr>
270 </tbody>270 </tbody>
271 </table>271 </table>
M internal/app/testdata/web/commit-rename.html +2 -2
@@ -153,7 +153,7 @@
153153 <td class="ln">
154154 </td>
155155 <td class="m">-</td>
156-<td class="tx">bye</td>
156+<td class="tx chroma">bye</td>
157157 </tr>
158158 </tbody>
159159 </table>
@@ -173,7 +173,7 @@
173173 <tr>
174174 <td class="ln del">1</td>
175175 <td class="m del">-</td>
176-<td class="tx del">bye</td>
176+<td class="tx chroma del">bye</td>
177177 <td class="ln none">
178178 </td>
179179 <td class="m none">
@@ -153,7 +153,7 @@
153 <td class="ln">153 <td class="ln">
154 </td>154 </td>
155 <td class="m">-</td>155 <td class="m">-</td>
156-<td class="tx">bye</td>156+<td class="tx chroma">bye</td>
157 </tr>157 </tr>
158 </tbody>158 </tbody>
159 </table>159 </table>
@@ -173,7 +173,7 @@
173 <tr>173 <tr>
174 <td class="ln del">1</td>174 <td class="ln del">1</td>
175 <td class="m del">-</td>175 <td class="m del">-</td>
176-<td class="tx del">bye</td>176+<td class="tx chroma del">bye</td>
177 <td class="ln none">177 <td class="ln none">
178 </td>178 </td>
179 <td class="m none">179 <td class="m none">
M internal/app/testdata/web/commit-root.html +22 -22
@@ -141,35 +141,35 @@
141141 </td>
142142 <td class="ln">1</td>
143143 <td class="m">+</td>
144-<td class="tx">one</td>
144+<td class="tx chroma">one</td>
145145 </tr>
146146 <tr class="add">
147147 <td class="ln">
148148 </td>
149149 <td class="ln">2</td>
150150 <td class="m">+</td>
151-<td class="tx">two</td>
151+<td class="tx chroma">two</td>
152152 </tr>
153153 <tr class="add">
154154 <td class="ln">
155155 </td>
156156 <td class="ln">3</td>
157157 <td class="m">+</td>
158-<td class="tx">three</td>
158+<td class="tx chroma">three</td>
159159 </tr>
160160 <tr class="add">
161161 <td class="ln">
162162 </td>
163163 <td class="ln">4</td>
164164 <td class="m">+</td>
165-<td class="tx">four</td>
165+<td class="tx chroma">four</td>
166166 </tr>
167167 <tr class="add">
168168 <td class="ln">
169169 </td>
170170 <td class="ln">5</td>
171171 <td class="m">+</td>
172-<td class="tx">five</td>
172+<td class="tx chroma">five</td>
173173 </tr>
174174 </tbody>
175175 </table>
@@ -195,7 +195,7 @@
195195 </td>
196196 <td class="ln add">1</td>
197197 <td class="m add">+</td>
198-<td class="tx add">one</td>
198+<td class="tx chroma add">one</td>
199199 </tr>
200200 <tr>
201201 <td class="ln none">
@@ -206,7 +206,7 @@
206206 </td>
207207 <td class="ln add">2</td>
208208 <td class="m add">+</td>
209-<td class="tx add">two</td>
209+<td class="tx chroma add">two</td>
210210 </tr>
211211 <tr>
212212 <td class="ln none">
@@ -217,7 +217,7 @@
217217 </td>
218218 <td class="ln add">3</td>
219219 <td class="m add">+</td>
220-<td class="tx add">three</td>
220+<td class="tx chroma add">three</td>
221221 </tr>
222222 <tr>
223223 <td class="ln none">
@@ -228,7 +228,7 @@
228228 </td>
229229 <td class="ln add">4</td>
230230 <td class="m add">+</td>
231-<td class="tx add">four</td>
231+<td class="tx chroma add">four</td>
232232 </tr>
233233 <tr>
234234 <td class="ln none">
@@ -239,7 +239,7 @@
239239 </td>
240240 <td class="ln add">5</td>
241241 <td class="m add">+</td>
242-<td class="tx add">five</td>
242+<td class="tx chroma add">five</td>
243243 </tr>
244244 </tbody>
245245 </table>
@@ -263,7 +263,7 @@
263263 </td>
264264 <td class="ln">1</td>
265265 <td class="m">+</td>
266-<td class="tx">bye</td>
266+<td class="tx chroma">bye</td>
267267 </tr>
268268 </tbody>
269269 </table>
@@ -289,7 +289,7 @@
289289 </td>
290290 <td class="ln add">1</td>
291291 <td class="m add">+</td>
292-<td class="tx add">bye</td>
292+<td class="tx chroma add">bye</td>
293293 </tr>
294294 </tbody>
295295 </table>
@@ -323,21 +323,21 @@
323323 </td>
324324 <td class="ln">1</td>
325325 <td class="m">+</td>
326-<td class="tx">keep me</td>
326+<td class="tx chroma">keep me</td>
327327 </tr>
328328 <tr class="add">
329329 <td class="ln">
330330 </td>
331331 <td class="ln">2</td>
332332 <td class="m">+</td>
333-<td class="tx">keep me too</td>
333+<td class="tx chroma">keep me too</td>
334334 </tr>
335335 <tr class="add">
336336 <td class="ln">
337337 </td>
338338 <td class="ln">3</td>
339339 <td class="m">+</td>
340-<td class="tx">and me</td>
340+<td class="tx chroma">and me</td>
341341 </tr>
342342 </tbody>
343343 </table>
@@ -363,7 +363,7 @@
363363 </td>
364364 <td class="ln add">1</td>
365365 <td class="m add">+</td>
366-<td class="tx add">keep me</td>
366+<td class="tx chroma add">keep me</td>
367367 </tr>
368368 <tr>
369369 <td class="ln none">
@@ -374,7 +374,7 @@
374374 </td>
375375 <td class="ln add">2</td>
376376 <td class="m add">+</td>
377-<td class="tx add">keep me too</td>
377+<td class="tx chroma add">keep me too</td>
378378 </tr>
379379 <tr>
380380 <td class="ln none">
@@ -385,7 +385,7 @@
385385 </td>
386386 <td class="ln add">3</td>
387387 <td class="m add">+</td>
388-<td class="tx add">and me</td>
388+<td class="tx chroma add">and me</td>
389389 </tr>
390390 </tbody>
391391 </table>
@@ -409,14 +409,14 @@
409409 </td>
410410 <td class="ln">1</td>
411411 <td class="m">+</td>
412-<td class="tx">#!/bin/sh</td>
412+<td class="tx chroma">#!/bin/sh</td>
413413 </tr>
414414 <tr class="add">
415415 <td class="ln">
416416 </td>
417417 <td class="ln">2</td>
418418 <td class="m">+</td>
419-<td class="tx">echo hi</td>
419+<td class="tx chroma">echo hi</td>
420420 </tr>
421421 </tbody>
422422 </table>
@@ -442,7 +442,7 @@
442442 </td>
443443 <td class="ln add">1</td>
444444 <td class="m add">+</td>
445-<td class="tx add">#!/bin/sh</td>
445+<td class="tx chroma add">#!/bin/sh</td>
446446 </tr>
447447 <tr>
448448 <td class="ln none">
@@ -453,7 +453,7 @@
453453 </td>
454454 <td class="ln add">2</td>
455455 <td class="m add">+</td>
456-<td class="tx add">echo hi</td>
456+<td class="tx chroma add">echo hi</td>
457457 </tr>
458458 </tbody>
459459 </table>
@@ -141,35 +141,35 @@
141 </td>141 </td>
142 <td class="ln">1</td>142 <td class="ln">1</td>
143 <td class="m">+</td>143 <td class="m">+</td>
144-<td class="tx">one</td>144+<td class="tx chroma">one</td>
145 </tr>145 </tr>
146 <tr class="add">146 <tr class="add">
147 <td class="ln">147 <td class="ln">
148 </td>148 </td>
149 <td class="ln">2</td>149 <td class="ln">2</td>
150 <td class="m">+</td>150 <td class="m">+</td>
151-<td class="tx">two</td>151+<td class="tx chroma">two</td>
152 </tr>152 </tr>
153 <tr class="add">153 <tr class="add">
154 <td class="ln">154 <td class="ln">
155 </td>155 </td>
156 <td class="ln">3</td>156 <td class="ln">3</td>
157 <td class="m">+</td>157 <td class="m">+</td>
158-<td class="tx">three</td>158+<td class="tx chroma">three</td>
159 </tr>159 </tr>
160 <tr class="add">160 <tr class="add">
161 <td class="ln">161 <td class="ln">
162 </td>162 </td>
163 <td class="ln">4</td>163 <td class="ln">4</td>
164 <td class="m">+</td>164 <td class="m">+</td>
165-<td class="tx">four</td>165+<td class="tx chroma">four</td>
166 </tr>166 </tr>
167 <tr class="add">167 <tr class="add">
168 <td class="ln">168 <td class="ln">
169 </td>169 </td>
170 <td class="ln">5</td>170 <td class="ln">5</td>
171 <td class="m">+</td>171 <td class="m">+</td>
172-<td class="tx">five</td>172+<td class="tx chroma">five</td>
173 </tr>173 </tr>
174 </tbody>174 </tbody>
175 </table>175 </table>
@@ -195,7 +195,7 @@
195 </td>195 </td>
196 <td class="ln add">1</td>196 <td class="ln add">1</td>
197 <td class="m add">+</td>197 <td class="m add">+</td>
198-<td class="tx add">one</td>198+<td class="tx chroma add">one</td>
199 </tr>199 </tr>
200 <tr>200 <tr>
201 <td class="ln none">201 <td class="ln none">
@@ -206,7 +206,7 @@
206 </td>206 </td>
207 <td class="ln add">2</td>207 <td class="ln add">2</td>
208 <td class="m add">+</td>208 <td class="m add">+</td>
209-<td class="tx add">two</td>209+<td class="tx chroma add">two</td>
210 </tr>210 </tr>
211 <tr>211 <tr>
212 <td class="ln none">212 <td class="ln none">
@@ -217,7 +217,7 @@
217 </td>217 </td>
218 <td class="ln add">3</td>218 <td class="ln add">3</td>
219 <td class="m add">+</td>219 <td class="m add">+</td>
220-<td class="tx add">three</td>220+<td class="tx chroma add">three</td>
221 </tr>221 </tr>
222 <tr>222 <tr>
223 <td class="ln none">223 <td class="ln none">
@@ -228,7 +228,7 @@
228 </td>228 </td>
229 <td class="ln add">4</td>229 <td class="ln add">4</td>
230 <td class="m add">+</td>230 <td class="m add">+</td>
231-<td class="tx add">four</td>231+<td class="tx chroma add">four</td>
232 </tr>232 </tr>
233 <tr>233 <tr>
234 <td class="ln none">234 <td class="ln none">
@@ -239,7 +239,7 @@
239 </td>239 </td>
240 <td class="ln add">5</td>240 <td class="ln add">5</td>
241 <td class="m add">+</td>241 <td class="m add">+</td>
242-<td class="tx add">five</td>242+<td class="tx chroma add">five</td>
243 </tr>243 </tr>
244 </tbody>244 </tbody>
245 </table>245 </table>
@@ -263,7 +263,7 @@
263 </td>263 </td>
264 <td class="ln">1</td>264 <td class="ln">1</td>
265 <td class="m">+</td>265 <td class="m">+</td>
266-<td class="tx">bye</td>266+<td class="tx chroma">bye</td>
267 </tr>267 </tr>
268 </tbody>268 </tbody>
269 </table>269 </table>
@@ -289,7 +289,7 @@
289 </td>289 </td>
290 <td class="ln add">1</td>290 <td class="ln add">1</td>
291 <td class="m add">+</td>291 <td class="m add">+</td>
292-<td class="tx add">bye</td>292+<td class="tx chroma add">bye</td>
293 </tr>293 </tr>
294 </tbody>294 </tbody>
295 </table>295 </table>
@@ -323,21 +323,21 @@
323 </td>323 </td>
324 <td class="ln">1</td>324 <td class="ln">1</td>
325 <td class="m">+</td>325 <td class="m">+</td>
326-<td class="tx">keep me</td>326+<td class="tx chroma">keep me</td>
327 </tr>327 </tr>
328 <tr class="add">328 <tr class="add">
329 <td class="ln">329 <td class="ln">
330 </td>330 </td>
331 <td class="ln">2</td>331 <td class="ln">2</td>
332 <td class="m">+</td>332 <td class="m">+</td>
333-<td class="tx">keep me too</td>333+<td class="tx chroma">keep me too</td>
334 </tr>334 </tr>
335 <tr class="add">335 <tr class="add">
336 <td class="ln">336 <td class="ln">
337 </td>337 </td>
338 <td class="ln">3</td>338 <td class="ln">3</td>
339 <td class="m">+</td>339 <td class="m">+</td>
340-<td class="tx">and me</td>340+<td class="tx chroma">and me</td>
341 </tr>341 </tr>
342 </tbody>342 </tbody>
343 </table>343 </table>
@@ -363,7 +363,7 @@
363 </td>363 </td>
364 <td class="ln add">1</td>364 <td class="ln add">1</td>
365 <td class="m add">+</td>365 <td class="m add">+</td>
366-<td class="tx add">keep me</td>366+<td class="tx chroma add">keep me</td>
367 </tr>367 </tr>
368 <tr>368 <tr>
369 <td class="ln none">369 <td class="ln none">
@@ -374,7 +374,7 @@
374 </td>374 </td>
375 <td class="ln add">2</td>375 <td class="ln add">2</td>
376 <td class="m add">+</td>376 <td class="m add">+</td>
377-<td class="tx add">keep me too</td>377+<td class="tx chroma add">keep me too</td>
378 </tr>378 </tr>
379 <tr>379 <tr>
380 <td class="ln none">380 <td class="ln none">
@@ -385,7 +385,7 @@
385 </td>385 </td>
386 <td class="ln add">3</td>386 <td class="ln add">3</td>
387 <td class="m add">+</td>387 <td class="m add">+</td>
388-<td class="tx add">and me</td>388+<td class="tx chroma add">and me</td>
389 </tr>389 </tr>
390 </tbody>390 </tbody>
391 </table>391 </table>
@@ -409,14 +409,14 @@
409 </td>409 </td>
410 <td class="ln">1</td>410 <td class="ln">1</td>
411 <td class="m">+</td>411 <td class="m">+</td>
412-<td class="tx">#!/bin/sh</td>412+<td class="tx chroma">#!/bin/sh</td>
413 </tr>413 </tr>
414 <tr class="add">414 <tr class="add">
415 <td class="ln">415 <td class="ln">
416 </td>416 </td>
417 <td class="ln">2</td>417 <td class="ln">2</td>
418 <td class="m">+</td>418 <td class="m">+</td>
419-<td class="tx">echo hi</td>419+<td class="tx chroma">echo hi</td>
420 </tr>420 </tr>
421 </tbody>421 </tbody>
422 </table>422 </table>
@@ -442,7 +442,7 @@
442 </td>442 </td>
443 <td class="ln add">1</td>443 <td class="ln add">1</td>
444 <td class="m add">+</td>444 <td class="m add">+</td>
445-<td class="tx add">#!/bin/sh</td>445+<td class="tx chroma add">#!/bin/sh</td>
446 </tr>446 </tr>
447 <tr>447 <tr>
448 <td class="ln none">448 <td class="ln none">
@@ -453,7 +453,7 @@
453 </td>453 </td>
454 <td class="ln add">2</td>454 <td class="ln add">2</td>
455 <td class="m add">+</td>455 <td class="m add">+</td>
456-<td class="tx add">echo hi</td>456+<td class="tx chroma add">echo hi</td>
457 </tr>457 </tr>
458 </tbody>458 </tbody>
459 </table>459 </table>
M internal/app/testdata/web/commit-trojan.html +60 -20
@@ -145,14 +145,15 @@
145145 </td>
146146 <td class="ln">1</td>
147147 <td class="m">+</td>
148-<td class="tx">package evil</td>
148+<td class="tx chroma">
149+<span class="kn">package</span> evil</td>
149150 </tr>
150151 <tr class="add">
151152 <td class="ln">
152153 </td>
153154 <td class="ln">2</td>
154155 <td class="m">+</td>
155-<td class="tx">
156+<td class="tx chroma">
156157 </td>
157158 </tr>
158159 <tr class="add">
@@ -160,44 +161,63 @@
160161 </td>
161162 <td class="ln">3</td>
162163 <td class="m">+</td>
163-<td class="tx">func admin(user string) bool {</td>
164+<td class="tx chroma">
165+<span class="kd">func</span> admin(user string) bool {</td>
164166 </tr>
165167 <tr class="add">
166168 <td class="ln">
167169 </td>
168170 <td class="ln">4</td>
169171 <td class="m">+</td>
170-<td class="tx"> if user == &#34;user<span class="inv" title="invisible character">&lt;U+202E&gt;</span>
171-<span class="inv" title="invisible character">&lt;U+2066&gt;</span>// check<span class="inv" title="invisible character">&lt;U+2069&gt;</span>
172-<span class="inv" title="invisible character">&lt;U+2066&gt;</span>&#34; {</td>
172+<td class="tx chroma">
173+<span class="k">if</span> user == <span class="s">&#34;user</span>
174+<span class="inv" title="invisible character">&lt;U+202E&gt;</span>
175+<span class="s">
176+</span>
177+<span class="inv" title="invisible character">&lt;U+2066&gt;</span>
178+<span class="s">// check</span>
179+<span class="inv" title="invisible character">&lt;U+2069&gt;</span>
180+<span class="s">
181+</span>
182+<span class="inv" title="invisible character">&lt;U+2066&gt;</span>
183+<span class="s">&#34;</span> {</td>
173184 </tr>
174185 <tr class="add">
175186 <td class="ln">
176187 </td>
177188 <td class="ln">5</td>
178189 <td class="m">+</td>
179-<td class="tx"> return true // z<span class="inv" title="invisible character">&lt;U+200B&gt;</span>w</td>
190+<td class="tx chroma">
191+<span class="k">return</span>
192+<span class="kc">true</span>
193+<span class="c1">// z</span>
194+<span class="inv" title="invisible character">&lt;U+200B&gt;</span>
195+<span class="c1">w</span>
196+</td>
180197 </tr>
181198 <tr class="add">
182199 <td class="ln">
183200 </td>
184201 <td class="ln">6</td>
185202 <td class="m">+</td>
186-<td class="tx"> }</td>
203+<td class="tx chroma"> }</td>
187204 </tr>
188205 <tr class="add">
189206 <td class="ln">
190207 </td>
191208 <td class="ln">7</td>
192209 <td class="m">+</td>
193-<td class="tx"> return false</td>
210+<td class="tx chroma">
211+<span class="k">return</span>
212+<span class="kc">false</span>
213+</td>
194214 </tr>
195215 <tr class="add">
196216 <td class="ln">
197217 </td>
198218 <td class="ln">8</td>
199219 <td class="m">+</td>
200-<td class="tx">}</td>
220+<td class="tx chroma">}</td>
201221 </tr>
202222 </tbody>
203223 </table>
@@ -223,7 +243,8 @@
223243 </td>
224244 <td class="ln add">1</td>
225245 <td class="m add">+</td>
226-<td class="tx add">package evil</td>
246+<td class="tx chroma add">
247+<span class="kn">package</span> evil</td>
227248 </tr>
228249 <tr>
229250 <td class="ln none">
@@ -234,7 +255,7 @@
234255 </td>
235256 <td class="ln add">2</td>
236257 <td class="m add">+</td>
237-<td class="tx add">
258+<td class="tx chroma add">
238259 </td>
239260 </tr>
240261 <tr>
@@ -246,7 +267,8 @@
246267 </td>
247268 <td class="ln add">3</td>
248269 <td class="m add">+</td>
249-<td class="tx add">func admin(user string) bool {</td>
270+<td class="tx chroma add">
271+<span class="kd">func</span> admin(user string) bool {</td>
250272 </tr>
251273 <tr>
252274 <td class="ln none">
@@ -257,9 +279,18 @@
257279 </td>
258280 <td class="ln add">4</td>
259281 <td class="m add">+</td>
260-<td class="tx add"> if user == &#34;user<span class="inv" title="invisible character">&lt;U+202E&gt;</span>
261-<span class="inv" title="invisible character">&lt;U+2066&gt;</span>// check<span class="inv" title="invisible character">&lt;U+2069&gt;</span>
262-<span class="inv" title="invisible character">&lt;U+2066&gt;</span>&#34; {</td>
282+<td class="tx chroma add">
283+<span class="k">if</span> user == <span class="s">&#34;user</span>
284+<span class="inv" title="invisible character">&lt;U+202E&gt;</span>
285+<span class="s">
286+</span>
287+<span class="inv" title="invisible character">&lt;U+2066&gt;</span>
288+<span class="s">// check</span>
289+<span class="inv" title="invisible character">&lt;U+2069&gt;</span>
290+<span class="s">
291+</span>
292+<span class="inv" title="invisible character">&lt;U+2066&gt;</span>
293+<span class="s">&#34;</span> {</td>
263294 </tr>
264295 <tr>
265296 <td class="ln none">
@@ -270,7 +301,13 @@
270301 </td>
271302 <td class="ln add">5</td>
272303 <td class="m add">+</td>
273-<td class="tx add"> return true // z<span class="inv" title="invisible character">&lt;U+200B&gt;</span>w</td>
304+<td class="tx chroma add">
305+<span class="k">return</span>
306+<span class="kc">true</span>
307+<span class="c1">// z</span>
308+<span class="inv" title="invisible character">&lt;U+200B&gt;</span>
309+<span class="c1">w</span>
310+</td>
274311 </tr>
275312 <tr>
276313 <td class="ln none">
@@ -281,7 +318,7 @@
281318 </td>
282319 <td class="ln add">6</td>
283320 <td class="m add">+</td>
284-<td class="tx add"> }</td>
321+<td class="tx chroma add"> }</td>
285322 </tr>
286323 <tr>
287324 <td class="ln none">
@@ -292,7 +329,10 @@
292329 </td>
293330 <td class="ln add">7</td>
294331 <td class="m add">+</td>
295-<td class="tx add"> return false</td>
332+<td class="tx chroma add">
333+<span class="k">return</span>
334+<span class="kc">false</span>
335+</td>
296336 </tr>
297337 <tr>
298338 <td class="ln none">
@@ -303,7 +343,7 @@
303343 </td>
304344 <td class="ln add">8</td>
305345 <td class="m add">+</td>
306-<td class="tx add">}</td>
346+<td class="tx chroma add">}</td>
307347 </tr>
308348 </tbody>
309349 </table>
@@ -145,14 +145,15 @@
145 </td>145 </td>
146 <td class="ln">1</td>146 <td class="ln">1</td>
147 <td class="m">+</td>147 <td class="m">+</td>
148-<td class="tx">package evil</td>148+<td class="tx chroma">
149+<span class="kn">package</span> evil</td>
149 </tr>150 </tr>
150 <tr class="add">151 <tr class="add">
151 <td class="ln">152 <td class="ln">
152 </td>153 </td>
153 <td class="ln">2</td>154 <td class="ln">2</td>
154 <td class="m">+</td>155 <td class="m">+</td>
155-<td class="tx">156+<td class="tx chroma">
156 </td>157 </td>
157 </tr>158 </tr>
158 <tr class="add">159 <tr class="add">
@@ -160,44 +161,63 @@
160 </td>161 </td>
161 <td class="ln">3</td>162 <td class="ln">3</td>
162 <td class="m">+</td>163 <td class="m">+</td>
163-<td class="tx">func admin(user string) bool {</td>164+<td class="tx chroma">
165+<span class="kd">func</span> admin(user string) bool {</td>
164 </tr>166 </tr>
165 <tr class="add">167 <tr class="add">
166 <td class="ln">168 <td class="ln">
167 </td>169 </td>
168 <td class="ln">4</td>170 <td class="ln">4</td>
169 <td class="m">+</td>171 <td class="m">+</td>
170-<td class="tx"> if user == &#34;user<span class="inv" title="invisible character">&lt;U+202E&gt;</span>172+<td class="tx chroma">
171-<span class="inv" title="invisible character">&lt;U+2066&gt;</span>// check<span class="inv" title="invisible character">&lt;U+2069&gt;</span>173+<span class="k">if</span> user == <span class="s">&#34;user</span>
172-<span class="inv" title="invisible character">&lt;U+2066&gt;</span>&#34; {</td>174+<span class="inv" title="invisible character">&lt;U+202E&gt;</span>
175+<span class="s">
176+</span>
177+<span class="inv" title="invisible character">&lt;U+2066&gt;</span>
178+<span class="s">// check</span>
179+<span class="inv" title="invisible character">&lt;U+2069&gt;</span>
180+<span class="s">
181+</span>
182+<span class="inv" title="invisible character">&lt;U+2066&gt;</span>
183+<span class="s">&#34;</span> {</td>
173 </tr>184 </tr>
174 <tr class="add">185 <tr class="add">
175 <td class="ln">186 <td class="ln">
176 </td>187 </td>
177 <td class="ln">5</td>188 <td class="ln">5</td>
178 <td class="m">+</td>189 <td class="m">+</td>
179-<td class="tx"> return true // z<span class="inv" title="invisible character">&lt;U+200B&gt;</span>w</td>190+<td class="tx chroma">
191+<span class="k">return</span>
192+<span class="kc">true</span>
193+<span class="c1">// z</span>
194+<span class="inv" title="invisible character">&lt;U+200B&gt;</span>
195+<span class="c1">w</span>
196+</td>
180 </tr>197 </tr>
181 <tr class="add">198 <tr class="add">
182 <td class="ln">199 <td class="ln">
183 </td>200 </td>
184 <td class="ln">6</td>201 <td class="ln">6</td>
185 <td class="m">+</td>202 <td class="m">+</td>
186-<td class="tx"> }</td>203+<td class="tx chroma"> }</td>
187 </tr>204 </tr>
188 <tr class="add">205 <tr class="add">
189 <td class="ln">206 <td class="ln">
190 </td>207 </td>
191 <td class="ln">7</td>208 <td class="ln">7</td>
192 <td class="m">+</td>209 <td class="m">+</td>
193-<td class="tx"> return false</td>210+<td class="tx chroma">
211+<span class="k">return</span>
212+<span class="kc">false</span>
213+</td>
194 </tr>214 </tr>
195 <tr class="add">215 <tr class="add">
196 <td class="ln">216 <td class="ln">
197 </td>217 </td>
198 <td class="ln">8</td>218 <td class="ln">8</td>
199 <td class="m">+</td>219 <td class="m">+</td>
200-<td class="tx">}</td>220+<td class="tx chroma">}</td>
201 </tr>221 </tr>
202 </tbody>222 </tbody>
203 </table>223 </table>
@@ -223,7 +243,8 @@
223 </td>243 </td>
224 <td class="ln add">1</td>244 <td class="ln add">1</td>
225 <td class="m add">+</td>245 <td class="m add">+</td>
226-<td class="tx add">package evil</td>246+<td class="tx chroma add">
247+<span class="kn">package</span> evil</td>
227 </tr>248 </tr>
228 <tr>249 <tr>
229 <td class="ln none">250 <td class="ln none">
@@ -234,7 +255,7 @@
234 </td>255 </td>
235 <td class="ln add">2</td>256 <td class="ln add">2</td>
236 <td class="m add">+</td>257 <td class="m add">+</td>
237-<td class="tx add">258+<td class="tx chroma add">
238 </td>259 </td>
239 </tr>260 </tr>
240 <tr>261 <tr>
@@ -246,7 +267,8 @@
246 </td>267 </td>
247 <td class="ln add">3</td>268 <td class="ln add">3</td>
248 <td class="m add">+</td>269 <td class="m add">+</td>
249-<td class="tx add">func admin(user string) bool {</td>270+<td class="tx chroma add">
271+<span class="kd">func</span> admin(user string) bool {</td>
250 </tr>272 </tr>
251 <tr>273 <tr>
252 <td class="ln none">274 <td class="ln none">
@@ -257,9 +279,18 @@
257 </td>279 </td>
258 <td class="ln add">4</td>280 <td class="ln add">4</td>
259 <td class="m add">+</td>281 <td class="m add">+</td>
260-<td class="tx add"> if user == &#34;user<span class="inv" title="invisible character">&lt;U+202E&gt;</span>282+<td class="tx chroma add">
261-<span class="inv" title="invisible character">&lt;U+2066&gt;</span>// check<span class="inv" title="invisible character">&lt;U+2069&gt;</span>283+<span class="k">if</span> user == <span class="s">&#34;user</span>
262-<span class="inv" title="invisible character">&lt;U+2066&gt;</span>&#34; {</td>284+<span class="inv" title="invisible character">&lt;U+202E&gt;</span>
285+<span class="s">
286+</span>
287+<span class="inv" title="invisible character">&lt;U+2066&gt;</span>
288+<span class="s">// check</span>
289+<span class="inv" title="invisible character">&lt;U+2069&gt;</span>
290+<span class="s">
291+</span>
292+<span class="inv" title="invisible character">&lt;U+2066&gt;</span>
293+<span class="s">&#34;</span> {</td>
263 </tr>294 </tr>
264 <tr>295 <tr>
265 <td class="ln none">296 <td class="ln none">
@@ -270,7 +301,13 @@
270 </td>301 </td>
271 <td class="ln add">5</td>302 <td class="ln add">5</td>
272 <td class="m add">+</td>303 <td class="m add">+</td>
273-<td class="tx add"> return true // z<span class="inv" title="invisible character">&lt;U+200B&gt;</span>w</td>304+<td class="tx chroma add">
305+<span class="k">return</span>
306+<span class="kc">true</span>
307+<span class="c1">// z</span>
308+<span class="inv" title="invisible character">&lt;U+200B&gt;</span>
309+<span class="c1">w</span>
310+</td>
274 </tr>311 </tr>
275 <tr>312 <tr>
276 <td class="ln none">313 <td class="ln none">
@@ -281,7 +318,7 @@
281 </td>318 </td>
282 <td class="ln add">6</td>319 <td class="ln add">6</td>
283 <td class="m add">+</td>320 <td class="m add">+</td>
284-<td class="tx add"> }</td>321+<td class="tx chroma add"> }</td>
285 </tr>322 </tr>
286 <tr>323 <tr>
287 <td class="ln none">324 <td class="ln none">
@@ -292,7 +329,10 @@
292 </td>329 </td>
293 <td class="ln add">7</td>330 <td class="ln add">7</td>
294 <td class="m add">+</td>331 <td class="m add">+</td>
295-<td class="tx add"> return false</td>332+<td class="tx chroma add">
333+<span class="k">return</span>
334+<span class="kc">false</span>
335+</td>
296 </tr>336 </tr>
297 <tr>337 <tr>
298 <td class="ln none">338 <td class="ln none">
@@ -303,7 +343,7 @@
303 </td>343 </td>
304 <td class="ln add">8</td>344 <td class="ln add">8</td>
305 <td class="m add">+</td>345 <td class="m add">+</td>
306-<td class="tx add">}</td>346+<td class="tx chroma add">}</td>
307 </tr>347 </tr>
308 </tbody>348 </tbody>
309 </table>349 </table>
M internal/app/web_commit_test.go +37 -0
@@ -186,6 +186,43 @@ func TestWebCommitPrivate(t *testing.T) {
186186 }
187187 }
188188
189+// The syntax colours reach the hidden lines: /context lexes the whole file, so
190+// a block comment that opens in the hidden lines and closes below them is
191+// coloured as one, with the classes of the code view, and the text stays
192+// escaped.
193+func TestWebDiffContextHighlight(t *testing.T) {
194+ h := newHarness(t, harnessOpts{})
195+ wt := t.TempDir()
196+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
197+ var src strings.Builder
198+ src.WriteString("package a\n\n/* open <b>\n")
199+ for i := 1; i <= 30; i++ {
200+ fmt.Fprintf(&src, "still a comment %d\n", i)
201+ }
202+ src.WriteString("*/\nvar x = 1\n")
203+ for i := 1; i <= 20; i++ {
204+ fmt.Fprintf(&src, "var y%d = %d\n", i, i)
205+ }
206+ h.put(wt, "f.go", []byte(src.String()), 0o644)
207+ h.commitAt(wt, "2026-02-01T10:00:00Z", "a file")
208+ h.put(wt, "f.go", []byte(strings.Replace(src.String(), "var y15 = 15", "var y15 = 150", 1)), 0o644)
209+ sha := h.commitAt(wt, "2026-02-02T10:00:00Z", "change y15")
210+ h.createRepo("hl", domain.VisibilityPublic)
211+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/hl.git"), "main")
212+
213+ _, _, page := h.get("/hl/commit/" + sha)
214+ assert.Contains(t, page, `<span class="kd">var</span>`, "the hunk is coloured")
215+ assertNoInline(t, page)
216+
217+ st, _, body := h.get("/hl/context/" + sha + "?key=g0&n=20&new=1&old=1&path=f.go")
218+ require.Equal(t, 200, st)
219+ assert.Contains(t, body, `<span class="kn">package</span> a`)
220+ assert.Contains(t, body, `<span class="cm">/* open &lt;b&gt;</span>`)
221+ assert.Contains(t, body, `<span class="cm">still a comment 5</span>`, "the comment opened above is still open")
222+ assert.NotContains(t, body, "<b>")
223+ assertNoInline(t, body)
224+}
225+
189226 // The hidden lines of a diff: the page has a gap before a hunk and after the
190227 // last one, and /context serves their rows for both layouts, numbered on both
191228 // sides; a private repository's are the 404 page.
@@ -186,6 +186,43 @@ func TestWebCommitPrivate(t *testing.T) {
186 }186 }
187 }187 }
188 188
189+// The syntax colours reach the hidden lines: /context lexes the whole file, so
190+// a block comment that opens in the hidden lines and closes below them is
191+// coloured as one, with the classes of the code view, and the text stays
192+// escaped.
193+func TestWebDiffContextHighlight(t *testing.T) {
194+ h := newHarness(t, harnessOpts{})
195+ wt := t.TempDir()
196+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
197+ var src strings.Builder
198+ src.WriteString("package a\n\n/* open <b>\n")
199+ for i := 1; i <= 30; i++ {
200+ fmt.Fprintf(&src, "still a comment %d\n", i)
201+ }
202+ src.WriteString("*/\nvar x = 1\n")
203+ for i := 1; i <= 20; i++ {
204+ fmt.Fprintf(&src, "var y%d = %d\n", i, i)
205+ }
206+ h.put(wt, "f.go", []byte(src.String()), 0o644)
207+ h.commitAt(wt, "2026-02-01T10:00:00Z", "a file")
208+ h.put(wt, "f.go", []byte(strings.Replace(src.String(), "var y15 = 15", "var y15 = 150", 1)), 0o644)
209+ sha := h.commitAt(wt, "2026-02-02T10:00:00Z", "change y15")
210+ h.createRepo("hl", domain.VisibilityPublic)
211+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/hl.git"), "main")
212+
213+ _, _, page := h.get("/hl/commit/" + sha)
214+ assert.Contains(t, page, `<span class="kd">var</span>`, "the hunk is coloured")
215+ assertNoInline(t, page)
216+
217+ st, _, body := h.get("/hl/context/" + sha + "?key=g0&n=20&new=1&old=1&path=f.go")
218+ require.Equal(t, 200, st)
219+ assert.Contains(t, body, `<span class="kn">package</span> a`)
220+ assert.Contains(t, body, `<span class="cm">/* open &lt;b&gt;</span>`)
221+ assert.Contains(t, body, `<span class="cm">still a comment 5</span>`, "the comment opened above is still open")
222+ assert.NotContains(t, body, "<b>")
223+ assertNoInline(t, body)
224+}
225+
189 // The hidden lines of a diff: the page has a gap before a hunk and after the226 // The hidden lines of a diff: the page has a gap before a hunk and after the
190 // last one, and /context serves their rows for both layouts, numbered on both227 // last one, and /context serves their rows for both layouts, numbered on both
191 // sides; a private repository's are the 404 page.228 // sides; a private repository's are the 404 page.
M internal/app/web_mr_test.go +3 -3
@@ -70,7 +70,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
7070 assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control"))
7171 assert.Equal(t, []string{"Accept-Encoding"}, anon.hdr.Values("Vary"))
7272 assert.Contains(t, anon.body, "feat/search")
73- assert.Contains(t, anon.body, "package search</td>", "the diff of the single commit")
73+ assert.Contains(t, anon.body, `<span class="kn">package</span> search</td>`, "the diff of the single commit")
7474 assert.NotContains(t, anon.body, "<form class=\"cform create\"")
7575 assert.NotContains(t, anon.body, `<form class="cform create"`)
7676 assertNoInline(t, anon.body)
@@ -134,7 +134,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
134134 assert.Contains(t, mr.body, "<h1><span class=\"mute\">#1</span> Add &lt;b&gt;search&lt;/b&gt;</h1>")
135135 assert.NotContains(t, mr.body, "<b>search</b>")
136136 assert.Contains(t, mr.body, "feat/search")
137- assert.Contains(t, mr.body, "package search</td>")
137+ assert.Contains(t, mr.body, `<span class="kn">package</span> search</td>`)
138138 assert.Contains(t, mr.body, "no checks")
139139 assert.Contains(t, mr.body, "fast-forward, squash")
140140 // The description is Markdown, rendered as a README: no raw HTML, no
@@ -337,7 +337,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
337337 assert.Equal(t, "private, no-store", since.hdr.Get("Cache-Control"))
338338 assert.Contains(t, since.body, "changes since "+head1[:7])
339339 assert.Contains(t, since.body, "extra</td>")
340- assert.NotContains(t, since.body, "package search</td>", "only what changed after the read head")
340+ assert.NotContains(t, since.body, `<span class="kn">package</span> search</td>`, "only what changed after the read head")
341341 assert.Contains(t, since.body, "Why &lt;i&gt;empty&lt;/i&gt;?", "the notes are listed below, their lines are not in this diff")
342342 assert.Contains(t, since.body, "search.go:3 (new)")
343343 assert.NotContains(t, since.body, `<form class="cform"`, "no comment form on this diff")
@@ -70,7 +70,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
70 assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control"))70 assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control"))
71 assert.Equal(t, []string{"Accept-Encoding"}, anon.hdr.Values("Vary"))71 assert.Equal(t, []string{"Accept-Encoding"}, anon.hdr.Values("Vary"))
72 assert.Contains(t, anon.body, "feat/search")72 assert.Contains(t, anon.body, "feat/search")
73- assert.Contains(t, anon.body, "package search</td>", "the diff of the single commit")73+ assert.Contains(t, anon.body, `<span class="kn">package</span> search</td>`, "the diff of the single commit")
74 assert.NotContains(t, anon.body, "<form class=\"cform create\"")74 assert.NotContains(t, anon.body, "<form class=\"cform create\"")
75 assert.NotContains(t, anon.body, `<form class="cform create"`)75 assert.NotContains(t, anon.body, `<form class="cform create"`)
76 assertNoInline(t, anon.body)76 assertNoInline(t, anon.body)
@@ -134,7 +134,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
134 assert.Contains(t, mr.body, "<h1><span class=\"mute\">#1</span> Add &lt;b&gt;search&lt;/b&gt;</h1>")134 assert.Contains(t, mr.body, "<h1><span class=\"mute\">#1</span> Add &lt;b&gt;search&lt;/b&gt;</h1>")
135 assert.NotContains(t, mr.body, "<b>search</b>")135 assert.NotContains(t, mr.body, "<b>search</b>")
136 assert.Contains(t, mr.body, "feat/search")136 assert.Contains(t, mr.body, "feat/search")
137- assert.Contains(t, mr.body, "package search</td>")137+ assert.Contains(t, mr.body, `<span class="kn">package</span> search</td>`)
138 assert.Contains(t, mr.body, "no checks")138 assert.Contains(t, mr.body, "no checks")
139 assert.Contains(t, mr.body, "fast-forward, squash")139 assert.Contains(t, mr.body, "fast-forward, squash")
140 // The description is Markdown, rendered as a README: no raw HTML, no140 // The description is Markdown, rendered as a README: no raw HTML, no
@@ -337,7 +337,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
337 assert.Equal(t, "private, no-store", since.hdr.Get("Cache-Control"))337 assert.Equal(t, "private, no-store", since.hdr.Get("Cache-Control"))
338 assert.Contains(t, since.body, "changes since "+head1[:7])338 assert.Contains(t, since.body, "changes since "+head1[:7])
339 assert.Contains(t, since.body, "extra</td>")339 assert.Contains(t, since.body, "extra</td>")
340- assert.NotContains(t, since.body, "package search</td>", "only what changed after the read head")340+ assert.NotContains(t, since.body, `<span class="kn">package</span> search</td>`, "only what changed after the read head")
341 assert.Contains(t, since.body, "Why &lt;i&gt;empty&lt;/i&gt;?", "the notes are listed below, their lines are not in this diff")341 assert.Contains(t, since.body, "Why &lt;i&gt;empty&lt;/i&gt;?", "the notes are listed below, their lines are not in this diff")
342 assert.Contains(t, since.body, "search.go:3 (new)")342 assert.Contains(t, since.body, "search.go:3 (new)")
343 assert.NotContains(t, since.body, `<form class="cform"`, "no comment form on this diff")343 assert.NotContains(t, since.body, `<form class="cform"`, "no comment form on this diff")

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 2

Tags 41

↑↓ moveenter openesc close