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 Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe 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, | |||
| 120 | 120 | NFiles: res.Stats.FilesChanged, | |
| 121 | 121 | Adds: res.Stats.Additions, | |
| 122 | 122 | Dels: res.Stats.Deletions, | |
| 123 | - | Files: diffFiles(res.Diff.Files), | |
| 123 | + | Files: s.hl.files(ctx, res.Diff.Files), | |
| 124 | 124 | } | |
| 125 | 125 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid)) | |
| 126 | 126 | 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, | |||
| 86 | 86 | return nil, 0, err | |
| 87 | 87 | } | |
| 88 | 88 | p.HasDiff = true | |
| 89 | - | p.Files = diffFiles(res.Diff.Files) | |
| 89 | + | p.Files = s.hl.files(ctx, res.Diff.Files) | |
| 90 | 90 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String())) | |
| 91 | 91 | p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions | |
| 92 | 92 | 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, err | 86 | return nil, 0, err | ||
| 87 | } | 87 | } | ||
| 88 | p.HasDiff = true | 88 | 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.Deletions | 91 | 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 | |||
| 81 | 81 | } | |
| 82 | 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 | 87 | var out ContextRows | |
| 85 | 88 | for i := newFrom - 1; i < cut; i++ { | |
| 86 | 89 | 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 | 94 | out.Lines = append(out.Lines, DiffLine{ | |
| 88 | - | Kind: "ctx", Mark: " ", Segs: segments(lines[i]), | |
| 95 | + | Kind: "ctx", Mark: " ", Segs: segs, | |
| 89 | 96 | Old: itoa(o), New: itoa(nw), oldN: o, newN: nw, | |
| 90 | 97 | NoEOL: noEOL && i == len(lines)-1, | |
| 91 | 98 | }) | |
| @@ -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 ContextRows | 87 | 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+1 | 89 | 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 ( | |||
| 22 | 22 | type Seg struct { | |
| 23 | 23 | Text string | |
| 24 | 24 | 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 | 30 | // 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 | 229 | // segments splits a line of code into plain text and markers. One trailing | |
| 227 | 230 | // carriage return, the terminator of a CRLF file, is dropped. | |
| 228 | 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 | 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 | 242 | start := 0 | |
| 235 | 243 | for i, r := range text { | |
| 236 | 244 | if !invisible(r) { | |
| 237 | 245 | continue | |
| 238 | 246 | } | |
| 239 | 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 | 250 | out = append(out, Seg{Text: marker(r), Mark: true}) | |
| 243 | 251 | start = i + utf8.RuneLen(r) | |
| 244 | 252 | } | |
| 245 | 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 | 256 | return out | |
| 249 | 257 | } | |
| @@ -424,9 +432,12 @@ func codeLine(ln DiffLine) templ.Component { | |||
| 424 | 432 | return templ.ComponentFunc(func(_ context.Context, w io.Writer) error { | |
| 425 | 433 | var b strings.Builder | |
| 426 | 434 | for _, s := range ln.Segs { | |
| 427 | - | if s.Mark { | |
| 435 | + | switch { | |
| 436 | + | case s.Mark: | |
| 428 | 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 | 441 | b.WriteString(html.EscapeString(s.Text)) | |
| 431 | 442 | } | |
| 432 | 443 | } | |
| @@ -22,6 +22,9 @@ import ( | |||||
| 22 | type Seg struct { | 22 | type Seg struct { | ||
| 23 | Text string | 23 | Text string | ||
| 24 | Mark bool | 24 | 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 the | 30 | // 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 trailing | 229 | // 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 := 0 | 242 | start := 0 | ||
| 235 | for i, r := range text { | 243 | for i, r := range text { | ||
| 236 | if !invisible(r) { | 244 | if !invisible(r) { | ||
| 237 | continue | 245 | 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 out | 256 | 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.Builder | 433 | 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) { | |||
| 221 | 221 | <td class="ln">@gutter(ln.Old, ln.OldHref)</td> | |
| 222 | 222 | <td class="ln">@gutter(ln.New, ln.NewHref)</td> | |
| 223 | 223 | <td class="m">{ ln.Mark }</td> | |
| 224 | - | <td class="tx">@codeLine(ln)</td> | |
| 224 | + | <td class="tx chroma">@codeLine(ln)</td> | |
| 225 | 225 | </tr> | |
| 226 | 226 | if len(ln.Notes) > 0 || ln.Form != nil { | |
| 227 | 227 | <tr class="fnote"> | |
| @@ -325,7 +325,7 @@ templ DiffContext(c ContextRows) { | |||
| 325 | 325 | <td class="ln">{ ln.Old }</td> | |
| 326 | 326 | <td class="ln">{ ln.New }</td> | |
| 327 | 327 | <td class="m">{ ln.Mark }</td> | |
| 328 | - | <td class="tx">@codeLine(ln)</td> | |
| 328 | + | <td class="tx chroma">@codeLine(ln)</td> | |
| 329 | 329 | </tr> | |
| 330 | 330 | } | |
| 331 | 331 | if c.More != nil { | |
| @@ -362,7 +362,7 @@ templ splitSide(ln *DiffLine, old bool) { | |||
| 362 | 362 | } | |
| 363 | 363 | </td> | |
| 364 | 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 | ||
| @@ -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 <b> */</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], `<U+202E>`) | |
| 135 | + | assert.Contains(t, rendered(t, f)[0], `<script>`) | |
| 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 <b> */</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], `<U+202E>`) | |||
| 135 | + | assert.Contains(t, rendered(t, f)[0], `<script>`) | |||
| 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
| @@ -1359,7 +1359,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1359 | 1359 | if templ_7745c5c3_Err != nil { | |
| 1360 | 1360 | return templ_7745c5c3_Err | |
| 1361 | 1361 | } | |
| 1362 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</td><td class=\"tx\">") | |
| 1362 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</td><td class=\"tx chroma\">") | |
| 1363 | 1363 | if templ_7745c5c3_Err != nil { | |
| 1364 | 1364 | return templ_7745c5c3_Err | |
| 1365 | 1365 | } | |
| @@ -1781,7 +1781,7 @@ func DiffContext(c ContextRows) templ.Component { | |||
| 1781 | 1781 | if templ_7745c5c3_Err != nil { | |
| 1782 | 1782 | return templ_7745c5c3_Err | |
| 1783 | 1783 | } | |
| 1784 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</td><td class=\"tx\">") | |
| 1784 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</td><td class=\"tx chroma\">") | |
| 1785 | 1785 | if templ_7745c5c3_Err != nil { | |
| 1786 | 1786 | return templ_7745c5c3_Err | |
| 1787 | 1787 | } | |
| @@ -1937,7 +1937,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component { | |||
| 1937 | 1937 | if templ_7745c5c3_Err != nil { | |
| 1938 | 1938 | return templ_7745c5c3_Err | |
| 1939 | 1939 | } | |
| 1940 | - | var templ_7745c5c3_Var93 = []any{"tx", ln.Kind} | |
| 1940 | + | var templ_7745c5c3_Var93 = []any{"tx", "chroma", ln.Kind} | |
| 1941 | 1941 | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var93...) | |
| 1942 | 1942 | if templ_7745c5c3_Err != nil { | |
| 1943 | 1943 | return templ_7745c5c3_Err | |
| @@ -1359,7 +1359,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||||
| 1359 | if templ_7745c5c3_Err != nil { | 1359 | if templ_7745c5c3_Err != nil { | ||
| 1360 | return templ_7745c5c3_Err | 1360 | return templ_7745c5c3_Err | ||
| 1361 | } | 1361 | } | ||
| 1362 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</td><td class=\"tx\">") | 1362 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</td><td class=\"tx chroma\">") |
| 1363 | if templ_7745c5c3_Err != nil { | 1363 | if templ_7745c5c3_Err != nil { | ||
| 1364 | return templ_7745c5c3_Err | 1364 | return templ_7745c5c3_Err | ||
| 1365 | } | 1365 | } | ||
| @@ -1781,7 +1781,7 @@ func DiffContext(c ContextRows) templ.Component { | |||||
| 1781 | if templ_7745c5c3_Err != nil { | 1781 | if templ_7745c5c3_Err != nil { | ||
| 1782 | return templ_7745c5c3_Err | 1782 | return templ_7745c5c3_Err | ||
| 1783 | } | 1783 | } | ||
| 1784 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</td><td class=\"tx\">") | 1784 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</td><td class=\"tx chroma\">") |
| 1785 | if templ_7745c5c3_Err != nil { | 1785 | if templ_7745c5c3_Err != nil { | ||
| 1786 | return templ_7745c5c3_Err | 1786 | return templ_7745c5c3_Err | ||
| 1787 | } | 1787 | } | ||
| @@ -1937,7 +1937,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component { | |||||
| 1937 | if templ_7745c5c3_Err != nil { | 1937 | if templ_7745c5c3_Err != nil { | ||
| 1938 | return templ_7745c5c3_Err | 1938 | return templ_7745c5c3_Err | ||
| 1939 | } | 1939 | } | ||
| 1940 | - | var templ_7745c5c3_Var93 = []any{"tx", ln.Kind} | 1940 | + | var templ_7745c5c3_Var93 = []any{"tx", "chroma", ln.Kind} |
| 1941 | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var93...) | 1941 | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var93...) | ||
| 1942 | if templ_7745c5c3_Err != nil { | 1942 | if templ_7745c5c3_Err != nil { | ||
| 1943 | return templ_7745c5c3_Err | 1943 | return templ_7745c5c3_Err | ||
M internal/adapters/web/highlight.go +7 -0
| @@ -144,6 +144,12 @@ func lexerFor(name string) chroma.Lexer { | |||
| 144 | 144 | // lexer that gave up half-way (its regexp timeout) leaves behind. A lexer | |
| 145 | 145 | // may add a final line feed (EnsureNL), nothing else. | |
| 146 | 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 | 153 | defer func() { | |
| 148 | 154 | if recover() != nil { | |
| 149 | 155 | toks, ok = nil, false | |
| @@ -153,6 +159,7 @@ func tokenise(ctx context.Context, lx chroma.Lexer, src string) (toks []chroma.T | |||
| 153 | 159 | if err != nil { | |
| 154 | 160 | return nil, false | |
| 155 | 161 | } | |
| 162 | + | toks = buf | |
| 156 | 163 | for t := it(); t != chroma.EOF; t = it() { | |
| 157 | 164 | if len(toks)%256 == 0 && ctx.Err() != nil { | |
| 158 | 165 | 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 lexer | 144 | // 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, false | 155 | 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, false | 160 | 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, false | 165 | 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, "<i>two</i>") | |
| 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, "<i>two</i>") | |||
| 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 | |||
| 476 | 476 | NFiles: res.Stats.FilesChanged, | |
| 477 | 477 | Adds: res.Stats.Additions, | |
| 478 | 478 | Dels: res.Stats.Deletions, | |
| 479 | - | Files: diffFiles(res.Diff.Files), | |
| 479 | + | Files: s.hl.files(ctx, res.Diff.Files), | |
| 480 | 480 | } | |
| 481 | 481 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head)) | |
| 482 | 482 | 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, | |||
| 196 | 196 | m := res.MergeRequest | |
| 197 | 197 | signed := SignedIn(ctx) | |
| 198 | 198 | 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 | 200 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String())) | |
| 201 | 201 | s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil) | |
| 202 | 202 | 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.MergeRequest | 196 | m := res.MergeRequest | ||
| 197 | signed := SignedIn(ctx) | 197 | signed := SignedIn(ctx) | ||
| 198 | canWrite := signed && m.State == domain.MRStateOpen && !since | 198 | 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 @@ | |||
| 44 | 44 | --hdr-h is its height, the offset of the sticky bars of the diffs. */ | |
| 45 | 45 | body { --hdr-h: 57px; } | |
| 46 | 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 | 50 | .hdr { | |
| 48 | 51 | position: sticky; top: 0; z-index: 20; | |
| 49 | 52 | 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 { | |||
| 108 | 108 | md *markdown | |
| 109 | 109 | // pathCache holds the path lists the file finder fetches. | |
| 110 | 110 | pathCache *pathCache | |
| 111 | - | mux *http.ServeMux | |
| 111 | + | // hl holds the colours of highlighted diffs. | |
| 112 | + | hl *hlCache | |
| 113 | + | mux *http.ServeMux | |
| 112 | 114 | // ownerRepo is the owner login as a repository name, zero when it is not | |
| 113 | 115 | // a valid one (then no repository can be named like the owner). | |
| 114 | 116 | ownerRepo domain.RepoName | |
| @@ -141,6 +143,7 @@ func New(cfg Config) (*Server, error) { | |||
| 141 | 143 | s := &Server{ | |
| 142 | 144 | cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(), | |
| 143 | 145 | pathCache: newPathCache(pathCacheBytes, pathBuilds), | |
| 146 | + | hl: newHLCache(hlCacheBytes), | |
| 144 | 147 | origin: originOf(u), | |
| 145 | 148 | } | |
| 146 | 149 | s.ownerRepo, _ = domain.ParseRepoName(cfg.Owner) | |
| @@ -108,7 +108,9 @@ type Server struct { | |||||
| 108 | md *markdown | 108 | md *markdown | ||
| 109 | // pathCache holds the path lists the file finder fetches. | 109 | // pathCache holds the path lists the file finder fetches. | ||
| 110 | pathCache *pathCache | 110 | pathCache *pathCache | ||
| 111 | - | mux *http.ServeMux | 111 | + | // 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 not | 114 | // 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.RepoName | 116 | 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 @@ | |||
| 142 | 142 | </td> | |
| 143 | 143 | <td class="ln">1</td> | |
| 144 | 144 | <td class="m">+</td> | |
| 145 | - | <td class="tx">from the side</td> | |
| 145 | + | <td class="tx chroma">from the side</td> | |
| 146 | 146 | </tr> | |
| 147 | 147 | </tbody> | |
| 148 | 148 | </table> | |
| @@ -168,7 +168,7 @@ | |||
| 168 | 168 | </td> | |
| 169 | 169 | <td class="ln add">1</td> | |
| 170 | 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 | 172 | </tr> | |
| 173 | 173 | </tbody> | |
| 174 | 174 | </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 <b>html</b> & "quotes".</pre> | |||
| 149 | 149 | <td class="ln">1</td> | |
| 150 | 150 | <td class="m"> | |
| 151 | 151 | </td> | |
| 152 | - | <td class="tx">one</td> | |
| 152 | + | <td class="tx chroma">one</td> | |
| 153 | 153 | </tr> | |
| 154 | 154 | <tr class="del"> | |
| 155 | 155 | <td class="ln">2</td> | |
| 156 | 156 | <td class="ln"> | |
| 157 | 157 | </td> | |
| 158 | 158 | <td class="m">-</td> | |
| 159 | - | <td class="tx">two</td> | |
| 159 | + | <td class="tx chroma">two</td> | |
| 160 | 160 | </tr> | |
| 161 | 161 | <tr class="add"> | |
| 162 | 162 | <td class="ln"> | |
| 163 | 163 | </td> | |
| 164 | 164 | <td class="ln">2</td> | |
| 165 | 165 | <td class="m">+</td> | |
| 166 | - | <td class="tx">2</td> | |
| 166 | + | <td class="tx chroma">2</td> | |
| 167 | 167 | </tr> | |
| 168 | 168 | <tr class="ctx"> | |
| 169 | 169 | <td class="ln">3</td> | |
| 170 | 170 | <td class="ln">3</td> | |
| 171 | 171 | <td class="m"> | |
| 172 | 172 | </td> | |
| 173 | - | <td class="tx">three</td> | |
| 173 | + | <td class="tx chroma">three</td> | |
| 174 | 174 | </tr> | |
| 175 | 175 | <tr class="ctx"> | |
| 176 | 176 | <td class="ln">4</td> | |
| 177 | 177 | <td class="ln">4</td> | |
| 178 | 178 | <td class="m"> | |
| 179 | 179 | </td> | |
| 180 | - | <td class="tx">four</td> | |
| 180 | + | <td class="tx chroma">four</td> | |
| 181 | 181 | </tr> | |
| 182 | 182 | <tr class="ctx"> | |
| 183 | 183 | <td class="ln">5</td> | |
| 184 | 184 | <td class="ln">5</td> | |
| 185 | 185 | <td class="m"> | |
| 186 | 186 | </td> | |
| 187 | - | <td class="tx">five</td> | |
| 187 | + | <td class="tx chroma">five</td> | |
| 188 | 188 | </tr> | |
| 189 | 189 | <tr class="add"> | |
| 190 | 190 | <td class="ln"> | |
| 191 | 191 | </td> | |
| 192 | 192 | <td class="ln">6</td> | |
| 193 | 193 | <td class="m">+</td> | |
| 194 | - | <td class="tx">six</td> | |
| 194 | + | <td class="tx chroma">six</td> | |
| 195 | 195 | </tr> | |
| 196 | 196 | </tbody> | |
| 197 | 197 | </table> | |
| @@ -212,49 +212,49 @@ And <b>html</b> & "quotes".</pre> | |||
| 212 | 212 | <td class="ln ctx">1</td> | |
| 213 | 213 | <td class="m ctx"> | |
| 214 | 214 | </td> | |
| 215 | - | <td class="tx ctx">one</td> | |
| 215 | + | <td class="tx chroma ctx">one</td> | |
| 216 | 216 | <td class="ln ctx">1</td> | |
| 217 | 217 | <td class="m ctx"> | |
| 218 | 218 | </td> | |
| 219 | - | <td class="tx ctx">one</td> | |
| 219 | + | <td class="tx chroma ctx">one</td> | |
| 220 | 220 | </tr> | |
| 221 | 221 | <tr> | |
| 222 | 222 | <td class="ln del">2</td> | |
| 223 | 223 | <td class="m del">-</td> | |
| 224 | - | <td class="tx del">two</td> | |
| 224 | + | <td class="tx chroma del">two</td> | |
| 225 | 225 | <td class="ln add">2</td> | |
| 226 | 226 | <td class="m add">+</td> | |
| 227 | - | <td class="tx add">2</td> | |
| 227 | + | <td class="tx chroma add">2</td> | |
| 228 | 228 | </tr> | |
| 229 | 229 | <tr> | |
| 230 | 230 | <td class="ln ctx">3</td> | |
| 231 | 231 | <td class="m ctx"> | |
| 232 | 232 | </td> | |
| 233 | - | <td class="tx ctx">three</td> | |
| 233 | + | <td class="tx chroma ctx">three</td> | |
| 234 | 234 | <td class="ln ctx">3</td> | |
| 235 | 235 | <td class="m ctx"> | |
| 236 | 236 | </td> | |
| 237 | - | <td class="tx ctx">three</td> | |
| 237 | + | <td class="tx chroma ctx">three</td> | |
| 238 | 238 | </tr> | |
| 239 | 239 | <tr> | |
| 240 | 240 | <td class="ln ctx">4</td> | |
| 241 | 241 | <td class="m ctx"> | |
| 242 | 242 | </td> | |
| 243 | - | <td class="tx ctx">four</td> | |
| 243 | + | <td class="tx chroma ctx">four</td> | |
| 244 | 244 | <td class="ln ctx">4</td> | |
| 245 | 245 | <td class="m ctx"> | |
| 246 | 246 | </td> | |
| 247 | - | <td class="tx ctx">four</td> | |
| 247 | + | <td class="tx chroma ctx">four</td> | |
| 248 | 248 | </tr> | |
| 249 | 249 | <tr> | |
| 250 | 250 | <td class="ln ctx">5</td> | |
| 251 | 251 | <td class="m ctx"> | |
| 252 | 252 | </td> | |
| 253 | - | <td class="tx ctx">five</td> | |
| 253 | + | <td class="tx chroma ctx">five</td> | |
| 254 | 254 | <td class="ln ctx">5</td> | |
| 255 | 255 | <td class="m ctx"> | |
| 256 | 256 | </td> | |
| 257 | - | <td class="tx ctx">five</td> | |
| 257 | + | <td class="tx chroma ctx">five</td> | |
| 258 | 258 | </tr> | |
| 259 | 259 | <tr> | |
| 260 | 260 | <td class="ln none"> | |
| @@ -265,7 +265,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 265 | 265 | </td> | |
| 266 | 266 | <td class="ln add">6</td> | |
| 267 | 267 | <td class="m add">+</td> | |
| 268 | - | <td class="tx add">six</td> | |
| 268 | + | <td class="tx chroma add">six</td> | |
| 269 | 269 | </tr> | |
| 270 | 270 | </tbody> | |
| 271 | 271 | </table> | |
| @@ -149,49 +149,49 @@ And <b>html</b> & "quotes".</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 <b>html</b> & "quotes".</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 <b>html</b> & "quotes".</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 <b>html</b> & "quotes".</pre> | |||
| 149 | 149 | <td class="ln">1</td> | |
| 150 | 150 | <td class="m"> | |
| 151 | 151 | </td> | |
| 152 | - | <td class="tx">one</td> | |
| 152 | + | <td class="tx chroma">one</td> | |
| 153 | 153 | </tr> | |
| 154 | 154 | <tr class="del"> | |
| 155 | 155 | <td class="ln">2</td> | |
| 156 | 156 | <td class="ln"> | |
| 157 | 157 | </td> | |
| 158 | 158 | <td class="m">-</td> | |
| 159 | - | <td class="tx">two</td> | |
| 159 | + | <td class="tx chroma">two</td> | |
| 160 | 160 | </tr> | |
| 161 | 161 | <tr class="add"> | |
| 162 | 162 | <td class="ln"> | |
| 163 | 163 | </td> | |
| 164 | 164 | <td class="ln">2</td> | |
| 165 | 165 | <td class="m">+</td> | |
| 166 | - | <td class="tx">2</td> | |
| 166 | + | <td class="tx chroma">2</td> | |
| 167 | 167 | </tr> | |
| 168 | 168 | <tr class="ctx"> | |
| 169 | 169 | <td class="ln">3</td> | |
| 170 | 170 | <td class="ln">3</td> | |
| 171 | 171 | <td class="m"> | |
| 172 | 172 | </td> | |
| 173 | - | <td class="tx">three</td> | |
| 173 | + | <td class="tx chroma">three</td> | |
| 174 | 174 | </tr> | |
| 175 | 175 | <tr class="ctx"> | |
| 176 | 176 | <td class="ln">4</td> | |
| 177 | 177 | <td class="ln">4</td> | |
| 178 | 178 | <td class="m"> | |
| 179 | 179 | </td> | |
| 180 | - | <td class="tx">four</td> | |
| 180 | + | <td class="tx chroma">four</td> | |
| 181 | 181 | </tr> | |
| 182 | 182 | <tr class="ctx"> | |
| 183 | 183 | <td class="ln">5</td> | |
| 184 | 184 | <td class="ln">5</td> | |
| 185 | 185 | <td class="m"> | |
| 186 | 186 | </td> | |
| 187 | - | <td class="tx">five</td> | |
| 187 | + | <td class="tx chroma">five</td> | |
| 188 | 188 | </tr> | |
| 189 | 189 | <tr class="add"> | |
| 190 | 190 | <td class="ln"> | |
| 191 | 191 | </td> | |
| 192 | 192 | <td class="ln">6</td> | |
| 193 | 193 | <td class="m">+</td> | |
| 194 | - | <td class="tx">six</td> | |
| 194 | + | <td class="tx chroma">six</td> | |
| 195 | 195 | </tr> | |
| 196 | 196 | </tbody> | |
| 197 | 197 | </table> | |
| @@ -212,49 +212,49 @@ And <b>html</b> & "quotes".</pre> | |||
| 212 | 212 | <td class="ln ctx">1</td> | |
| 213 | 213 | <td class="m ctx"> | |
| 214 | 214 | </td> | |
| 215 | - | <td class="tx ctx">one</td> | |
| 215 | + | <td class="tx chroma ctx">one</td> | |
| 216 | 216 | <td class="ln ctx">1</td> | |
| 217 | 217 | <td class="m ctx"> | |
| 218 | 218 | </td> | |
| 219 | - | <td class="tx ctx">one</td> | |
| 219 | + | <td class="tx chroma ctx">one</td> | |
| 220 | 220 | </tr> | |
| 221 | 221 | <tr> | |
| 222 | 222 | <td class="ln del">2</td> | |
| 223 | 223 | <td class="m del">-</td> | |
| 224 | - | <td class="tx del">two</td> | |
| 224 | + | <td class="tx chroma del">two</td> | |
| 225 | 225 | <td class="ln add">2</td> | |
| 226 | 226 | <td class="m add">+</td> | |
| 227 | - | <td class="tx add">2</td> | |
| 227 | + | <td class="tx chroma add">2</td> | |
| 228 | 228 | </tr> | |
| 229 | 229 | <tr> | |
| 230 | 230 | <td class="ln ctx">3</td> | |
| 231 | 231 | <td class="m ctx"> | |
| 232 | 232 | </td> | |
| 233 | - | <td class="tx ctx">three</td> | |
| 233 | + | <td class="tx chroma ctx">three</td> | |
| 234 | 234 | <td class="ln ctx">3</td> | |
| 235 | 235 | <td class="m ctx"> | |
| 236 | 236 | </td> | |
| 237 | - | <td class="tx ctx">three</td> | |
| 237 | + | <td class="tx chroma ctx">three</td> | |
| 238 | 238 | </tr> | |
| 239 | 239 | <tr> | |
| 240 | 240 | <td class="ln ctx">4</td> | |
| 241 | 241 | <td class="m ctx"> | |
| 242 | 242 | </td> | |
| 243 | - | <td class="tx ctx">four</td> | |
| 243 | + | <td class="tx chroma ctx">four</td> | |
| 244 | 244 | <td class="ln ctx">4</td> | |
| 245 | 245 | <td class="m ctx"> | |
| 246 | 246 | </td> | |
| 247 | - | <td class="tx ctx">four</td> | |
| 247 | + | <td class="tx chroma ctx">four</td> | |
| 248 | 248 | </tr> | |
| 249 | 249 | <tr> | |
| 250 | 250 | <td class="ln ctx">5</td> | |
| 251 | 251 | <td class="m ctx"> | |
| 252 | 252 | </td> | |
| 253 | - | <td class="tx ctx">five</td> | |
| 253 | + | <td class="tx chroma ctx">five</td> | |
| 254 | 254 | <td class="ln ctx">5</td> | |
| 255 | 255 | <td class="m ctx"> | |
| 256 | 256 | </td> | |
| 257 | - | <td class="tx ctx">five</td> | |
| 257 | + | <td class="tx chroma ctx">five</td> | |
| 258 | 258 | </tr> | |
| 259 | 259 | <tr> | |
| 260 | 260 | <td class="ln none"> | |
| @@ -265,7 +265,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 265 | 265 | </td> | |
| 266 | 266 | <td class="ln add">6</td> | |
| 267 | 267 | <td class="m add">+</td> | |
| 268 | - | <td class="tx add">six</td> | |
| 268 | + | <td class="tx chroma add">six</td> | |
| 269 | 269 | </tr> | |
| 270 | 270 | </tbody> | |
| 271 | 271 | </table> | |
| @@ -149,49 +149,49 @@ And <b>html</b> & "quotes".</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 <b>html</b> & "quotes".</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 <b>html</b> & "quotes".</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 @@ | |||
| 153 | 153 | <td class="ln"> | |
| 154 | 154 | </td> | |
| 155 | 155 | <td class="m">-</td> | |
| 156 | - | <td class="tx">bye</td> | |
| 156 | + | <td class="tx chroma">bye</td> | |
| 157 | 157 | </tr> | |
| 158 | 158 | </tbody> | |
| 159 | 159 | </table> | |
| @@ -173,7 +173,7 @@ | |||
| 173 | 173 | <tr> | |
| 174 | 174 | <td class="ln del">1</td> | |
| 175 | 175 | <td class="m del">-</td> | |
| 176 | - | <td class="tx del">bye</td> | |
| 176 | + | <td class="tx chroma del">bye</td> | |
| 177 | 177 | <td class="ln none"> | |
| 178 | 178 | </td> | |
| 179 | 179 | <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 @@ | |||
| 141 | 141 | </td> | |
| 142 | 142 | <td class="ln">1</td> | |
| 143 | 143 | <td class="m">+</td> | |
| 144 | - | <td class="tx">one</td> | |
| 144 | + | <td class="tx chroma">one</td> | |
| 145 | 145 | </tr> | |
| 146 | 146 | <tr class="add"> | |
| 147 | 147 | <td class="ln"> | |
| 148 | 148 | </td> | |
| 149 | 149 | <td class="ln">2</td> | |
| 150 | 150 | <td class="m">+</td> | |
| 151 | - | <td class="tx">two</td> | |
| 151 | + | <td class="tx chroma">two</td> | |
| 152 | 152 | </tr> | |
| 153 | 153 | <tr class="add"> | |
| 154 | 154 | <td class="ln"> | |
| 155 | 155 | </td> | |
| 156 | 156 | <td class="ln">3</td> | |
| 157 | 157 | <td class="m">+</td> | |
| 158 | - | <td class="tx">three</td> | |
| 158 | + | <td class="tx chroma">three</td> | |
| 159 | 159 | </tr> | |
| 160 | 160 | <tr class="add"> | |
| 161 | 161 | <td class="ln"> | |
| 162 | 162 | </td> | |
| 163 | 163 | <td class="ln">4</td> | |
| 164 | 164 | <td class="m">+</td> | |
| 165 | - | <td class="tx">four</td> | |
| 165 | + | <td class="tx chroma">four</td> | |
| 166 | 166 | </tr> | |
| 167 | 167 | <tr class="add"> | |
| 168 | 168 | <td class="ln"> | |
| 169 | 169 | </td> | |
| 170 | 170 | <td class="ln">5</td> | |
| 171 | 171 | <td class="m">+</td> | |
| 172 | - | <td class="tx">five</td> | |
| 172 | + | <td class="tx chroma">five</td> | |
| 173 | 173 | </tr> | |
| 174 | 174 | </tbody> | |
| 175 | 175 | </table> | |
| @@ -195,7 +195,7 @@ | |||
| 195 | 195 | </td> | |
| 196 | 196 | <td class="ln add">1</td> | |
| 197 | 197 | <td class="m add">+</td> | |
| 198 | - | <td class="tx add">one</td> | |
| 198 | + | <td class="tx chroma add">one</td> | |
| 199 | 199 | </tr> | |
| 200 | 200 | <tr> | |
| 201 | 201 | <td class="ln none"> | |
| @@ -206,7 +206,7 @@ | |||
| 206 | 206 | </td> | |
| 207 | 207 | <td class="ln add">2</td> | |
| 208 | 208 | <td class="m add">+</td> | |
| 209 | - | <td class="tx add">two</td> | |
| 209 | + | <td class="tx chroma add">two</td> | |
| 210 | 210 | </tr> | |
| 211 | 211 | <tr> | |
| 212 | 212 | <td class="ln none"> | |
| @@ -217,7 +217,7 @@ | |||
| 217 | 217 | </td> | |
| 218 | 218 | <td class="ln add">3</td> | |
| 219 | 219 | <td class="m add">+</td> | |
| 220 | - | <td class="tx add">three</td> | |
| 220 | + | <td class="tx chroma add">three</td> | |
| 221 | 221 | </tr> | |
| 222 | 222 | <tr> | |
| 223 | 223 | <td class="ln none"> | |
| @@ -228,7 +228,7 @@ | |||
| 228 | 228 | </td> | |
| 229 | 229 | <td class="ln add">4</td> | |
| 230 | 230 | <td class="m add">+</td> | |
| 231 | - | <td class="tx add">four</td> | |
| 231 | + | <td class="tx chroma add">four</td> | |
| 232 | 232 | </tr> | |
| 233 | 233 | <tr> | |
| 234 | 234 | <td class="ln none"> | |
| @@ -239,7 +239,7 @@ | |||
| 239 | 239 | </td> | |
| 240 | 240 | <td class="ln add">5</td> | |
| 241 | 241 | <td class="m add">+</td> | |
| 242 | - | <td class="tx add">five</td> | |
| 242 | + | <td class="tx chroma add">five</td> | |
| 243 | 243 | </tr> | |
| 244 | 244 | </tbody> | |
| 245 | 245 | </table> | |
| @@ -263,7 +263,7 @@ | |||
| 263 | 263 | </td> | |
| 264 | 264 | <td class="ln">1</td> | |
| 265 | 265 | <td class="m">+</td> | |
| 266 | - | <td class="tx">bye</td> | |
| 266 | + | <td class="tx chroma">bye</td> | |
| 267 | 267 | </tr> | |
| 268 | 268 | </tbody> | |
| 269 | 269 | </table> | |
| @@ -289,7 +289,7 @@ | |||
| 289 | 289 | </td> | |
| 290 | 290 | <td class="ln add">1</td> | |
| 291 | 291 | <td class="m add">+</td> | |
| 292 | - | <td class="tx add">bye</td> | |
| 292 | + | <td class="tx chroma add">bye</td> | |
| 293 | 293 | </tr> | |
| 294 | 294 | </tbody> | |
| 295 | 295 | </table> | |
| @@ -323,21 +323,21 @@ | |||
| 323 | 323 | </td> | |
| 324 | 324 | <td class="ln">1</td> | |
| 325 | 325 | <td class="m">+</td> | |
| 326 | - | <td class="tx">keep me</td> | |
| 326 | + | <td class="tx chroma">keep me</td> | |
| 327 | 327 | </tr> | |
| 328 | 328 | <tr class="add"> | |
| 329 | 329 | <td class="ln"> | |
| 330 | 330 | </td> | |
| 331 | 331 | <td class="ln">2</td> | |
| 332 | 332 | <td class="m">+</td> | |
| 333 | - | <td class="tx">keep me too</td> | |
| 333 | + | <td class="tx chroma">keep me too</td> | |
| 334 | 334 | </tr> | |
| 335 | 335 | <tr class="add"> | |
| 336 | 336 | <td class="ln"> | |
| 337 | 337 | </td> | |
| 338 | 338 | <td class="ln">3</td> | |
| 339 | 339 | <td class="m">+</td> | |
| 340 | - | <td class="tx">and me</td> | |
| 340 | + | <td class="tx chroma">and me</td> | |
| 341 | 341 | </tr> | |
| 342 | 342 | </tbody> | |
| 343 | 343 | </table> | |
| @@ -363,7 +363,7 @@ | |||
| 363 | 363 | </td> | |
| 364 | 364 | <td class="ln add">1</td> | |
| 365 | 365 | <td class="m add">+</td> | |
| 366 | - | <td class="tx add">keep me</td> | |
| 366 | + | <td class="tx chroma add">keep me</td> | |
| 367 | 367 | </tr> | |
| 368 | 368 | <tr> | |
| 369 | 369 | <td class="ln none"> | |
| @@ -374,7 +374,7 @@ | |||
| 374 | 374 | </td> | |
| 375 | 375 | <td class="ln add">2</td> | |
| 376 | 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 | 378 | </tr> | |
| 379 | 379 | <tr> | |
| 380 | 380 | <td class="ln none"> | |
| @@ -385,7 +385,7 @@ | |||
| 385 | 385 | </td> | |
| 386 | 386 | <td class="ln add">3</td> | |
| 387 | 387 | <td class="m add">+</td> | |
| 388 | - | <td class="tx add">and me</td> | |
| 388 | + | <td class="tx chroma add">and me</td> | |
| 389 | 389 | </tr> | |
| 390 | 390 | </tbody> | |
| 391 | 391 | </table> | |
| @@ -409,14 +409,14 @@ | |||
| 409 | 409 | </td> | |
| 410 | 410 | <td class="ln">1</td> | |
| 411 | 411 | <td class="m">+</td> | |
| 412 | - | <td class="tx">#!/bin/sh</td> | |
| 412 | + | <td class="tx chroma">#!/bin/sh</td> | |
| 413 | 413 | </tr> | |
| 414 | 414 | <tr class="add"> | |
| 415 | 415 | <td class="ln"> | |
| 416 | 416 | </td> | |
| 417 | 417 | <td class="ln">2</td> | |
| 418 | 418 | <td class="m">+</td> | |
| 419 | - | <td class="tx">echo hi</td> | |
| 419 | + | <td class="tx chroma">echo hi</td> | |
| 420 | 420 | </tr> | |
| 421 | 421 | </tbody> | |
| 422 | 422 | </table> | |
| @@ -442,7 +442,7 @@ | |||
| 442 | 442 | </td> | |
| 443 | 443 | <td class="ln add">1</td> | |
| 444 | 444 | <td class="m add">+</td> | |
| 445 | - | <td class="tx add">#!/bin/sh</td> | |
| 445 | + | <td class="tx chroma add">#!/bin/sh</td> | |
| 446 | 446 | </tr> | |
| 447 | 447 | <tr> | |
| 448 | 448 | <td class="ln none"> | |
| @@ -453,7 +453,7 @@ | |||
| 453 | 453 | </td> | |
| 454 | 454 | <td class="ln add">2</td> | |
| 455 | 455 | <td class="m add">+</td> | |
| 456 | - | <td class="tx add">echo hi</td> | |
| 456 | + | <td class="tx chroma add">echo hi</td> | |
| 457 | 457 | </tr> | |
| 458 | 458 | </tbody> | |
| 459 | 459 | </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 @@ | |||
| 145 | 145 | </td> | |
| 146 | 146 | <td class="ln">1</td> | |
| 147 | 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 | 150 | </tr> | |
| 150 | 151 | <tr class="add"> | |
| 151 | 152 | <td class="ln"> | |
| 152 | 153 | </td> | |
| 153 | 154 | <td class="ln">2</td> | |
| 154 | 155 | <td class="m">+</td> | |
| 155 | - | <td class="tx"> | |
| 156 | + | <td class="tx chroma"> | |
| 156 | 157 | </td> | |
| 157 | 158 | </tr> | |
| 158 | 159 | <tr class="add"> | |
| @@ -160,44 +161,63 @@ | |||
| 160 | 161 | </td> | |
| 161 | 162 | <td class="ln">3</td> | |
| 162 | 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 | 166 | </tr> | |
| 165 | 167 | <tr class="add"> | |
| 166 | 168 | <td class="ln"> | |
| 167 | 169 | </td> | |
| 168 | 170 | <td class="ln">4</td> | |
| 169 | 171 | <td class="m">+</td> | |
| 170 | - | <td class="tx"> if user == "user<span class="inv" title="invisible character"><U+202E></span> | |
| 171 | - | <span class="inv" title="invisible character"><U+2066></span>// check<span class="inv" title="invisible character"><U+2069></span> | |
| 172 | - | <span class="inv" title="invisible character"><U+2066></span>" {</td> | |
| 172 | + | <td class="tx chroma"> | |
| 173 | + | <span class="k">if</span> user == <span class="s">"user</span> | |
| 174 | + | <span class="inv" title="invisible character"><U+202E></span> | |
| 175 | + | <span class="s"> | |
| 176 | + | </span> | |
| 177 | + | <span class="inv" title="invisible character"><U+2066></span> | |
| 178 | + | <span class="s">// check</span> | |
| 179 | + | <span class="inv" title="invisible character"><U+2069></span> | |
| 180 | + | <span class="s"> | |
| 181 | + | </span> | |
| 182 | + | <span class="inv" title="invisible character"><U+2066></span> | |
| 183 | + | <span class="s">"</span> {</td> | |
| 173 | 184 | </tr> | |
| 174 | 185 | <tr class="add"> | |
| 175 | 186 | <td class="ln"> | |
| 176 | 187 | </td> | |
| 177 | 188 | <td class="ln">5</td> | |
| 178 | 189 | <td class="m">+</td> | |
| 179 | - | <td class="tx"> return true // z<span class="inv" title="invisible character"><U+200B></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"><U+200B></span> | |
| 195 | + | <span class="c1">w</span> | |
| 196 | + | </td> | |
| 180 | 197 | </tr> | |
| 181 | 198 | <tr class="add"> | |
| 182 | 199 | <td class="ln"> | |
| 183 | 200 | </td> | |
| 184 | 201 | <td class="ln">6</td> | |
| 185 | 202 | <td class="m">+</td> | |
| 186 | - | <td class="tx"> }</td> | |
| 203 | + | <td class="tx chroma"> }</td> | |
| 187 | 204 | </tr> | |
| 188 | 205 | <tr class="add"> | |
| 189 | 206 | <td class="ln"> | |
| 190 | 207 | </td> | |
| 191 | 208 | <td class="ln">7</td> | |
| 192 | 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 | 214 | </tr> | |
| 195 | 215 | <tr class="add"> | |
| 196 | 216 | <td class="ln"> | |
| 197 | 217 | </td> | |
| 198 | 218 | <td class="ln">8</td> | |
| 199 | 219 | <td class="m">+</td> | |
| 200 | - | <td class="tx">}</td> | |
| 220 | + | <td class="tx chroma">}</td> | |
| 201 | 221 | </tr> | |
| 202 | 222 | </tbody> | |
| 203 | 223 | </table> | |
| @@ -223,7 +243,8 @@ | |||
| 223 | 243 | </td> | |
| 224 | 244 | <td class="ln add">1</td> | |
| 225 | 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 | 248 | </tr> | |
| 228 | 249 | <tr> | |
| 229 | 250 | <td class="ln none"> | |
| @@ -234,7 +255,7 @@ | |||
| 234 | 255 | </td> | |
| 235 | 256 | <td class="ln add">2</td> | |
| 236 | 257 | <td class="m add">+</td> | |
| 237 | - | <td class="tx add"> | |
| 258 | + | <td class="tx chroma add"> | |
| 238 | 259 | </td> | |
| 239 | 260 | </tr> | |
| 240 | 261 | <tr> | |
| @@ -246,7 +267,8 @@ | |||
| 246 | 267 | </td> | |
| 247 | 268 | <td class="ln add">3</td> | |
| 248 | 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 | 272 | </tr> | |
| 251 | 273 | <tr> | |
| 252 | 274 | <td class="ln none"> | |
| @@ -257,9 +279,18 @@ | |||
| 257 | 279 | </td> | |
| 258 | 280 | <td class="ln add">4</td> | |
| 259 | 281 | <td class="m add">+</td> | |
| 260 | - | <td class="tx add"> if user == "user<span class="inv" title="invisible character"><U+202E></span> | |
| 261 | - | <span class="inv" title="invisible character"><U+2066></span>// check<span class="inv" title="invisible character"><U+2069></span> | |
| 262 | - | <span class="inv" title="invisible character"><U+2066></span>" {</td> | |
| 282 | + | <td class="tx chroma add"> | |
| 283 | + | <span class="k">if</span> user == <span class="s">"user</span> | |
| 284 | + | <span class="inv" title="invisible character"><U+202E></span> | |
| 285 | + | <span class="s"> | |
| 286 | + | </span> | |
| 287 | + | <span class="inv" title="invisible character"><U+2066></span> | |
| 288 | + | <span class="s">// check</span> | |
| 289 | + | <span class="inv" title="invisible character"><U+2069></span> | |
| 290 | + | <span class="s"> | |
| 291 | + | </span> | |
| 292 | + | <span class="inv" title="invisible character"><U+2066></span> | |
| 293 | + | <span class="s">"</span> {</td> | |
| 263 | 294 | </tr> | |
| 264 | 295 | <tr> | |
| 265 | 296 | <td class="ln none"> | |
| @@ -270,7 +301,13 @@ | |||
| 270 | 301 | </td> | |
| 271 | 302 | <td class="ln add">5</td> | |
| 272 | 303 | <td class="m add">+</td> | |
| 273 | - | <td class="tx add"> return true // z<span class="inv" title="invisible character"><U+200B></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"><U+200B></span> | |
| 309 | + | <span class="c1">w</span> | |
| 310 | + | </td> | |
| 274 | 311 | </tr> | |
| 275 | 312 | <tr> | |
| 276 | 313 | <td class="ln none"> | |
| @@ -281,7 +318,7 @@ | |||
| 281 | 318 | </td> | |
| 282 | 319 | <td class="ln add">6</td> | |
| 283 | 320 | <td class="m add">+</td> | |
| 284 | - | <td class="tx add"> }</td> | |
| 321 | + | <td class="tx chroma add"> }</td> | |
| 285 | 322 | </tr> | |
| 286 | 323 | <tr> | |
| 287 | 324 | <td class="ln none"> | |
| @@ -292,7 +329,10 @@ | |||
| 292 | 329 | </td> | |
| 293 | 330 | <td class="ln add">7</td> | |
| 294 | 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 | 336 | </tr> | |
| 297 | 337 | <tr> | |
| 298 | 338 | <td class="ln none"> | |
| @@ -303,7 +343,7 @@ | |||
| 303 | 343 | </td> | |
| 304 | 344 | <td class="ln add">8</td> | |
| 305 | 345 | <td class="m add">+</td> | |
| 306 | - | <td class="tx add">}</td> | |
| 346 | + | <td class="tx chroma add">}</td> | |
| 307 | 347 | </tr> | |
| 308 | 348 | </tbody> | |
| 309 | 349 | </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 == "user<span class="inv" title="invisible character"><U+202E></span> | 172 | + | <td class="tx chroma"> |
| 171 | - | <span class="inv" title="invisible character"><U+2066></span>// check<span class="inv" title="invisible character"><U+2069></span> | 173 | + | <span class="k">if</span> user == <span class="s">"user</span> |
| 172 | - | <span class="inv" title="invisible character"><U+2066></span>" {</td> | 174 | + | <span class="inv" title="invisible character"><U+202E></span> |
| 175 | + | <span class="s"> | |||
| 176 | + | </span> | |||
| 177 | + | <span class="inv" title="invisible character"><U+2066></span> | |||
| 178 | + | <span class="s">// check</span> | |||
| 179 | + | <span class="inv" title="invisible character"><U+2069></span> | |||
| 180 | + | <span class="s"> | |||
| 181 | + | </span> | |||
| 182 | + | <span class="inv" title="invisible character"><U+2066></span> | |||
| 183 | + | <span class="s">"</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"><U+200B></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"><U+200B></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 == "user<span class="inv" title="invisible character"><U+202E></span> | 282 | + | <td class="tx chroma add"> |
| 261 | - | <span class="inv" title="invisible character"><U+2066></span>// check<span class="inv" title="invisible character"><U+2069></span> | 283 | + | <span class="k">if</span> user == <span class="s">"user</span> |
| 262 | - | <span class="inv" title="invisible character"><U+2066></span>" {</td> | 284 | + | <span class="inv" title="invisible character"><U+202E></span> |
| 285 | + | <span class="s"> | |||
| 286 | + | </span> | |||
| 287 | + | <span class="inv" title="invisible character"><U+2066></span> | |||
| 288 | + | <span class="s">// check</span> | |||
| 289 | + | <span class="inv" title="invisible character"><U+2069></span> | |||
| 290 | + | <span class="s"> | |||
| 291 | + | </span> | |||
| 292 | + | <span class="inv" title="invisible character"><U+2066></span> | |||
| 293 | + | <span class="s">"</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"><U+200B></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"><U+200B></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) { | |||
| 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 <b></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 | 226 | // The hidden lines of a diff: the page has a gap before a hunk and after the | |
| 190 | 227 | // last one, and /context serves their rows for both layouts, numbered on both | |
| 191 | 228 | // 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 <b></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 the | 226 | // 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 both | 227 | // 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) { | |||
| 70 | 70 | assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control")) | |
| 71 | 71 | assert.Equal(t, []string{"Accept-Encoding"}, anon.hdr.Values("Vary")) | |
| 72 | 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 | 74 | assert.NotContains(t, anon.body, "<form class=\"cform create\"") | |
| 75 | 75 | assert.NotContains(t, anon.body, `<form class="cform create"`) | |
| 76 | 76 | assertNoInline(t, anon.body) | |
| @@ -134,7 +134,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||
| 134 | 134 | assert.Contains(t, mr.body, "<h1><span class=\"mute\">#1</span> Add <b>search</b></h1>") | |
| 135 | 135 | assert.NotContains(t, mr.body, "<b>search</b>") | |
| 136 | 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 | 138 | assert.Contains(t, mr.body, "no checks") | |
| 139 | 139 | assert.Contains(t, mr.body, "fast-forward, squash") | |
| 140 | 140 | // The description is Markdown, rendered as a README: no raw HTML, no | |
| @@ -337,7 +337,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) { | |||
| 337 | 337 | assert.Equal(t, "private, no-store", since.hdr.Get("Cache-Control")) | |
| 338 | 338 | assert.Contains(t, since.body, "changes since "+head1[:7]) | |
| 339 | 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 | 341 | assert.Contains(t, since.body, "Why <i>empty</i>?", "the notes are listed below, their lines are not in this diff") | |
| 342 | 342 | assert.Contains(t, since.body, "search.go:3 (new)") | |
| 343 | 343 | 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 <b>search</b></h1>") | 134 | assert.Contains(t, mr.body, "<h1><span class=\"mute\">#1</span> Add <b>search</b></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, no | 140 | // 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 <i>empty</i>?", "the notes are listed below, their lines are not in this diff") | 341 | assert.Contains(t, since.body, "Why <i>empty</i>?", "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") | ||