detached 6d4e2aa

feat(web): collapse generated and large diff files with on-demand load

commit
6d4e2aa7f2e1d1a57b3f039f84eaf2c39e10437f
author
brice · · 2026-10-20 08:00 UTC
committer
brice · · 2026-10-20 08:01 UTC
parent
c3d0391
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
29 files · +1096 -467
M docs/merge-requests.md +6 -0
@@ -63,6 +63,12 @@ In `mr show --json`, `mergeability` holds `state`, `behind`, `behind_capped`, `c
6363
6464 In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box.
6565
66+## Large diffs on the web
67+
68+The merge request, commit and compare pages list every changed file. Generated files (lockfiles, `vendor/`, `node_modules/`, minified or generated code) and large files (over 2,000 changed lines) are collapsed: the header and the counts show, the lines do not. "load diff" fetches that one file in place, in full, and you can comment on its lines. Without scripts the link reloads the page with that file loaded. After the first 25 files of a merge request, the other files load as you scroll.
69+
70+A file over 8 MiB of text cannot be shown in the browser: the page links to its raw blob. A commit page lists at most 300 files; clone the repository to read the rest.
71+
6672 ## Notes
6773
6874 A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
@@ -63,6 +63,12 @@ In `mr show --json`, `mergeability` holds `state`, `behind`, `behind_capped`, `c
63 63
64 In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box.64 In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box.
65 65
66+## Large diffs on the web
67+
68+The merge request, commit and compare pages list every changed file. Generated files (lockfiles, `vendor/`, `node_modules/`, minified or generated code) and large files (over 2,000 changed lines) are collapsed: the header and the counts show, the lines do not. "load diff" fetches that one file in place, in full, and you can comment on its lines. Without scripts the link reloads the page with that file loaded. After the first 25 files of a merge request, the other files load as you scroll.
69+
70+A file over 8 MiB of text cannot be shown in the browser: the page links to its raw blob. A commit page lists at most 300 files; clone the repository to read the rest.
71+
66 ## Notes72 ## Notes
67 73
68 A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.74 A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
M internal/adapters/web/assets.go +2 -1
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
2424 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2525 // the content, so they can be cached for ever.
2626 type Assets struct {
27- CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string
27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, Lazy, MR, IssueForm, PowWorker, Icon string
2828 // Manifest is the web app manifest (install on a phone's home screen),
2929 // AppleIcon the home screen icon iOS reads from a link instead.
3030 Manifest, AppleIcon string
@@ -64,6 +64,7 @@ var scripts = []struct {
6464 {"files.js", func(a *Assets, u string) { a.Files = u }},
6565 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
6666 {"log.js", func(a *Assets, u string) { a.Log = u }},
67+ {"lazy.js", func(a *Assets, u string) { a.Lazy = u }},
6768 {"mr.js", func(a *Assets, u string) { a.MR = u }},
6869 {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},
6970 {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
24 // Assets are the URLs of the assets a page loads. Their names carry a hash of24 // Assets are the URLs of the assets a page loads. Their names carry a hash of
25 // the content, so they can be cached for ever.25 // the content, so they can be cached for ever.
26 type Assets struct {26 type Assets struct {
27- CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, Lazy, MR, IssueForm, PowWorker, Icon string
28 // Manifest is the web app manifest (install on a phone's home screen),28 // Manifest is the web app manifest (install on a phone's home screen),
29 // AppleIcon the home screen icon iOS reads from a link instead.29 // AppleIcon the home screen icon iOS reads from a link instead.
30 Manifest, AppleIcon string30 Manifest, AppleIcon string
@@ -64,6 +64,7 @@ var scripts = []struct {
64 {"files.js", func(a *Assets, u string) { a.Files = u }},64 {"files.js", func(a *Assets, u string) { a.Files = u }},
65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
66 {"log.js", func(a *Assets, u string) { a.Log = u }},66 {"log.js", func(a *Assets, u string) { a.Log = u }},
67+ {"lazy.js", func(a *Assets, u string) { a.Lazy = u }},
67 {"mr.js", func(a *Assets, u string) { a.MR = u }},68 {"mr.js", func(a *Assets, u string) { a.MR = u }},
68 {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},69 {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},
69 {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},70 {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},
M internal/adapters/web/commit.go +80 -11
@@ -106,7 +106,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
106106 if err != nil {
107107 return nil, 0, err
108108 }
109- res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{})
109+ res, err := s.commitDiff(ctx, r, d.repo.Name, sha)
110110 if err != nil {
111111 return nil, 0, err
112112 }
@@ -129,6 +129,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
129129 Verification: c.Verification,
130130 }
131131 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
132+ collapseFiles(p.Files, res.Diff.Files, commitLinks(d.name(), cid))
132133 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
133134 p.Committer = &cm
134135 }
@@ -167,6 +168,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
167168 {Label: p.Short, Current: true},
168169 }
169170 l.HasRail = true
171+ l.Lazy = true
170172 return Commit(l, p), http.StatusOK, nil
171173 }
172174
@@ -187,23 +189,90 @@ func commitBody(c domain.Commit) string {
187189 return messageText(strings.TrimSpace(body))
188190 }
189191
190-// diffNote says what is missing from an incomplete diff, "" for a complete one.
192+// diffNote says what is missing from an incomplete diff, "" for a complete one:
193+// files past the limit of the listing, files too large for the browser at all.
194+// Collapsed files say so on their own line and load on their own.
191195 func diffNote(d domain.Diff, shown, total int) string {
192- cut := 0
196+ gone := 0
193197 for _, f := range d.Files {
194- if f.Omitted != domain.OmitNone {
195- cut++
198+ if f.Omitted == domain.OmitUnavailable {
199+ gone++
196200 }
197201 }
198- if !d.Truncated && cut == 0 {
199- return ""
200- }
201202 var parts []string
202- if shown < total {
203+ if d.Truncated && shown < total {
203204 parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")
204205 }
205- if cut > 0 {
206- parts = append(parts, plural(cut, "file is", "files are")+" too large to show")
206+ if gone > 0 {
207+ parts = append(parts, plural(gone, "file is", "files are")+" too large to show")
208+ }
209+ if len(parts) == 0 {
210+ return ""
207211 }
208212 return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."
209213 }
214+
215+// commitWindow is how many files of a commit get their hunks read with the
216+// page; the files past it are collapsed, and load on their own.
217+const commitWindow = 300
218+
219+// commitDiff reads a commit for its page (and the compare page): every file
220+// listed, the hunks of the first commitWindow files read, generated files
221+// collapsed. Files past the window are collapsed too. With ?file=i (the link
222+// of a page without scripts) file i is read in full and replaces its entry.
223+func (s *Server) commitDiff(ctx context.Context, r *http.Request, repo domain.RepoName, rev string) (domain.CommitDetail, error) {
224+ res, err := s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: commitWindow}})
225+ if err != nil {
226+ return res, err
227+ }
228+ for i := commitWindow; i < len(res.Diff.Files); i++ {
229+ if f := &res.Diff.Files[i]; !f.Binary && f.Omitted == domain.OmitNone && f.Additions+f.Deletions > 0 {
230+ f.Omitted = domain.OmitBudget
231+ }
232+ }
233+ if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() {
234+ one, err := s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
235+ if err != nil {
236+ return res, err
237+ }
238+ if i < len(one.Diff.Files) {
239+ res.Diff.Files[i] = one.Diff.Files[i]
240+ }
241+ }
242+ return res, nil
243+}
244+
245+// commitLinks are the addresses a commit page loads its collapsed files from.
246+func commitLinks(repo, rev string) fileLinks {
247+ return fileLinks{src: repoHref(repo, "commit", rev, "files"), repo: repo, rev: rev}
248+}
249+
250+// commitFiles is GET /<repo>/commit/<sha>/files?one=1&from=i[&view=&layouts=]:
251+// the one file i of the commit with its hunks, in full, as the one
252+// details.dfile the page swaps for its placeholder.
253+func (s *Server) commitFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
254+ sha := r.PathValue("sha")
255+ i, err := parseOne(r)
256+ if err != nil || !isCommitID(sha) {
257+ return nil, 0, errNotFound
258+ }
259+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
260+ if err != nil {
261+ return nil, 0, err
262+ }
263+ res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
264+ if err != nil {
265+ return nil, 0, err
266+ }
267+ if i >= len(res.Diff.Files) {
268+ return nil, 0, errNotFound
269+ }
270+ cid := res.Commit.ID.String()
271+ raw := res.Diff.Files[i : i+1]
272+ files := s.hl.files(ctx, raw)
273+ files[0].ID = "f" + itoa(i+1)
274+ contextGaps(files, raw, repoHref(d.name(), "context", cid))
275+ collapseFiles(files, raw, commitLinks(d.name(), cid))
276+ lines, _ := diffLines(files)
277+ return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil
278+}
@@ -106,7 +106,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
106 if err != nil {106 if err != nil {
107 return nil, 0, err107 return nil, 0, err
108 }108 }
109- res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{})109+ res, err := s.commitDiff(ctx, r, d.repo.Name, sha)
110 if err != nil {110 if err != nil {
111 return nil, 0, err111 return nil, 0, err
112 }112 }
@@ -129,6 +129,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
129 Verification: c.Verification,129 Verification: c.Verification,
130 }130 }
131 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))131 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
132+ collapseFiles(p.Files, res.Diff.Files, commitLinks(d.name(), cid))
132 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {133 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
133 p.Committer = &cm134 p.Committer = &cm
134 }135 }
@@ -167,6 +168,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
167 {Label: p.Short, Current: true},168 {Label: p.Short, Current: true},
168 }169 }
169 l.HasRail = true170 l.HasRail = true
171+ l.Lazy = true
170 return Commit(l, p), http.StatusOK, nil172 return Commit(l, p), http.StatusOK, nil
171 }173 }
172 174
@@ -187,23 +189,90 @@ func commitBody(c domain.Commit) string {
187 return messageText(strings.TrimSpace(body))189 return messageText(strings.TrimSpace(body))
188 }190 }
189 191
190-// diffNote says what is missing from an incomplete diff, "" for a complete one.192+// diffNote says what is missing from an incomplete diff, "" for a complete one:
193+// files past the limit of the listing, files too large for the browser at all.
194+// Collapsed files say so on their own line and load on their own.
191 func diffNote(d domain.Diff, shown, total int) string {195 func diffNote(d domain.Diff, shown, total int) string {
192- cut := 0196+ gone := 0
193 for _, f := range d.Files {197 for _, f := range d.Files {
194- if f.Omitted != domain.OmitNone {198+ if f.Omitted == domain.OmitUnavailable {
195- cut++199+ gone++
196 }200 }
197 }201 }
198- if !d.Truncated && cut == 0 {
199- return ""
200- }
201 var parts []string202 var parts []string
202- if shown < total {203+ if d.Truncated && shown < total {
203 parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")204 parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")
204 }205 }
205- if cut > 0 {206+ if gone > 0 {
206- parts = append(parts, plural(cut, "file is", "files are")+" too large to show")207+ parts = append(parts, plural(gone, "file is", "files are")+" too large to show")
208+ }
209+ if len(parts) == 0 {
210+ return ""
207 }211 }
208 return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."212 return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."
209 }213 }
214+
215+// commitWindow is how many files of a commit get their hunks read with the
216+// page; the files past it are collapsed, and load on their own.
217+const commitWindow = 300
218+
219+// commitDiff reads a commit for its page (and the compare page): every file
220+// listed, the hunks of the first commitWindow files read, generated files
221+// collapsed. Files past the window are collapsed too. With ?file=i (the link
222+// of a page without scripts) file i is read in full and replaces its entry.
223+func (s *Server) commitDiff(ctx context.Context, r *http.Request, repo domain.RepoName, rev string) (domain.CommitDetail, error) {
224+ res, err := s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: commitWindow}})
225+ if err != nil {
226+ return res, err
227+ }
228+ for i := commitWindow; i < len(res.Diff.Files); i++ {
229+ if f := &res.Diff.Files[i]; !f.Binary && f.Omitted == domain.OmitNone && f.Additions+f.Deletions > 0 {
230+ f.Omitted = domain.OmitBudget
231+ }
232+ }
233+ if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() {
234+ one, err := s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
235+ if err != nil {
236+ return res, err
237+ }
238+ if i < len(one.Diff.Files) {
239+ res.Diff.Files[i] = one.Diff.Files[i]
240+ }
241+ }
242+ return res, nil
243+}
244+
245+// commitLinks are the addresses a commit page loads its collapsed files from.
246+func commitLinks(repo, rev string) fileLinks {
247+ return fileLinks{src: repoHref(repo, "commit", rev, "files"), repo: repo, rev: rev}
248+}
249+
250+// commitFiles is GET /<repo>/commit/<sha>/files?one=1&from=i[&view=&layouts=]:
251+// the one file i of the commit with its hunks, in full, as the one
252+// details.dfile the page swaps for its placeholder.
253+func (s *Server) commitFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
254+ sha := r.PathValue("sha")
255+ i, err := parseOne(r)
256+ if err != nil || !isCommitID(sha) {
257+ return nil, 0, errNotFound
258+ }
259+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
260+ if err != nil {
261+ return nil, 0, err
262+ }
263+ res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
264+ if err != nil {
265+ return nil, 0, err
266+ }
267+ if i >= len(res.Diff.Files) {
268+ return nil, 0, errNotFound
269+ }
270+ cid := res.Commit.ID.String()
271+ raw := res.Diff.Files[i : i+1]
272+ files := s.hl.files(ctx, raw)
273+ files[0].ID = "f" + itoa(i+1)
274+ contextGaps(files, raw, repoHref(d.name(), "context", cid))
275+ collapseFiles(files, raw, commitLinks(d.name(), cid))
276+ lines, _ := diffLines(files)
277+ return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil
278+}
M internal/adapters/web/compare.go +3 -1
@@ -76,13 +76,14 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
7676
7777 // The exact diff: one commit on top of the tip of the target.
7878 if exact && !behind && len(ahead) == 1 {
79- res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String(), ports.GetCommitOptions{})
79+ res, err := s.commitDiff(ctx, r, d.repo.Name, ahead[0].ID.String())
8080 if err != nil {
8181 return nil, 0, err
8282 }
8383 p.HasDiff = true
8484 p.Files = s.hl.files(ctx, res.Diff.Files)
8585 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
86+ collapseFiles(p.Files, res.Diff.Files, commitLinks(name, res.Commit.ID.String()))
8687 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
8788 if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {
8889 p.Note = n
@@ -131,6 +132,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
131132 Crumb{Label: p.Source, Current: true},
132133 )
133134 l.HasRail = true
135+ l.Lazy = true
134136 return Compare(l, p), http.StatusOK, nil
135137 }
136138
@@ -76,13 +76,14 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
76 76
77 // The exact diff: one commit on top of the tip of the target.77 // The exact diff: one commit on top of the tip of the target.
78 if exact && !behind && len(ahead) == 1 {78 if exact && !behind && len(ahead) == 1 {
79- res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String(), ports.GetCommitOptions{})79+ res, err := s.commitDiff(ctx, r, d.repo.Name, ahead[0].ID.String())
80 if err != nil {80 if err != nil {
81 return nil, 0, err81 return nil, 0, err
82 }82 }
83 p.HasDiff = true83 p.HasDiff = true
84 p.Files = s.hl.files(ctx, res.Diff.Files)84 p.Files = s.hl.files(ctx, res.Diff.Files)
85 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))85 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
86+ collapseFiles(p.Files, res.Diff.Files, commitLinks(name, res.Commit.ID.String()))
86 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions87 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
87 if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {88 if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {
88 p.Note = n89 p.Note = n
@@ -131,6 +132,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
131 Crumb{Label: p.Source, Current: true},132 Crumb{Label: p.Source, Current: true},
132 )133 )
133 l.HasRail = true134 l.HasRail = true
135+ l.Lazy = true
134 return Compare(l, p), http.StatusOK, nil136 return Compare(l, p), http.StatusOK, nil
135 }137 }
136 138
M internal/adapters/web/diff.go +89 -3
@@ -7,8 +7,10 @@ import (
77 "fmt"
88 "html"
99 "io"
10+ "net/http"
1011 "net/url"
1112 "slices"
13+ "strconv"
1214 "strings"
1315 "unicode/utf8"
1416
@@ -123,6 +125,14 @@ type DiffFile struct {
123125 Note string
124126 Open bool
125127 Hunks []DiffHunk
128+ // Manual is true for a collapsed file (generated, large, over the page
129+ // budget): no hunks, a placeholder with a "load diff" control that reads
130+ // that one file (Src, empty when it cannot be loaded in place; Href is the
131+ // page without scripts).
132+ Manual bool
133+ Href string
134+ // Raw is the address of the raw blob of a file too large to show.
135+ Raw string
126136 // Digits is the width of the largest line number of the file, at least 2
127137 // and at most 7: the gutter is as wide as the file needs (class lnw-N).
128138 Digits int
@@ -371,7 +381,8 @@ func diffFiles(files []domain.FileDiff) []DiffFile {
371381 Add: f.Additions, Del: f.Deletions, Bin: f.Binary,
372382 // A file that is shown in full opens; a file with nothing to read
373383 // (binary, truncated) stays closed under its note.
374- Open: len(f.Hunks) > 0 && f.Omitted == domain.OmitNone,
384+ Open: len(f.Hunks) > 0 && f.Omitted == domain.OmitNone,
385+ Manual: f.Omitted.Collapsed(),
375386 }
376387 df.Key = f.NewPath
377388 switch f.Status {
@@ -406,8 +417,14 @@ func fileNote(f domain.FileDiff) string {
406417 switch {
407418 case f.Binary:
408419 parts = append(parts, "binary file: no text diff")
409- case f.Omitted != domain.OmitNone:
410- parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions))
420+ case f.Omitted == domain.OmitGenerated:
421+ parts = append(parts, "generated file: collapsed")
422+ case f.Omitted == domain.OmitLarge:
423+ parts = append(parts, fmt.Sprintf("large diff (%d lines changed): collapsed", f.Additions+f.Deletions))
424+ case f.Omitted == domain.OmitBudget:
425+ parts = append(parts, "collapsed to keep the page light")
426+ case f.Omitted == domain.OmitUnavailable:
427+ parts = append(parts, "too large to show in the browser (over 8 MiB of text)")
411428 }
412429 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {
413430 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))
@@ -449,3 +466,72 @@ func codeLine(ln DiffLine) templ.Component {
449466 return err
450467 })
451468 }
469+
470+// fileLinks says where the collapsed files of a diff are loaded from.
471+type fileLinks struct {
472+ // src is the address of the fragment route of the diff ("" when files
473+ // cannot be loaded in place); one file is src plus one=1&from=i.
474+ src string
475+ // from is the window of the page, kept in the links of a page without
476+ // scripts.
477+ from int
478+ // repo and rev name the raw blob of a file that cannot be shown.
479+ repo, rev string
480+}
481+
482+// oneFileSrc is the address of the fragment holding file i alone.
483+func oneFileSrc(src string, i int) string {
484+ sep := "?"
485+ if strings.Contains(src, "?") {
486+ sep = "&"
487+ }
488+ return src + sep + "one=1&from=" + itoa(i)
489+}
490+
491+// collapseFiles gives the collapsed files of a diff their load address, and
492+// the files too large to show a link to their raw blob. Nothing is read.
493+func collapseFiles(out []DiffFile, files []domain.FileDiff, l fileLinks) {
494+ for i, f := range files {
495+ if i >= len(out) {
496+ break
497+ }
498+ switch {
499+ case f.Omitted.Collapsed():
500+ if l.src != "" {
501+ out[i].Src = oneFileSrc(l.src, i)
502+ }
503+ q := "?file=" + itoa(i)
504+ if l.from > 0 {
505+ q = "?from=" + itoa(l.from) + "&file=" + itoa(i)
506+ }
507+ out[i].Href = q + "#" + out[i].ID
508+ case f.Omitted == domain.OmitUnavailable && f.Status != domain.FileDeleted && l.rev != "":
509+ out[i].Raw = repoHref(l.repo, "raw", l.rev, f.NewPath)
510+ }
511+ }
512+}
513+
514+// fileParam reads ?file= of a page without scripts: the index of a collapsed
515+// file to read in full, in [0, n).
516+func fileParam(r *http.Request, n int) (int, bool) {
517+ s := r.URL.Query().Get("file")
518+ i, err := strconv.Atoi(s)
519+ if err != nil || i < 0 || i >= n || strconv.Itoa(i) != s {
520+ return 0, false
521+ }
522+ return i, true
523+}
524+
525+// fragmentView lays out a one-file fragment like the page that asks for it:
526+// ?view= is the layout shown and ?layouts= says whether the page holds both.
527+func fragmentView(r *http.Request, lines int) DiffView {
528+ v := newDiffView(r, lines, "", "")
529+ switch r.URL.Query().Get("layouts") {
530+ case "both":
531+ v.Both = true
532+ case "one":
533+ v.Both = false
534+ }
535+ v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
536+ return v
537+}
@@ -7,8 +7,10 @@ import (
7 "fmt"7 "fmt"
8 "html"8 "html"
9 "io"9 "io"
10+ "net/http"
10 "net/url"11 "net/url"
11 "slices"12 "slices"
13+ "strconv"
12 "strings"14 "strings"
13 "unicode/utf8"15 "unicode/utf8"
14 16
@@ -123,6 +125,14 @@ type DiffFile struct {
123 Note string125 Note string
124 Open bool126 Open bool
125 Hunks []DiffHunk127 Hunks []DiffHunk
128+ // Manual is true for a collapsed file (generated, large, over the page
129+ // budget): no hunks, a placeholder with a "load diff" control that reads
130+ // that one file (Src, empty when it cannot be loaded in place; Href is the
131+ // page without scripts).
132+ Manual bool
133+ Href string
134+ // Raw is the address of the raw blob of a file too large to show.
135+ Raw string
126 // Digits is the width of the largest line number of the file, at least 2136 // Digits is the width of the largest line number of the file, at least 2
127 // and at most 7: the gutter is as wide as the file needs (class lnw-N).137 // and at most 7: the gutter is as wide as the file needs (class lnw-N).
128 Digits int138 Digits int
@@ -371,7 +381,8 @@ func diffFiles(files []domain.FileDiff) []DiffFile {
371 Add: f.Additions, Del: f.Deletions, Bin: f.Binary,381 Add: f.Additions, Del: f.Deletions, Bin: f.Binary,
372 // A file that is shown in full opens; a file with nothing to read382 // A file that is shown in full opens; a file with nothing to read
373 // (binary, truncated) stays closed under its note.383 // (binary, truncated) stays closed under its note.
374- Open: len(f.Hunks) > 0 && f.Omitted == domain.OmitNone,384+ Open: len(f.Hunks) > 0 && f.Omitted == domain.OmitNone,
385+ Manual: f.Omitted.Collapsed(),
375 }386 }
376 df.Key = f.NewPath387 df.Key = f.NewPath
377 switch f.Status {388 switch f.Status {
@@ -406,8 +417,14 @@ func fileNote(f domain.FileDiff) string {
406 switch {417 switch {
407 case f.Binary:418 case f.Binary:
408 parts = append(parts, "binary file: no text diff")419 parts = append(parts, "binary file: no text diff")
409- case f.Omitted != domain.OmitNone:420+ case f.Omitted == domain.OmitGenerated:
410- parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions))421+ parts = append(parts, "generated file: collapsed")
422+ case f.Omitted == domain.OmitLarge:
423+ parts = append(parts, fmt.Sprintf("large diff (%d lines changed): collapsed", f.Additions+f.Deletions))
424+ case f.Omitted == domain.OmitBudget:
425+ parts = append(parts, "collapsed to keep the page light")
426+ case f.Omitted == domain.OmitUnavailable:
427+ parts = append(parts, "too large to show in the browser (over 8 MiB of text)")
411 }428 }
412 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {429 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {
413 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))430 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))
@@ -449,3 +466,72 @@ func codeLine(ln DiffLine) templ.Component {
449 return err466 return err
450 })467 })
451 }468 }
469+
470+// fileLinks says where the collapsed files of a diff are loaded from.
471+type fileLinks struct {
472+ // src is the address of the fragment route of the diff ("" when files
473+ // cannot be loaded in place); one file is src plus one=1&from=i.
474+ src string
475+ // from is the window of the page, kept in the links of a page without
476+ // scripts.
477+ from int
478+ // repo and rev name the raw blob of a file that cannot be shown.
479+ repo, rev string
480+}
481+
482+// oneFileSrc is the address of the fragment holding file i alone.
483+func oneFileSrc(src string, i int) string {
484+ sep := "?"
485+ if strings.Contains(src, "?") {
486+ sep = "&"
487+ }
488+ return src + sep + "one=1&from=" + itoa(i)
489+}
490+
491+// collapseFiles gives the collapsed files of a diff their load address, and
492+// the files too large to show a link to their raw blob. Nothing is read.
493+func collapseFiles(out []DiffFile, files []domain.FileDiff, l fileLinks) {
494+ for i, f := range files {
495+ if i >= len(out) {
496+ break
497+ }
498+ switch {
499+ case f.Omitted.Collapsed():
500+ if l.src != "" {
501+ out[i].Src = oneFileSrc(l.src, i)
502+ }
503+ q := "?file=" + itoa(i)
504+ if l.from > 0 {
505+ q = "?from=" + itoa(l.from) + "&file=" + itoa(i)
506+ }
507+ out[i].Href = q + "#" + out[i].ID
508+ case f.Omitted == domain.OmitUnavailable && f.Status != domain.FileDeleted && l.rev != "":
509+ out[i].Raw = repoHref(l.repo, "raw", l.rev, f.NewPath)
510+ }
511+ }
512+}
513+
514+// fileParam reads ?file= of a page without scripts: the index of a collapsed
515+// file to read in full, in [0, n).
516+func fileParam(r *http.Request, n int) (int, bool) {
517+ s := r.URL.Query().Get("file")
518+ i, err := strconv.Atoi(s)
519+ if err != nil || i < 0 || i >= n || strconv.Itoa(i) != s {
520+ return 0, false
521+ }
522+ return i, true
523+}
524+
525+// fragmentView lays out a one-file fragment like the page that asks for it:
526+// ?view= is the layout shown and ?layouts= says whether the page holds both.
527+func fragmentView(r *http.Request, lines int) DiffView {
528+ v := newDiffView(r, lines, "", "")
529+ switch r.URL.Query().Get("layouts") {
530+ case "both":
531+ v.Both = true
532+ case "one":
533+ v.Both = false
534+ }
535+ v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
536+ return v
537+}
M internal/adapters/web/diff.templ +10 -1
@@ -233,12 +233,19 @@ templ DiffFileView(f DiffFile, p DiffView) {
233233 }
234234 </summary>
235235 if f.Lazy {
236- // A placeholder: mr.js fetches the window of the file when it comes
236+ // A placeholder: lazy.js fetches the window of the file when it comes
237237 // near the screen and swaps the files of the window in. Without
238238 // scripts the link opens the page of that window.
239239 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
240240 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
241241 </div>
242+ } else if f.Manual {
243+ // A collapsed file (generated, large, over the page budget): no
244+ // rows. "load diff" reads this one file and swaps it in place
245+ // (lazy.js); without scripts the link reloads the page with the file.
246+ <div class="dlazy" data-src={ f.Src } data-manual>
247+ <a href={ templ.SafeURL(f.Href) }>load diff</a>
248+ </div>
242249 } else if len(f.Hunks) > 0 {
243250 <div class="dscroll">
244251 if p.ShowUnified {
@@ -302,6 +309,8 @@ templ DiffFileView(f DiffFile, p DiffView) {
302309 </table>
303310 }
304311 </div>
312+ } else if f.Raw != "" {
313+ <p class="note pad">{ f.Note } <a href={ templ.SafeURL(f.Raw) }>view raw</a></p>
305314 } else {
306315 <p class="note pad">
307316 if f.Note != "" {
@@ -233,12 +233,19 @@ templ DiffFileView(f DiffFile, p DiffView) {
233 }233 }
234 </summary>234 </summary>
235 if f.Lazy {235 if f.Lazy {
236- // A placeholder: mr.js fetches the window of the file when it comes236+ // A placeholder: lazy.js fetches the window of the file when it comes
237 // near the screen and swaps the files of the window in. Without237 // near the screen and swaps the files of the window in. Without
238 // scripts the link opens the page of that window.238 // scripts the link opens the page of that window.
239 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>239 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
240 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>240 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
241 </div>241 </div>
242+ } else if f.Manual {
243+ // A collapsed file (generated, large, over the page budget): no
244+ // rows. "load diff" reads this one file and swaps it in place
245+ // (lazy.js); without scripts the link reloads the page with the file.
246+ <div class="dlazy" data-src={ f.Src } data-manual>
247+ <a href={ templ.SafeURL(f.Href) }>load diff</a>
248+ </div>
242 } else if len(f.Hunks) > 0 {249 } else if len(f.Hunks) > 0 {
243 <div class="dscroll">250 <div class="dscroll">
244 if p.ShowUnified {251 if p.ShowUnified {
@@ -302,6 +309,8 @@ templ DiffFileView(f DiffFile, p DiffView) {
302 </table>309 </table>
303 }310 }
304 </div>311 </div>
312+ } else if f.Raw != "" {
313+ <p class="note pad">{ f.Note } <a href={ templ.SafeURL(f.Raw) }>view raw</a></p>
305 } else {314 } else {
306 <p class="note pad">315 <p class="note pad">
307 if f.Note != "" {316 if f.Note != "" {
M internal/adapters/web/diff_templ.go +235 -173
@@ -1417,31 +1417,62 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
14171417 if templ_7745c5c3_Err != nil {
14181418 return templ_7745c5c3_Err
14191419 }
1420+ } else if f.Manual {
1421+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, " <div class=\"dlazy\" data-src=\"")
1422+ if templ_7745c5c3_Err != nil {
1423+ return templ_7745c5c3_Err
1424+ }
1425+ var templ_7745c5c3_Var75 string
1426+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Src)
1427+ if templ_7745c5c3_Err != nil {
1428+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 246, Col: 38}
1429+ }
1430+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1431+ if templ_7745c5c3_Err != nil {
1432+ return templ_7745c5c3_Err
1433+ }
1434+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\" data-manual><a href=\"")
1435+ if templ_7745c5c3_Err != nil {
1436+ return templ_7745c5c3_Err
1437+ }
1438+ var templ_7745c5c3_Var76 templ.SafeURL
1439+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Href))
1440+ if templ_7745c5c3_Err != nil {
1441+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 247, Col: 35}
1442+ }
1443+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1444+ if templ_7745c5c3_Err != nil {
1445+ return templ_7745c5c3_Err
1446+ }
1447+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\">load diff</a></div>")
1448+ if templ_7745c5c3_Err != nil {
1449+ return templ_7745c5c3_Err
1450+ }
14201451 } else if len(f.Hunks) > 0 {
1421- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<div class=\"dscroll\">")
1452+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<div class=\"dscroll\">")
14221453 if templ_7745c5c3_Err != nil {
14231454 return templ_7745c5c3_Err
14241455 }
14251456 if p.ShowUnified {
1426- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<table class=\"dt uni\" aria-label=\"")
1457+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<table class=\"dt uni\" aria-label=\"")
14271458 if templ_7745c5c3_Err != nil {
14281459 return templ_7745c5c3_Err
14291460 }
1430- var templ_7745c5c3_Var75 string
1431- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
1461+ var templ_7745c5c3_Var77 string
1462+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
14321463 if templ_7745c5c3_Err != nil {
1433- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 245, Col: 60}
1464+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 252, Col: 60}
14341465 }
1435- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1466+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
14361467 if templ_7745c5c3_Err != nil {
14371468 return templ_7745c5c3_Err
14381469 }
1439- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\">")
1470+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\">")
14401471 if templ_7745c5c3_Err != nil {
14411472 return templ_7745c5c3_Err
14421473 }
14431474 for _, h := range f.Hunks {
1444- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<tbody>")
1475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<tbody>")
14451476 if templ_7745c5c3_Err != nil {
14461477 return templ_7745c5c3_Err
14471478 }
@@ -1451,43 +1482,43 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
14511482 return templ_7745c5c3_Err
14521483 }
14531484 }
1454- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<tr class=\"hunk\"><td colspan=\"4\">")
1485+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tr class=\"hunk\"><td colspan=\"4\">")
14551486 if templ_7745c5c3_Err != nil {
14561487 return templ_7745c5c3_Err
14571488 }
1458- var templ_7745c5c3_Var76 string
1459- templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1489+ var templ_7745c5c3_Var78 string
1490+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
14601491 if templ_7745c5c3_Err != nil {
1461- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 251, Col: 51}
1492+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 258, Col: 51}
14621493 }
1463- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1494+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
14641495 if templ_7745c5c3_Err != nil {
14651496 return templ_7745c5c3_Err
14661497 }
1467- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</td></tr>")
1498+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td></tr>")
14681499 if templ_7745c5c3_Err != nil {
14691500 return templ_7745c5c3_Err
14701501 }
14711502 for _, ln := range h.Lines {
1472- var templ_7745c5c3_Var77 = []any{ln.Kind, templ.KV("sel", ln.Selected)}
1473- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var77...)
1503+ var templ_7745c5c3_Var79 = []any{ln.Kind, templ.KV("sel", ln.Selected)}
1504+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var79...)
14741505 if templ_7745c5c3_Err != nil {
14751506 return templ_7745c5c3_Err
14761507 }
1477- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<tr class=\"")
1508+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<tr class=\"")
14781509 if templ_7745c5c3_Err != nil {
14791510 return templ_7745c5c3_Err
14801511 }
1481- var templ_7745c5c3_Var78 string
1482- templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var77).String())
1512+ var templ_7745c5c3_Var80 string
1513+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var79).String())
14831514 if templ_7745c5c3_Err != nil {
14841515 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
14851516 }
1486- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)
1517+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
14871518 if templ_7745c5c3_Err != nil {
14881519 return templ_7745c5c3_Err
14891520 }
1490- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "\"><td class=\"ln\">")
1521+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\"><td class=\"ln\">")
14911522 if templ_7745c5c3_Err != nil {
14921523 return templ_7745c5c3_Err
14931524 }
@@ -1495,7 +1526,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
14951526 if templ_7745c5c3_Err != nil {
14961527 return templ_7745c5c3_Err
14971528 }
1498- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td><td class=\"ln\">")
1529+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td><td class=\"ln\">")
14991530 if templ_7745c5c3_Err != nil {
15001531 return templ_7745c5c3_Err
15011532 }
@@ -1503,20 +1534,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15031534 if templ_7745c5c3_Err != nil {
15041535 return templ_7745c5c3_Err
15051536 }
1506- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td><td class=\"m\">")
1537+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</td><td class=\"m\">")
15071538 if templ_7745c5c3_Err != nil {
15081539 return templ_7745c5c3_Err
15091540 }
1510- var templ_7745c5c3_Var79 string
1511- templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1541+ var templ_7745c5c3_Var81 string
1542+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
15121543 if templ_7745c5c3_Err != nil {
1513- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 256, Col: 33}
1544+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 263, Col: 33}
15141545 }
1515- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))
1546+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
15161547 if templ_7745c5c3_Err != nil {
15171548 return templ_7745c5c3_Err
15181549 }
1519- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</td><td class=\"tx chroma\">")
1550+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</td><td class=\"tx chroma\">")
15201551 if templ_7745c5c3_Err != nil {
15211552 return templ_7745c5c3_Err
15221553 }
@@ -1524,12 +1555,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15241555 if templ_7745c5c3_Err != nil {
15251556 return templ_7745c5c3_Err
15261557 }
1527- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td></tr>")
1558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</td></tr>")
15281559 if templ_7745c5c3_Err != nil {
15291560 return templ_7745c5c3_Err
15301561 }
15311562 if len(ln.Notes) > 0 || ln.Form != nil {
1532- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
1563+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
15331564 if templ_7745c5c3_Err != nil {
15341565 return templ_7745c5c3_Err
15351566 }
@@ -1537,19 +1568,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15371568 if templ_7745c5c3_Err != nil {
15381569 return templ_7745c5c3_Err
15391570 }
1540- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</td></tr>")
1571+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</td></tr>")
15411572 if templ_7745c5c3_Err != nil {
15421573 return templ_7745c5c3_Err
15431574 }
15441575 }
15451576 }
1546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</tbody> ")
1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</tbody> ")
15471578 if templ_7745c5c3_Err != nil {
15481579 return templ_7745c5c3_Err
15491580 }
15501581 }
15511582 if f.Tail != nil {
1552- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<tbody>")
1583+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<tbody>")
15531584 if templ_7745c5c3_Err != nil {
15541585 return templ_7745c5c3_Err
15551586 }
@@ -1557,36 +1588,36 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15571588 if templ_7745c5c3_Err != nil {
15581589 return templ_7745c5c3_Err
15591590 }
1560- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</tbody>")
1591+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "</tbody>")
15611592 if templ_7745c5c3_Err != nil {
15621593 return templ_7745c5c3_Err
15631594 }
15641595 }
1565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</table>")
1596+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "</table>")
15661597 if templ_7745c5c3_Err != nil {
15671598 return templ_7745c5c3_Err
15681599 }
15691600 }
15701601 if p.ShowSplit {
1571- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<table class=\"dt spl\" aria-label=\"")
1602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<table class=\"dt spl\" aria-label=\"")
15721603 if templ_7745c5c3_Err != nil {
15731604 return templ_7745c5c3_Err
15741605 }
1575- var templ_7745c5c3_Var80 string
1576- templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
1606+ var templ_7745c5c3_Var82 string
1607+ templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
15771608 if templ_7745c5c3_Err != nil {
1578- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 276, Col: 58}
1609+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 283, Col: 58}
15791610 }
1580- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1611+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
15811612 if templ_7745c5c3_Err != nil {
15821613 return templ_7745c5c3_Err
15831614 }
1584- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
1615+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
15851616 if templ_7745c5c3_Err != nil {
15861617 return templ_7745c5c3_Err
15871618 }
15881619 for _, h := range f.Hunks {
1589- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<tbody>")
1620+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<tbody>")
15901621 if templ_7745c5c3_Err != nil {
15911622 return templ_7745c5c3_Err
15921623 }
@@ -1596,25 +1627,25 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15961627 return templ_7745c5c3_Err
15971628 }
15981629 }
1599- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<tr class=\"hunk\"><td colspan=\"6\">")
1630+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<tr class=\"hunk\"><td colspan=\"6\">")
16001631 if templ_7745c5c3_Err != nil {
16011632 return templ_7745c5c3_Err
16021633 }
1603- var templ_7745c5c3_Var81 string
1604- templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1634+ var templ_7745c5c3_Var83 string
1635+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
16051636 if templ_7745c5c3_Err != nil {
1606- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 283, Col: 51}
1637+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 290, Col: 51}
16071638 }
1608- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
1639+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
16091640 if templ_7745c5c3_Err != nil {
16101641 return templ_7745c5c3_Err
16111642 }
1612- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "</td></tr>")
1643+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</td></tr>")
16131644 if templ_7745c5c3_Err != nil {
16141645 return templ_7745c5c3_Err
16151646 }
16161647 for _, row := range h.Split {
1617- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<tr>")
1648+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<tr>")
16181649 if templ_7745c5c3_Err != nil {
16191650 return templ_7745c5c3_Err
16201651 }
@@ -1626,12 +1657,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16261657 if templ_7745c5c3_Err != nil {
16271658 return templ_7745c5c3_Err
16281659 }
1629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</tr>")
1660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</tr>")
16301661 if templ_7745c5c3_Err != nil {
16311662 return templ_7745c5c3_Err
16321663 }
16331664 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
1634- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<tr class=\"fnote\"><td colspan=\"6\">")
1665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<tr class=\"fnote\"><td colspan=\"6\">")
16351666 if templ_7745c5c3_Err != nil {
16361667 return templ_7745c5c3_Err
16371668 }
@@ -1639,19 +1670,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16391670 if templ_7745c5c3_Err != nil {
16401671 return templ_7745c5c3_Err
16411672 }
1642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</td></tr>")
1673+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</td></tr>")
16431674 if templ_7745c5c3_Err != nil {
16441675 return templ_7745c5c3_Err
16451676 }
16461677 }
16471678 }
1648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</tbody> ")
1679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</tbody> ")
16491680 if templ_7745c5c3_Err != nil {
16501681 return templ_7745c5c3_Err
16511682 }
16521683 }
16531684 if f.Tail != nil {
1654- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<tbody>")
1685+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "<tbody>")
16551686 if templ_7745c5c3_Err != nil {
16561687 return templ_7745c5c3_Err
16571688 }
@@ -1659,48 +1690,79 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16591690 if templ_7745c5c3_Err != nil {
16601691 return templ_7745c5c3_Err
16611692 }
1662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</tbody>")
1693+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</tbody>")
16631694 if templ_7745c5c3_Err != nil {
16641695 return templ_7745c5c3_Err
16651696 }
16661697 }
1667- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</table>")
1698+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</table>")
16681699 if templ_7745c5c3_Err != nil {
16691700 return templ_7745c5c3_Err
16701701 }
16711702 }
1672- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</div>")
1703+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</div>")
1704+ if templ_7745c5c3_Err != nil {
1705+ return templ_7745c5c3_Err
1706+ }
1707+ } else if f.Raw != "" {
1708+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<p class=\"note pad\">")
1709+ if templ_7745c5c3_Err != nil {
1710+ return templ_7745c5c3_Err
1711+ }
1712+ var templ_7745c5c3_Var84 string
1713+ templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1714+ if templ_7745c5c3_Err != nil {
1715+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 313, Col: 31}
1716+ }
1717+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84))
1718+ if templ_7745c5c3_Err != nil {
1719+ return templ_7745c5c3_Err
1720+ }
1721+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, " <a href=\"")
1722+ if templ_7745c5c3_Err != nil {
1723+ return templ_7745c5c3_Err
1724+ }
1725+ var templ_7745c5c3_Var85 templ.SafeURL
1726+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Raw))
1727+ if templ_7745c5c3_Err != nil {
1728+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 313, Col: 64}
1729+ }
1730+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
1731+ if templ_7745c5c3_Err != nil {
1732+ return templ_7745c5c3_Err
1733+ }
1734+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "\">view raw</a></p>")
16731735 if templ_7745c5c3_Err != nil {
16741736 return templ_7745c5c3_Err
16751737 }
16761738 } else {
1677- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "<p class=\"note pad\">")
1739+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<p class=\"note pad\">")
16781740 if templ_7745c5c3_Err != nil {
16791741 return templ_7745c5c3_Err
16801742 }
16811743 if f.Note != "" {
1682- var templ_7745c5c3_Var82 string
1683- templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1744+ var templ_7745c5c3_Var86 string
1745+ templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
16841746 if templ_7745c5c3_Err != nil {
1685- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 308, Col: 13}
1747+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 317, Col: 13}
16861748 }
1687- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82))
1749+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
16881750 if templ_7745c5c3_Err != nil {
16891751 return templ_7745c5c3_Err
16901752 }
16911753 } else {
1692- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "no textual change.")
1754+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "no textual change.")
16931755 if templ_7745c5c3_Err != nil {
16941756 return templ_7745c5c3_Err
16951757 }
16961758 }
1697- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</p>")
1759+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "</p>")
16981760 if templ_7745c5c3_Err != nil {
16991761 return templ_7745c5c3_Err
17001762 }
17011763 }
17021764 if len(f.Loose) > 0 {
1703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<div class=\"dloose\">")
1765+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "<div class=\"dloose\">")
17041766 if templ_7745c5c3_Err != nil {
17051767 return templ_7745c5c3_Err
17061768 }
@@ -1710,12 +1772,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
17101772 return templ_7745c5c3_Err
17111773 }
17121774 }
1713- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</div>")
1775+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "</div>")
17141776 if templ_7745c5c3_Err != nil {
17151777 return templ_7745c5c3_Err
17161778 }
17171779 }
1718- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</details>")
1780+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</details>")
17191781 if templ_7745c5c3_Err != nil {
17201782 return templ_7745c5c3_Err
17211783 }
@@ -1741,9 +1803,9 @@ func MRFiles(p MRFilesPage) templ.Component {
17411803 }()
17421804 }
17431805 ctx = templ.InitializeContext(ctx)
1744- templ_7745c5c3_Var83 := templ.GetChildren(ctx)
1745- if templ_7745c5c3_Var83 == nil {
1746- templ_7745c5c3_Var83 = templ.NopComponent
1806+ templ_7745c5c3_Var87 := templ.GetChildren(ctx)
1807+ if templ_7745c5c3_Var87 == nil {
1808+ templ_7745c5c3_Var87 = templ.NopComponent
17471809 }
17481810 ctx = templ.ClearChildren(ctx)
17491811 for _, f := range p.Files {
@@ -1774,12 +1836,12 @@ func MRFilesMoved() templ.Component {
17741836 }()
17751837 }
17761838 ctx = templ.InitializeContext(ctx)
1777- templ_7745c5c3_Var84 := templ.GetChildren(ctx)
1778- if templ_7745c5c3_Var84 == nil {
1779- templ_7745c5c3_Var84 = templ.NopComponent
1839+ templ_7745c5c3_Var88 := templ.GetChildren(ctx)
1840+ if templ_7745c5c3_Var88 == nil {
1841+ templ_7745c5c3_Var88 = templ.NopComponent
17801842 }
17811843 ctx = templ.ClearChildren(ctx)
1782- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
1844+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
17831845 if templ_7745c5c3_Err != nil {
17841846 return templ_7745c5c3_Err
17851847 }
@@ -1806,64 +1868,64 @@ func gapRow(g DiffGap, cols int) templ.Component {
18061868 }()
18071869 }
18081870 ctx = templ.InitializeContext(ctx)
1809- templ_7745c5c3_Var85 := templ.GetChildren(ctx)
1810- if templ_7745c5c3_Var85 == nil {
1811- templ_7745c5c3_Var85 = templ.NopComponent
1871+ templ_7745c5c3_Var89 := templ.GetChildren(ctx)
1872+ if templ_7745c5c3_Var89 == nil {
1873+ templ_7745c5c3_Var89 = templ.NopComponent
18121874 }
18131875 ctx = templ.ClearChildren(ctx)
1814- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<tr class=\"gap\" data-js data-gap=\"")
1876+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<tr class=\"gap\" data-js data-gap=\"")
18151877 if templ_7745c5c3_Err != nil {
18161878 return templ_7745c5c3_Err
18171879 }
1818- var templ_7745c5c3_Var86 string
1819- templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)
1880+ var templ_7745c5c3_Var90 string
1881+ templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)
18201882 if templ_7745c5c3_Err != nil {
1821- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 342, Col: 41}
1883+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 351, Col: 41}
18221884 }
1823- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)
1885+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)
18241886 if templ_7745c5c3_Err != nil {
18251887 return templ_7745c5c3_Err
18261888 }
1827- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "\"><td colspan=\"")
1889+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "\"><td colspan=\"")
18281890 if templ_7745c5c3_Err != nil {
18291891 return templ_7745c5c3_Err
18301892 }
1831- var templ_7745c5c3_Var87 string
1832- templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))
1893+ var templ_7745c5c3_Var91 string
1894+ templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))
18331895 if templ_7745c5c3_Err != nil {
1834- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 343, Col: 26}
1896+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 352, Col: 26}
18351897 }
1836- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)
1898+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
18371899 if templ_7745c5c3_Err != nil {
18381900 return templ_7745c5c3_Err
18391901 }
1840- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"><button type=\"button\" data-src=\"")
1902+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "\"><button type=\"button\" data-src=\"")
18411903 if templ_7745c5c3_Err != nil {
18421904 return templ_7745c5c3_Err
18431905 }
1844- var templ_7745c5c3_Var88 string
1845- templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)
1906+ var templ_7745c5c3_Var92 string
1907+ templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)
18461908 if templ_7745c5c3_Err != nil {
1847- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 344, Col: 41}
1909+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 353, Col: 41}
18481910 }
1849- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)
1911+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
18501912 if templ_7745c5c3_Err != nil {
18511913 return templ_7745c5c3_Err
18521914 }
1853- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"><span aria-hidden=\"true\">⋯</span> ")
1915+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "\"><span aria-hidden=\"true\">⋯</span> ")
18541916 if templ_7745c5c3_Err != nil {
18551917 return templ_7745c5c3_Err
18561918 }
1857- var templ_7745c5c3_Var89 string
1858- templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())
1919+ var templ_7745c5c3_Var93 string
1920+ templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())
18591921 if templ_7745c5c3_Err != nil {
1860- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 345, Col: 51}
1922+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 354, Col: 51}
18611923 }
1862- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
1924+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
18631925 if templ_7745c5c3_Err != nil {
18641926 return templ_7745c5c3_Err
18651927 }
1866- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</button></td></tr>")
1928+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</button></td></tr>")
18671929 if templ_7745c5c3_Err != nil {
18681930 return templ_7745c5c3_Err
18691931 }
@@ -1889,56 +1951,56 @@ func DiffContext(c ContextRows) templ.Component {
18891951 }()
18901952 }
18911953 ctx = templ.InitializeContext(ctx)
1892- templ_7745c5c3_Var90 := templ.GetChildren(ctx)
1893- if templ_7745c5c3_Var90 == nil {
1894- templ_7745c5c3_Var90 = templ.NopComponent
1954+ templ_7745c5c3_Var94 := templ.GetChildren(ctx)
1955+ if templ_7745c5c3_Var94 == nil {
1956+ templ_7745c5c3_Var94 = templ.NopComponent
18951957 }
18961958 ctx = templ.ClearChildren(ctx)
1897- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<table><tbody data-for=\"uni\">")
1959+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "<table><tbody data-for=\"uni\">")
18981960 if templ_7745c5c3_Err != nil {
18991961 return templ_7745c5c3_Err
19001962 }
19011963 for _, ln := range c.Lines {
1902- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<tr class=\"ctx\"><td class=\"ln\">")
1964+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<tr class=\"ctx\"><td class=\"ln\">")
19031965 if templ_7745c5c3_Err != nil {
19041966 return templ_7745c5c3_Err
19051967 }
1906- var templ_7745c5c3_Var91 string
1907- templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
1968+ var templ_7745c5c3_Var95 string
1969+ templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
19081970 if templ_7745c5c3_Err != nil {
1909- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 358, Col: 28}
1971+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 367, Col: 28}
19101972 }
1911- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
1973+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
19121974 if templ_7745c5c3_Err != nil {
19131975 return templ_7745c5c3_Err
19141976 }
1915- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "</td><td class=\"ln\">")
1977+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</td><td class=\"ln\">")
19161978 if templ_7745c5c3_Err != nil {
19171979 return templ_7745c5c3_Err
19181980 }
1919- var templ_7745c5c3_Var92 string
1920- templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
1981+ var templ_7745c5c3_Var96 string
1982+ templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
19211983 if templ_7745c5c3_Err != nil {
1922- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 359, Col: 28}
1984+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 368, Col: 28}
19231985 }
1924- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
1986+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
19251987 if templ_7745c5c3_Err != nil {
19261988 return templ_7745c5c3_Err
19271989 }
1928- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "</td><td class=\"m\">")
1990+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</td><td class=\"m\">")
19291991 if templ_7745c5c3_Err != nil {
19301992 return templ_7745c5c3_Err
19311993 }
1932- var templ_7745c5c3_Var93 string
1933- templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1994+ var templ_7745c5c3_Var97 string
1995+ templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
19341996 if templ_7745c5c3_Err != nil {
1935- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 360, Col: 28}
1997+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 369, Col: 28}
19361998 }
1937- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
1999+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
19382000 if templ_7745c5c3_Err != nil {
19392001 return templ_7745c5c3_Err
19402002 }
1941- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</td><td class=\"tx chroma\">")
2003+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</td><td class=\"tx chroma\">")
19422004 if templ_7745c5c3_Err != nil {
19432005 return templ_7745c5c3_Err
19442006 }
@@ -1946,7 +2008,7 @@ func DiffContext(c ContextRows) templ.Component {
19462008 if templ_7745c5c3_Err != nil {
19472009 return templ_7745c5c3_Err
19482010 }
1949- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</td></tr>")
2011+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "</td></tr>")
19502012 if templ_7745c5c3_Err != nil {
19512013 return templ_7745c5c3_Err
19522014 }
@@ -1957,12 +2019,12 @@ func DiffContext(c ContextRows) templ.Component {
19572019 return templ_7745c5c3_Err
19582020 }
19592021 }
1960- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</tbody> <tbody data-for=\"spl\">")
2022+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</tbody> <tbody data-for=\"spl\">")
19612023 if templ_7745c5c3_Err != nil {
19622024 return templ_7745c5c3_Err
19632025 }
19642026 for i := range c.Lines {
1965- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<tr>")
2027+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<tr>")
19662028 if templ_7745c5c3_Err != nil {
19672029 return templ_7745c5c3_Err
19682030 }
@@ -1974,7 +2036,7 @@ func DiffContext(c ContextRows) templ.Component {
19742036 if templ_7745c5c3_Err != nil {
19752037 return templ_7745c5c3_Err
19762038 }
1977- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</tr>")
2039+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</tr>")
19782040 if templ_7745c5c3_Err != nil {
19792041 return templ_7745c5c3_Err
19802042 }
@@ -1985,7 +2047,7 @@ func DiffContext(c ContextRows) templ.Component {
19852047 return templ_7745c5c3_Err
19862048 }
19872049 }
1988- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</tbody></table>")
2050+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</tbody></table>")
19892051 if templ_7745c5c3_Err != nil {
19902052 return templ_7745c5c3_Err
19912053 }
@@ -2011,36 +2073,36 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20112073 }()
20122074 }
20132075 ctx = templ.InitializeContext(ctx)
2014- templ_7745c5c3_Var94 := templ.GetChildren(ctx)
2015- if templ_7745c5c3_Var94 == nil {
2016- templ_7745c5c3_Var94 = templ.NopComponent
2076+ templ_7745c5c3_Var98 := templ.GetChildren(ctx)
2077+ if templ_7745c5c3_Var98 == nil {
2078+ templ_7745c5c3_Var98 = templ.NopComponent
20172079 }
20182080 ctx = templ.ClearChildren(ctx)
20192081 if ln == nil {
2020- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
2082+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
20212083 if templ_7745c5c3_Err != nil {
20222084 return templ_7745c5c3_Err
20232085 }
20242086 } else {
2025- var templ_7745c5c3_Var95 = []any{"ln", ln.Kind, templ.KV("sel", ln.Selected)}
2026- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var95...)
2087+ var templ_7745c5c3_Var99 = []any{"ln", ln.Kind, templ.KV("sel", ln.Selected)}
2088+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var99...)
20272089 if templ_7745c5c3_Err != nil {
20282090 return templ_7745c5c3_Err
20292091 }
2030- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<td class=\"")
2092+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<td class=\"")
20312093 if templ_7745c5c3_Err != nil {
20322094 return templ_7745c5c3_Err
20332095 }
2034- var templ_7745c5c3_Var96 string
2035- templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var95).String())
2096+ var templ_7745c5c3_Var100 string
2097+ templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var99).String())
20362098 if templ_7745c5c3_Err != nil {
20372099 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
20382100 }
2039- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var96)
2101+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var100)
20402102 if templ_7745c5c3_Err != nil {
20412103 return templ_7745c5c3_Err
20422104 }
2043- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "\">")
2105+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\">")
20442106 if templ_7745c5c3_Err != nil {
20452107 return templ_7745c5c3_Err
20462108 }
@@ -2055,64 +2117,64 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20552117 return templ_7745c5c3_Err
20562118 }
20572119 }
2058- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "</td>")
2120+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</td>")
20592121 if templ_7745c5c3_Err != nil {
20602122 return templ_7745c5c3_Err
20612123 }
2062- var templ_7745c5c3_Var97 = []any{"m", ln.Kind, templ.KV("sel", ln.Selected)}
2063- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var97...)
2124+ var templ_7745c5c3_Var101 = []any{"m", ln.Kind, templ.KV("sel", ln.Selected)}
2125+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var101...)
20642126 if templ_7745c5c3_Err != nil {
20652127 return templ_7745c5c3_Err
20662128 }
2067- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<td class=\"")
2129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<td class=\"")
20682130 if templ_7745c5c3_Err != nil {
20692131 return templ_7745c5c3_Err
20702132 }
2071- var templ_7745c5c3_Var98 string
2072- templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var97).String())
2133+ var templ_7745c5c3_Var102 string
2134+ templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var101).String())
20732135 if templ_7745c5c3_Err != nil {
20742136 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
20752137 }
2076- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var98)
2138+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var102)
20772139 if templ_7745c5c3_Err != nil {
20782140 return templ_7745c5c3_Err
20792141 }
2080- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "\">")
2142+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\">")
20812143 if templ_7745c5c3_Err != nil {
20822144 return templ_7745c5c3_Err
20832145 }
2084- var templ_7745c5c3_Var99 string
2085- templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
2146+ var templ_7745c5c3_Var103 string
2147+ templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
20862148 if templ_7745c5c3_Err != nil {
2087- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 397, Col: 68}
2149+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 68}
20882150 }
2089- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
2151+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
20902152 if templ_7745c5c3_Err != nil {
20912153 return templ_7745c5c3_Err
20922154 }
2093- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</td>")
2155+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</td>")
20942156 if templ_7745c5c3_Err != nil {
20952157 return templ_7745c5c3_Err
20962158 }
2097- var templ_7745c5c3_Var100 = []any{"tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected)}
2098- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var100...)
2159+ var templ_7745c5c3_Var104 = []any{"tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected)}
2160+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var104...)
20992161 if templ_7745c5c3_Err != nil {
21002162 return templ_7745c5c3_Err
21012163 }
2102- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<td class=\"")
2164+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "<td class=\"")
21032165 if templ_7745c5c3_Err != nil {
21042166 return templ_7745c5c3_Err
21052167 }
2106- var templ_7745c5c3_Var101 string
2107- templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var100).String())
2168+ var templ_7745c5c3_Var105 string
2169+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var104).String())
21082170 if templ_7745c5c3_Err != nil {
21092171 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
21102172 }
2111- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var101)
2173+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var105)
21122174 if templ_7745c5c3_Err != nil {
21132175 return templ_7745c5c3_Err
21142176 }
2115- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\">")
2177+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "\">")
21162178 if templ_7745c5c3_Err != nil {
21172179 return templ_7745c5c3_Err
21182180 }
@@ -2120,7 +2182,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
21202182 if templ_7745c5c3_Err != nil {
21212183 return templ_7745c5c3_Err
21222184 }
2123- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</td>")
2185+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "</td>")
21242186 if templ_7745c5c3_Err != nil {
21252187 return templ_7745c5c3_Err
21262188 }
@@ -2147,49 +2209,49 @@ func gutter(n, href string) templ.Component {
21472209 }()
21482210 }
21492211 ctx = templ.InitializeContext(ctx)
2150- templ_7745c5c3_Var102 := templ.GetChildren(ctx)
2151- if templ_7745c5c3_Var102 == nil {
2152- templ_7745c5c3_Var102 = templ.NopComponent
2212+ templ_7745c5c3_Var106 := templ.GetChildren(ctx)
2213+ if templ_7745c5c3_Var106 == nil {
2214+ templ_7745c5c3_Var106 = templ.NopComponent
21532215 }
21542216 ctx = templ.ClearChildren(ctx)
21552217 if href != "" {
2156- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<a href=\"")
2218+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "<a href=\"")
21572219 if templ_7745c5c3_Err != nil {
21582220 return templ_7745c5c3_Err
21592221 }
2160- var templ_7745c5c3_Var103 templ.SafeURL
2161- templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
2222+ var templ_7745c5c3_Var107 templ.SafeURL
2223+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
21622224 if templ_7745c5c3_Err != nil {
2163- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 31}
2225+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 415, Col: 31}
21642226 }
2165- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
2227+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
21662228 if templ_7745c5c3_Err != nil {
21672229 return templ_7745c5c3_Err
21682230 }
2169- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\" title=\"comment on this line\">")
2231+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "\" title=\"comment on this line\">")
21702232 if templ_7745c5c3_Err != nil {
21712233 return templ_7745c5c3_Err
21722234 }
2173- var templ_7745c5c3_Var104 string
2174- templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2235+ var templ_7745c5c3_Var108 string
2236+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(n)
21752237 if templ_7745c5c3_Err != nil {
2176- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 66}
2238+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 415, Col: 66}
21772239 }
2178- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
2240+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
21792241 if templ_7745c5c3_Err != nil {
21802242 return templ_7745c5c3_Err
21812243 }
2182- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</a>")
2244+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "</a>")
21832245 if templ_7745c5c3_Err != nil {
21842246 return templ_7745c5c3_Err
21852247 }
21862248 } else {
2187- var templ_7745c5c3_Var105 string
2188- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2249+ var templ_7745c5c3_Var109 string
2250+ templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(n)
21892251 if templ_7745c5c3_Err != nil {
2190- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 408, Col: 5}
2252+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 417, Col: 5}
21912253 }
2192- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
2254+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
21932255 if templ_7745c5c3_Err != nil {
21942256 return templ_7745c5c3_Err
21952257 }
@@ -1417,31 +1417,62 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1417 if templ_7745c5c3_Err != nil {1417 if templ_7745c5c3_Err != nil {
1418 return templ_7745c5c3_Err1418 return templ_7745c5c3_Err
1419 }1419 }
1420+ } else if f.Manual {
1421+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, " <div class=\"dlazy\" data-src=\"")
1422+ if templ_7745c5c3_Err != nil {
1423+ return templ_7745c5c3_Err
1424+ }
1425+ var templ_7745c5c3_Var75 string
1426+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Src)
1427+ if templ_7745c5c3_Err != nil {
1428+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 246, Col: 38}
1429+ }
1430+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1431+ if templ_7745c5c3_Err != nil {
1432+ return templ_7745c5c3_Err
1433+ }
1434+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\" data-manual><a href=\"")
1435+ if templ_7745c5c3_Err != nil {
1436+ return templ_7745c5c3_Err
1437+ }
1438+ var templ_7745c5c3_Var76 templ.SafeURL
1439+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Href))
1440+ if templ_7745c5c3_Err != nil {
1441+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 247, Col: 35}
1442+ }
1443+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1444+ if templ_7745c5c3_Err != nil {
1445+ return templ_7745c5c3_Err
1446+ }
1447+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\">load diff</a></div>")
1448+ if templ_7745c5c3_Err != nil {
1449+ return templ_7745c5c3_Err
1450+ }
1420 } else if len(f.Hunks) > 0 {1451 } else if len(f.Hunks) > 0 {
1421- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<div class=\"dscroll\">")1452+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<div class=\"dscroll\">")
1422 if templ_7745c5c3_Err != nil {1453 if templ_7745c5c3_Err != nil {
1423 return templ_7745c5c3_Err1454 return templ_7745c5c3_Err
1424 }1455 }
1425 if p.ShowUnified {1456 if p.ShowUnified {
1426- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<table class=\"dt uni\" aria-label=\"")1457+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<table class=\"dt uni\" aria-label=\"")
1427 if templ_7745c5c3_Err != nil {1458 if templ_7745c5c3_Err != nil {
1428 return templ_7745c5c3_Err1459 return templ_7745c5c3_Err
1429 }1460 }
1430- var templ_7745c5c3_Var75 string1461+ var templ_7745c5c3_Var77 string
1431- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")1462+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
1432 if templ_7745c5c3_Err != nil {1463 if templ_7745c5c3_Err != nil {
1433- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 245, Col: 60}1464+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 252, Col: 60}
1434 }1465 }
1435- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)1466+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
1436 if templ_7745c5c3_Err != nil {1467 if templ_7745c5c3_Err != nil {
1437 return templ_7745c5c3_Err1468 return templ_7745c5c3_Err
1438 }1469 }
1439- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\">")1470+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\">")
1440 if templ_7745c5c3_Err != nil {1471 if templ_7745c5c3_Err != nil {
1441 return templ_7745c5c3_Err1472 return templ_7745c5c3_Err
1442 }1473 }
1443 for _, h := range f.Hunks {1474 for _, h := range f.Hunks {
1444- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<tbody>")1475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<tbody>")
1445 if templ_7745c5c3_Err != nil {1476 if templ_7745c5c3_Err != nil {
1446 return templ_7745c5c3_Err1477 return templ_7745c5c3_Err
1447 }1478 }
@@ -1451,43 +1482,43 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1451 return templ_7745c5c3_Err1482 return templ_7745c5c3_Err
1452 }1483 }
1453 }1484 }
1454- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<tr class=\"hunk\"><td colspan=\"4\">")1485+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tr class=\"hunk\"><td colspan=\"4\">")
1455 if templ_7745c5c3_Err != nil {1486 if templ_7745c5c3_Err != nil {
1456 return templ_7745c5c3_Err1487 return templ_7745c5c3_Err
1457 }1488 }
1458- var templ_7745c5c3_Var76 string1489+ var templ_7745c5c3_Var78 string
1459- templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)1490+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1460 if templ_7745c5c3_Err != nil {1491 if templ_7745c5c3_Err != nil {
1461- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 251, Col: 51}1492+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 258, Col: 51}
1462 }1493 }
1463- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))1494+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
1464 if templ_7745c5c3_Err != nil {1495 if templ_7745c5c3_Err != nil {
1465 return templ_7745c5c3_Err1496 return templ_7745c5c3_Err
1466 }1497 }
1467- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</td></tr>")1498+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td></tr>")
1468 if templ_7745c5c3_Err != nil {1499 if templ_7745c5c3_Err != nil {
1469 return templ_7745c5c3_Err1500 return templ_7745c5c3_Err
1470 }1501 }
1471 for _, ln := range h.Lines {1502 for _, ln := range h.Lines {
1472- var templ_7745c5c3_Var77 = []any{ln.Kind, templ.KV("sel", ln.Selected)}1503+ var templ_7745c5c3_Var79 = []any{ln.Kind, templ.KV("sel", ln.Selected)}
1473- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var77...)1504+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var79...)
1474 if templ_7745c5c3_Err != nil {1505 if templ_7745c5c3_Err != nil {
1475 return templ_7745c5c3_Err1506 return templ_7745c5c3_Err
1476 }1507 }
1477- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<tr class=\"")1508+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<tr class=\"")
1478 if templ_7745c5c3_Err != nil {1509 if templ_7745c5c3_Err != nil {
1479 return templ_7745c5c3_Err1510 return templ_7745c5c3_Err
1480 }1511 }
1481- var templ_7745c5c3_Var78 string1512+ var templ_7745c5c3_Var80 string
1482- templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var77).String())1513+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var79).String())
1483 if templ_7745c5c3_Err != nil {1514 if templ_7745c5c3_Err != nil {
1484 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}1515 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
1485 }1516 }
1486- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)1517+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1487 if templ_7745c5c3_Err != nil {1518 if templ_7745c5c3_Err != nil {
1488 return templ_7745c5c3_Err1519 return templ_7745c5c3_Err
1489 }1520 }
1490- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "\"><td class=\"ln\">")1521+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\"><td class=\"ln\">")
1491 if templ_7745c5c3_Err != nil {1522 if templ_7745c5c3_Err != nil {
1492 return templ_7745c5c3_Err1523 return templ_7745c5c3_Err
1493 }1524 }
@@ -1495,7 +1526,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1495 if templ_7745c5c3_Err != nil {1526 if templ_7745c5c3_Err != nil {
1496 return templ_7745c5c3_Err1527 return templ_7745c5c3_Err
1497 }1528 }
1498- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td><td class=\"ln\">")1529+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td><td class=\"ln\">")
1499 if templ_7745c5c3_Err != nil {1530 if templ_7745c5c3_Err != nil {
1500 return templ_7745c5c3_Err1531 return templ_7745c5c3_Err
1501 }1532 }
@@ -1503,20 +1534,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1503 if templ_7745c5c3_Err != nil {1534 if templ_7745c5c3_Err != nil {
1504 return templ_7745c5c3_Err1535 return templ_7745c5c3_Err
1505 }1536 }
1506- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td><td class=\"m\">")1537+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</td><td class=\"m\">")
1507 if templ_7745c5c3_Err != nil {1538 if templ_7745c5c3_Err != nil {
1508 return templ_7745c5c3_Err1539 return templ_7745c5c3_Err
1509 }1540 }
1510- var templ_7745c5c3_Var79 string1541+ var templ_7745c5c3_Var81 string
1511- templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)1542+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1512 if templ_7745c5c3_Err != nil {1543 if templ_7745c5c3_Err != nil {
1513- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 256, Col: 33}1544+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 263, Col: 33}
1514 }1545 }
1515- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))1546+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
1516 if templ_7745c5c3_Err != nil {1547 if templ_7745c5c3_Err != nil {
1517 return templ_7745c5c3_Err1548 return templ_7745c5c3_Err
1518 }1549 }
1519- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</td><td class=\"tx chroma\">")1550+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</td><td class=\"tx chroma\">")
1520 if templ_7745c5c3_Err != nil {1551 if templ_7745c5c3_Err != nil {
1521 return templ_7745c5c3_Err1552 return templ_7745c5c3_Err
1522 }1553 }
@@ -1524,12 +1555,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1524 if templ_7745c5c3_Err != nil {1555 if templ_7745c5c3_Err != nil {
1525 return templ_7745c5c3_Err1556 return templ_7745c5c3_Err
1526 }1557 }
1527- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td></tr>")1558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</td></tr>")
1528 if templ_7745c5c3_Err != nil {1559 if templ_7745c5c3_Err != nil {
1529 return templ_7745c5c3_Err1560 return templ_7745c5c3_Err
1530 }1561 }
1531 if len(ln.Notes) > 0 || ln.Form != nil {1562 if len(ln.Notes) > 0 || ln.Form != nil {
1532- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")1563+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
1533 if templ_7745c5c3_Err != nil {1564 if templ_7745c5c3_Err != nil {
1534 return templ_7745c5c3_Err1565 return templ_7745c5c3_Err
1535 }1566 }
@@ -1537,19 +1568,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1537 if templ_7745c5c3_Err != nil {1568 if templ_7745c5c3_Err != nil {
1538 return templ_7745c5c3_Err1569 return templ_7745c5c3_Err
1539 }1570 }
1540- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</td></tr>")1571+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</td></tr>")
1541 if templ_7745c5c3_Err != nil {1572 if templ_7745c5c3_Err != nil {
1542 return templ_7745c5c3_Err1573 return templ_7745c5c3_Err
1543 }1574 }
1544 }1575 }
1545 }1576 }
1546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</tbody> ")1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</tbody> ")
1547 if templ_7745c5c3_Err != nil {1578 if templ_7745c5c3_Err != nil {
1548 return templ_7745c5c3_Err1579 return templ_7745c5c3_Err
1549 }1580 }
1550 }1581 }
1551 if f.Tail != nil {1582 if f.Tail != nil {
1552- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<tbody>")1583+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<tbody>")
1553 if templ_7745c5c3_Err != nil {1584 if templ_7745c5c3_Err != nil {
1554 return templ_7745c5c3_Err1585 return templ_7745c5c3_Err
1555 }1586 }
@@ -1557,36 +1588,36 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1557 if templ_7745c5c3_Err != nil {1588 if templ_7745c5c3_Err != nil {
1558 return templ_7745c5c3_Err1589 return templ_7745c5c3_Err
1559 }1590 }
1560- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</tbody>")1591+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "</tbody>")
1561 if templ_7745c5c3_Err != nil {1592 if templ_7745c5c3_Err != nil {
1562 return templ_7745c5c3_Err1593 return templ_7745c5c3_Err
1563 }1594 }
1564 }1595 }
1565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</table>")1596+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "</table>")
1566 if templ_7745c5c3_Err != nil {1597 if templ_7745c5c3_Err != nil {
1567 return templ_7745c5c3_Err1598 return templ_7745c5c3_Err
1568 }1599 }
1569 }1600 }
1570 if p.ShowSplit {1601 if p.ShowSplit {
1571- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<table class=\"dt spl\" aria-label=\"")1602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<table class=\"dt spl\" aria-label=\"")
1572 if templ_7745c5c3_Err != nil {1603 if templ_7745c5c3_Err != nil {
1573 return templ_7745c5c3_Err1604 return templ_7745c5c3_Err
1574 }1605 }
1575- var templ_7745c5c3_Var80 string1606+ var templ_7745c5c3_Var82 string
1576- templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")1607+ templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
1577 if templ_7745c5c3_Err != nil {1608 if templ_7745c5c3_Err != nil {
1578- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 276, Col: 58}1609+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 283, Col: 58}
1579 }1610 }
1580- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)1611+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
1581 if templ_7745c5c3_Err != nil {1612 if templ_7745c5c3_Err != nil {
1582 return templ_7745c5c3_Err1613 return templ_7745c5c3_Err
1583 }1614 }
1584- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")1615+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
1585 if templ_7745c5c3_Err != nil {1616 if templ_7745c5c3_Err != nil {
1586 return templ_7745c5c3_Err1617 return templ_7745c5c3_Err
1587 }1618 }
1588 for _, h := range f.Hunks {1619 for _, h := range f.Hunks {
1589- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<tbody>")1620+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<tbody>")
1590 if templ_7745c5c3_Err != nil {1621 if templ_7745c5c3_Err != nil {
1591 return templ_7745c5c3_Err1622 return templ_7745c5c3_Err
1592 }1623 }
@@ -1596,25 +1627,25 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1596 return templ_7745c5c3_Err1627 return templ_7745c5c3_Err
1597 }1628 }
1598 }1629 }
1599- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<tr class=\"hunk\"><td colspan=\"6\">")1630+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<tr class=\"hunk\"><td colspan=\"6\">")
1600 if templ_7745c5c3_Err != nil {1631 if templ_7745c5c3_Err != nil {
1601 return templ_7745c5c3_Err1632 return templ_7745c5c3_Err
1602 }1633 }
1603- var templ_7745c5c3_Var81 string1634+ var templ_7745c5c3_Var83 string
1604- templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)1635+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1605 if templ_7745c5c3_Err != nil {1636 if templ_7745c5c3_Err != nil {
1606- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 283, Col: 51}1637+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 290, Col: 51}
1607 }1638 }
1608- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))1639+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
1609 if templ_7745c5c3_Err != nil {1640 if templ_7745c5c3_Err != nil {
1610 return templ_7745c5c3_Err1641 return templ_7745c5c3_Err
1611 }1642 }
1612- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "</td></tr>")1643+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</td></tr>")
1613 if templ_7745c5c3_Err != nil {1644 if templ_7745c5c3_Err != nil {
1614 return templ_7745c5c3_Err1645 return templ_7745c5c3_Err
1615 }1646 }
1616 for _, row := range h.Split {1647 for _, row := range h.Split {
1617- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<tr>")1648+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<tr>")
1618 if templ_7745c5c3_Err != nil {1649 if templ_7745c5c3_Err != nil {
1619 return templ_7745c5c3_Err1650 return templ_7745c5c3_Err
1620 }1651 }
@@ -1626,12 +1657,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1626 if templ_7745c5c3_Err != nil {1657 if templ_7745c5c3_Err != nil {
1627 return templ_7745c5c3_Err1658 return templ_7745c5c3_Err
1628 }1659 }
1629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</tr>")1660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</tr>")
1630 if templ_7745c5c3_Err != nil {1661 if templ_7745c5c3_Err != nil {
1631 return templ_7745c5c3_Err1662 return templ_7745c5c3_Err
1632 }1663 }
1633 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {1664 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
1634- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<tr class=\"fnote\"><td colspan=\"6\">")1665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<tr class=\"fnote\"><td colspan=\"6\">")
1635 if templ_7745c5c3_Err != nil {1666 if templ_7745c5c3_Err != nil {
1636 return templ_7745c5c3_Err1667 return templ_7745c5c3_Err
1637 }1668 }
@@ -1639,19 +1670,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1639 if templ_7745c5c3_Err != nil {1670 if templ_7745c5c3_Err != nil {
1640 return templ_7745c5c3_Err1671 return templ_7745c5c3_Err
1641 }1672 }
1642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</td></tr>")1673+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</td></tr>")
1643 if templ_7745c5c3_Err != nil {1674 if templ_7745c5c3_Err != nil {
1644 return templ_7745c5c3_Err1675 return templ_7745c5c3_Err
1645 }1676 }
1646 }1677 }
1647 }1678 }
1648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</tbody> ")1679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</tbody> ")
1649 if templ_7745c5c3_Err != nil {1680 if templ_7745c5c3_Err != nil {
1650 return templ_7745c5c3_Err1681 return templ_7745c5c3_Err
1651 }1682 }
1652 }1683 }
1653 if f.Tail != nil {1684 if f.Tail != nil {
1654- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<tbody>")1685+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "<tbody>")
1655 if templ_7745c5c3_Err != nil {1686 if templ_7745c5c3_Err != nil {
1656 return templ_7745c5c3_Err1687 return templ_7745c5c3_Err
1657 }1688 }
@@ -1659,48 +1690,79 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1659 if templ_7745c5c3_Err != nil {1690 if templ_7745c5c3_Err != nil {
1660 return templ_7745c5c3_Err1691 return templ_7745c5c3_Err
1661 }1692 }
1662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</tbody>")1693+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</tbody>")
1663 if templ_7745c5c3_Err != nil {1694 if templ_7745c5c3_Err != nil {
1664 return templ_7745c5c3_Err1695 return templ_7745c5c3_Err
1665 }1696 }
1666 }1697 }
1667- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</table>")1698+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</table>")
1668 if templ_7745c5c3_Err != nil {1699 if templ_7745c5c3_Err != nil {
1669 return templ_7745c5c3_Err1700 return templ_7745c5c3_Err
1670 }1701 }
1671 }1702 }
1672- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</div>")1703+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</div>")
1704+ if templ_7745c5c3_Err != nil {
1705+ return templ_7745c5c3_Err
1706+ }
1707+ } else if f.Raw != "" {
1708+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<p class=\"note pad\">")
1709+ if templ_7745c5c3_Err != nil {
1710+ return templ_7745c5c3_Err
1711+ }
1712+ var templ_7745c5c3_Var84 string
1713+ templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1714+ if templ_7745c5c3_Err != nil {
1715+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 313, Col: 31}
1716+ }
1717+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84))
1718+ if templ_7745c5c3_Err != nil {
1719+ return templ_7745c5c3_Err
1720+ }
1721+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, " <a href=\"")
1722+ if templ_7745c5c3_Err != nil {
1723+ return templ_7745c5c3_Err
1724+ }
1725+ var templ_7745c5c3_Var85 templ.SafeURL
1726+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Raw))
1727+ if templ_7745c5c3_Err != nil {
1728+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 313, Col: 64}
1729+ }
1730+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
1731+ if templ_7745c5c3_Err != nil {
1732+ return templ_7745c5c3_Err
1733+ }
1734+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "\">view raw</a></p>")
1673 if templ_7745c5c3_Err != nil {1735 if templ_7745c5c3_Err != nil {
1674 return templ_7745c5c3_Err1736 return templ_7745c5c3_Err
1675 }1737 }
1676 } else {1738 } else {
1677- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "<p class=\"note pad\">")1739+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<p class=\"note pad\">")
1678 if templ_7745c5c3_Err != nil {1740 if templ_7745c5c3_Err != nil {
1679 return templ_7745c5c3_Err1741 return templ_7745c5c3_Err
1680 }1742 }
1681 if f.Note != "" {1743 if f.Note != "" {
1682- var templ_7745c5c3_Var82 string1744+ var templ_7745c5c3_Var86 string
1683- templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)1745+ templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1684 if templ_7745c5c3_Err != nil {1746 if templ_7745c5c3_Err != nil {
1685- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 308, Col: 13}1747+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 317, Col: 13}
1686 }1748 }
1687- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82))1749+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
1688 if templ_7745c5c3_Err != nil {1750 if templ_7745c5c3_Err != nil {
1689 return templ_7745c5c3_Err1751 return templ_7745c5c3_Err
1690 }1752 }
1691 } else {1753 } else {
1692- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "no textual change.")1754+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "no textual change.")
1693 if templ_7745c5c3_Err != nil {1755 if templ_7745c5c3_Err != nil {
1694 return templ_7745c5c3_Err1756 return templ_7745c5c3_Err
1695 }1757 }
1696 }1758 }
1697- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</p>")1759+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "</p>")
1698 if templ_7745c5c3_Err != nil {1760 if templ_7745c5c3_Err != nil {
1699 return templ_7745c5c3_Err1761 return templ_7745c5c3_Err
1700 }1762 }
1701 }1763 }
1702 if len(f.Loose) > 0 {1764 if len(f.Loose) > 0 {
1703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<div class=\"dloose\">")1765+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "<div class=\"dloose\">")
1704 if templ_7745c5c3_Err != nil {1766 if templ_7745c5c3_Err != nil {
1705 return templ_7745c5c3_Err1767 return templ_7745c5c3_Err
1706 }1768 }
@@ -1710,12 +1772,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1710 return templ_7745c5c3_Err1772 return templ_7745c5c3_Err
1711 }1773 }
1712 }1774 }
1713- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</div>")1775+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "</div>")
1714 if templ_7745c5c3_Err != nil {1776 if templ_7745c5c3_Err != nil {
1715 return templ_7745c5c3_Err1777 return templ_7745c5c3_Err
1716 }1778 }
1717 }1779 }
1718- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</details>")1780+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</details>")
1719 if templ_7745c5c3_Err != nil {1781 if templ_7745c5c3_Err != nil {
1720 return templ_7745c5c3_Err1782 return templ_7745c5c3_Err
1721 }1783 }
@@ -1741,9 +1803,9 @@ func MRFiles(p MRFilesPage) templ.Component {
1741 }()1803 }()
1742 }1804 }
1743 ctx = templ.InitializeContext(ctx)1805 ctx = templ.InitializeContext(ctx)
1744- templ_7745c5c3_Var83 := templ.GetChildren(ctx)1806+ templ_7745c5c3_Var87 := templ.GetChildren(ctx)
1745- if templ_7745c5c3_Var83 == nil {1807+ if templ_7745c5c3_Var87 == nil {
1746- templ_7745c5c3_Var83 = templ.NopComponent1808+ templ_7745c5c3_Var87 = templ.NopComponent
1747 }1809 }
1748 ctx = templ.ClearChildren(ctx)1810 ctx = templ.ClearChildren(ctx)
1749 for _, f := range p.Files {1811 for _, f := range p.Files {
@@ -1774,12 +1836,12 @@ func MRFilesMoved() templ.Component {
1774 }()1836 }()
1775 }1837 }
1776 ctx = templ.InitializeContext(ctx)1838 ctx = templ.InitializeContext(ctx)
1777- templ_7745c5c3_Var84 := templ.GetChildren(ctx)1839+ templ_7745c5c3_Var88 := templ.GetChildren(ctx)
1778- if templ_7745c5c3_Var84 == nil {1840+ if templ_7745c5c3_Var88 == nil {
1779- templ_7745c5c3_Var84 = templ.NopComponent1841+ templ_7745c5c3_Var88 = templ.NopComponent
1780 }1842 }
1781 ctx = templ.ClearChildren(ctx)1843 ctx = templ.ClearChildren(ctx)
1782- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")1844+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
1783 if templ_7745c5c3_Err != nil {1845 if templ_7745c5c3_Err != nil {
1784 return templ_7745c5c3_Err1846 return templ_7745c5c3_Err
1785 }1847 }
@@ -1806,64 +1868,64 @@ func gapRow(g DiffGap, cols int) templ.Component {
1806 }()1868 }()
1807 }1869 }
1808 ctx = templ.InitializeContext(ctx)1870 ctx = templ.InitializeContext(ctx)
1809- templ_7745c5c3_Var85 := templ.GetChildren(ctx)1871+ templ_7745c5c3_Var89 := templ.GetChildren(ctx)
1810- if templ_7745c5c3_Var85 == nil {1872+ if templ_7745c5c3_Var89 == nil {
1811- templ_7745c5c3_Var85 = templ.NopComponent1873+ templ_7745c5c3_Var89 = templ.NopComponent
1812 }1874 }
1813 ctx = templ.ClearChildren(ctx)1875 ctx = templ.ClearChildren(ctx)
1814- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<tr class=\"gap\" data-js data-gap=\"")1876+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<tr class=\"gap\" data-js data-gap=\"")
1815 if templ_7745c5c3_Err != nil {1877 if templ_7745c5c3_Err != nil {
1816 return templ_7745c5c3_Err1878 return templ_7745c5c3_Err
1817 }1879 }
1818- var templ_7745c5c3_Var86 string1880+ var templ_7745c5c3_Var90 string
1819- templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)1881+ templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)
1820 if templ_7745c5c3_Err != nil {1882 if templ_7745c5c3_Err != nil {
1821- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 342, Col: 41}1883+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 351, Col: 41}
1822 }1884 }
1823- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)1885+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)
1824 if templ_7745c5c3_Err != nil {1886 if templ_7745c5c3_Err != nil {
1825 return templ_7745c5c3_Err1887 return templ_7745c5c3_Err
1826 }1888 }
1827- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "\"><td colspan=\"")1889+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "\"><td colspan=\"")
1828 if templ_7745c5c3_Err != nil {1890 if templ_7745c5c3_Err != nil {
1829 return templ_7745c5c3_Err1891 return templ_7745c5c3_Err
1830 }1892 }
1831- var templ_7745c5c3_Var87 string1893+ var templ_7745c5c3_Var91 string
1832- templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))1894+ templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))
1833 if templ_7745c5c3_Err != nil {1895 if templ_7745c5c3_Err != nil {
1834- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 343, Col: 26}1896+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 352, Col: 26}
1835 }1897 }
1836- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)1898+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
1837 if templ_7745c5c3_Err != nil {1899 if templ_7745c5c3_Err != nil {
1838 return templ_7745c5c3_Err1900 return templ_7745c5c3_Err
1839 }1901 }
1840- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"><button type=\"button\" data-src=\"")1902+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "\"><button type=\"button\" data-src=\"")
1841 if templ_7745c5c3_Err != nil {1903 if templ_7745c5c3_Err != nil {
1842 return templ_7745c5c3_Err1904 return templ_7745c5c3_Err
1843 }1905 }
1844- var templ_7745c5c3_Var88 string1906+ var templ_7745c5c3_Var92 string
1845- templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)1907+ templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)
1846 if templ_7745c5c3_Err != nil {1908 if templ_7745c5c3_Err != nil {
1847- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 344, Col: 41}1909+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 353, Col: 41}
1848 }1910 }
1849- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)1911+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
1850 if templ_7745c5c3_Err != nil {1912 if templ_7745c5c3_Err != nil {
1851 return templ_7745c5c3_Err1913 return templ_7745c5c3_Err
1852 }1914 }
1853- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"><span aria-hidden=\"true\">⋯</span> ")1915+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "\"><span aria-hidden=\"true\">⋯</span> ")
1854 if templ_7745c5c3_Err != nil {1916 if templ_7745c5c3_Err != nil {
1855 return templ_7745c5c3_Err1917 return templ_7745c5c3_Err
1856 }1918 }
1857- var templ_7745c5c3_Var89 string1919+ var templ_7745c5c3_Var93 string
1858- templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())1920+ templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())
1859 if templ_7745c5c3_Err != nil {1921 if templ_7745c5c3_Err != nil {
1860- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 345, Col: 51}1922+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 354, Col: 51}
1861 }1923 }
1862- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))1924+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
1863 if templ_7745c5c3_Err != nil {1925 if templ_7745c5c3_Err != nil {
1864 return templ_7745c5c3_Err1926 return templ_7745c5c3_Err
1865 }1927 }
1866- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</button></td></tr>")1928+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</button></td></tr>")
1867 if templ_7745c5c3_Err != nil {1929 if templ_7745c5c3_Err != nil {
1868 return templ_7745c5c3_Err1930 return templ_7745c5c3_Err
1869 }1931 }
@@ -1889,56 +1951,56 @@ func DiffContext(c ContextRows) templ.Component {
1889 }()1951 }()
1890 }1952 }
1891 ctx = templ.InitializeContext(ctx)1953 ctx = templ.InitializeContext(ctx)
1892- templ_7745c5c3_Var90 := templ.GetChildren(ctx)1954+ templ_7745c5c3_Var94 := templ.GetChildren(ctx)
1893- if templ_7745c5c3_Var90 == nil {1955+ if templ_7745c5c3_Var94 == nil {
1894- templ_7745c5c3_Var90 = templ.NopComponent1956+ templ_7745c5c3_Var94 = templ.NopComponent
1895 }1957 }
1896 ctx = templ.ClearChildren(ctx)1958 ctx = templ.ClearChildren(ctx)
1897- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<table><tbody data-for=\"uni\">")1959+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "<table><tbody data-for=\"uni\">")
1898 if templ_7745c5c3_Err != nil {1960 if templ_7745c5c3_Err != nil {
1899 return templ_7745c5c3_Err1961 return templ_7745c5c3_Err
1900 }1962 }
1901 for _, ln := range c.Lines {1963 for _, ln := range c.Lines {
1902- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<tr class=\"ctx\"><td class=\"ln\">")1964+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<tr class=\"ctx\"><td class=\"ln\">")
1903 if templ_7745c5c3_Err != nil {1965 if templ_7745c5c3_Err != nil {
1904 return templ_7745c5c3_Err1966 return templ_7745c5c3_Err
1905 }1967 }
1906- var templ_7745c5c3_Var91 string1968+ var templ_7745c5c3_Var95 string
1907- templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)1969+ templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
1908 if templ_7745c5c3_Err != nil {1970 if templ_7745c5c3_Err != nil {
1909- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 358, Col: 28}1971+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 367, Col: 28}
1910 }1972 }
1911- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))1973+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
1912 if templ_7745c5c3_Err != nil {1974 if templ_7745c5c3_Err != nil {
1913 return templ_7745c5c3_Err1975 return templ_7745c5c3_Err
1914 }1976 }
1915- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "</td><td class=\"ln\">")1977+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</td><td class=\"ln\">")
1916 if templ_7745c5c3_Err != nil {1978 if templ_7745c5c3_Err != nil {
1917 return templ_7745c5c3_Err1979 return templ_7745c5c3_Err
1918 }1980 }
1919- var templ_7745c5c3_Var92 string1981+ var templ_7745c5c3_Var96 string
1920- templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)1982+ templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
1921 if templ_7745c5c3_Err != nil {1983 if templ_7745c5c3_Err != nil {
1922- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 359, Col: 28}1984+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 368, Col: 28}
1923 }1985 }
1924- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))1986+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
1925 if templ_7745c5c3_Err != nil {1987 if templ_7745c5c3_Err != nil {
1926 return templ_7745c5c3_Err1988 return templ_7745c5c3_Err
1927 }1989 }
1928- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "</td><td class=\"m\">")1990+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</td><td class=\"m\">")
1929 if templ_7745c5c3_Err != nil {1991 if templ_7745c5c3_Err != nil {
1930 return templ_7745c5c3_Err1992 return templ_7745c5c3_Err
1931 }1993 }
1932- var templ_7745c5c3_Var93 string1994+ var templ_7745c5c3_Var97 string
1933- templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)1995+ templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1934 if templ_7745c5c3_Err != nil {1996 if templ_7745c5c3_Err != nil {
1935- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 360, Col: 28}1997+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 369, Col: 28}
1936 }1998 }
1937- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))1999+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
1938 if templ_7745c5c3_Err != nil {2000 if templ_7745c5c3_Err != nil {
1939 return templ_7745c5c3_Err2001 return templ_7745c5c3_Err
1940 }2002 }
1941- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</td><td class=\"tx chroma\">")2003+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</td><td class=\"tx chroma\">")
1942 if templ_7745c5c3_Err != nil {2004 if templ_7745c5c3_Err != nil {
1943 return templ_7745c5c3_Err2005 return templ_7745c5c3_Err
1944 }2006 }
@@ -1946,7 +2008,7 @@ func DiffContext(c ContextRows) templ.Component {
1946 if templ_7745c5c3_Err != nil {2008 if templ_7745c5c3_Err != nil {
1947 return templ_7745c5c3_Err2009 return templ_7745c5c3_Err
1948 }2010 }
1949- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</td></tr>")2011+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "</td></tr>")
1950 if templ_7745c5c3_Err != nil {2012 if templ_7745c5c3_Err != nil {
1951 return templ_7745c5c3_Err2013 return templ_7745c5c3_Err
1952 }2014 }
@@ -1957,12 +2019,12 @@ func DiffContext(c ContextRows) templ.Component {
1957 return templ_7745c5c3_Err2019 return templ_7745c5c3_Err
1958 }2020 }
1959 }2021 }
1960- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</tbody> <tbody data-for=\"spl\">")2022+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</tbody> <tbody data-for=\"spl\">")
1961 if templ_7745c5c3_Err != nil {2023 if templ_7745c5c3_Err != nil {
1962 return templ_7745c5c3_Err2024 return templ_7745c5c3_Err
1963 }2025 }
1964 for i := range c.Lines {2026 for i := range c.Lines {
1965- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<tr>")2027+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<tr>")
1966 if templ_7745c5c3_Err != nil {2028 if templ_7745c5c3_Err != nil {
1967 return templ_7745c5c3_Err2029 return templ_7745c5c3_Err
1968 }2030 }
@@ -1974,7 +2036,7 @@ func DiffContext(c ContextRows) templ.Component {
1974 if templ_7745c5c3_Err != nil {2036 if templ_7745c5c3_Err != nil {
1975 return templ_7745c5c3_Err2037 return templ_7745c5c3_Err
1976 }2038 }
1977- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</tr>")2039+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</tr>")
1978 if templ_7745c5c3_Err != nil {2040 if templ_7745c5c3_Err != nil {
1979 return templ_7745c5c3_Err2041 return templ_7745c5c3_Err
1980 }2042 }
@@ -1985,7 +2047,7 @@ func DiffContext(c ContextRows) templ.Component {
1985 return templ_7745c5c3_Err2047 return templ_7745c5c3_Err
1986 }2048 }
1987 }2049 }
1988- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</tbody></table>")2050+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</tbody></table>")
1989 if templ_7745c5c3_Err != nil {2051 if templ_7745c5c3_Err != nil {
1990 return templ_7745c5c3_Err2052 return templ_7745c5c3_Err
1991 }2053 }
@@ -2011,36 +2073,36 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2011 }()2073 }()
2012 }2074 }
2013 ctx = templ.InitializeContext(ctx)2075 ctx = templ.InitializeContext(ctx)
2014- templ_7745c5c3_Var94 := templ.GetChildren(ctx)2076+ templ_7745c5c3_Var98 := templ.GetChildren(ctx)
2015- if templ_7745c5c3_Var94 == nil {2077+ if templ_7745c5c3_Var98 == nil {
2016- templ_7745c5c3_Var94 = templ.NopComponent2078+ templ_7745c5c3_Var98 = templ.NopComponent
2017 }2079 }
2018 ctx = templ.ClearChildren(ctx)2080 ctx = templ.ClearChildren(ctx)
2019 if ln == nil {2081 if ln == nil {
2020- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")2082+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
2021 if templ_7745c5c3_Err != nil {2083 if templ_7745c5c3_Err != nil {
2022 return templ_7745c5c3_Err2084 return templ_7745c5c3_Err
2023 }2085 }
2024 } else {2086 } else {
2025- var templ_7745c5c3_Var95 = []any{"ln", ln.Kind, templ.KV("sel", ln.Selected)}2087+ var templ_7745c5c3_Var99 = []any{"ln", ln.Kind, templ.KV("sel", ln.Selected)}
2026- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var95...)2088+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var99...)
2027 if templ_7745c5c3_Err != nil {2089 if templ_7745c5c3_Err != nil {
2028 return templ_7745c5c3_Err2090 return templ_7745c5c3_Err
2029 }2091 }
2030- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<td class=\"")2092+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<td class=\"")
2031 if templ_7745c5c3_Err != nil {2093 if templ_7745c5c3_Err != nil {
2032 return templ_7745c5c3_Err2094 return templ_7745c5c3_Err
2033 }2095 }
2034- var templ_7745c5c3_Var96 string2096+ var templ_7745c5c3_Var100 string
2035- templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var95).String())2097+ templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var99).String())
2036 if templ_7745c5c3_Err != nil {2098 if templ_7745c5c3_Err != nil {
2037 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}2099 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
2038 }2100 }
2039- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var96)2101+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var100)
2040 if templ_7745c5c3_Err != nil {2102 if templ_7745c5c3_Err != nil {
2041 return templ_7745c5c3_Err2103 return templ_7745c5c3_Err
2042 }2104 }
2043- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "\">")2105+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\">")
2044 if templ_7745c5c3_Err != nil {2106 if templ_7745c5c3_Err != nil {
2045 return templ_7745c5c3_Err2107 return templ_7745c5c3_Err
2046 }2108 }
@@ -2055,64 +2117,64 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2055 return templ_7745c5c3_Err2117 return templ_7745c5c3_Err
2056 }2118 }
2057 }2119 }
2058- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "</td>")2120+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</td>")
2059 if templ_7745c5c3_Err != nil {2121 if templ_7745c5c3_Err != nil {
2060 return templ_7745c5c3_Err2122 return templ_7745c5c3_Err
2061 }2123 }
2062- var templ_7745c5c3_Var97 = []any{"m", ln.Kind, templ.KV("sel", ln.Selected)}2124+ var templ_7745c5c3_Var101 = []any{"m", ln.Kind, templ.KV("sel", ln.Selected)}
2063- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var97...)2125+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var101...)
2064 if templ_7745c5c3_Err != nil {2126 if templ_7745c5c3_Err != nil {
2065 return templ_7745c5c3_Err2127 return templ_7745c5c3_Err
2066 }2128 }
2067- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<td class=\"")2129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<td class=\"")
2068 if templ_7745c5c3_Err != nil {2130 if templ_7745c5c3_Err != nil {
2069 return templ_7745c5c3_Err2131 return templ_7745c5c3_Err
2070 }2132 }
2071- var templ_7745c5c3_Var98 string2133+ var templ_7745c5c3_Var102 string
2072- templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var97).String())2134+ templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var101).String())
2073 if templ_7745c5c3_Err != nil {2135 if templ_7745c5c3_Err != nil {
2074 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}2136 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
2075 }2137 }
2076- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var98)2138+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var102)
2077 if templ_7745c5c3_Err != nil {2139 if templ_7745c5c3_Err != nil {
2078 return templ_7745c5c3_Err2140 return templ_7745c5c3_Err
2079 }2141 }
2080- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "\">")2142+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\">")
2081 if templ_7745c5c3_Err != nil {2143 if templ_7745c5c3_Err != nil {
2082 return templ_7745c5c3_Err2144 return templ_7745c5c3_Err
2083 }2145 }
2084- var templ_7745c5c3_Var99 string2146+ var templ_7745c5c3_Var103 string
2085- templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)2147+ templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
2086 if templ_7745c5c3_Err != nil {2148 if templ_7745c5c3_Err != nil {
2087- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 397, Col: 68}2149+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 68}
2088 }2150 }
2089- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))2151+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
2090 if templ_7745c5c3_Err != nil {2152 if templ_7745c5c3_Err != nil {
2091 return templ_7745c5c3_Err2153 return templ_7745c5c3_Err
2092 }2154 }
2093- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</td>")2155+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</td>")
2094 if templ_7745c5c3_Err != nil {2156 if templ_7745c5c3_Err != nil {
2095 return templ_7745c5c3_Err2157 return templ_7745c5c3_Err
2096 }2158 }
2097- var templ_7745c5c3_Var100 = []any{"tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected)}2159+ var templ_7745c5c3_Var104 = []any{"tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected)}
2098- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var100...)2160+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var104...)
2099 if templ_7745c5c3_Err != nil {2161 if templ_7745c5c3_Err != nil {
2100 return templ_7745c5c3_Err2162 return templ_7745c5c3_Err
2101 }2163 }
2102- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<td class=\"")2164+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "<td class=\"")
2103 if templ_7745c5c3_Err != nil {2165 if templ_7745c5c3_Err != nil {
2104 return templ_7745c5c3_Err2166 return templ_7745c5c3_Err
2105 }2167 }
2106- var templ_7745c5c3_Var101 string2168+ var templ_7745c5c3_Var105 string
2107- templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var100).String())2169+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var104).String())
2108 if templ_7745c5c3_Err != nil {2170 if templ_7745c5c3_Err != nil {
2109 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}2171 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
2110 }2172 }
2111- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var101)2173+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var105)
2112 if templ_7745c5c3_Err != nil {2174 if templ_7745c5c3_Err != nil {
2113 return templ_7745c5c3_Err2175 return templ_7745c5c3_Err
2114 }2176 }
2115- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\">")2177+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "\">")
2116 if templ_7745c5c3_Err != nil {2178 if templ_7745c5c3_Err != nil {
2117 return templ_7745c5c3_Err2179 return templ_7745c5c3_Err
2118 }2180 }
@@ -2120,7 +2182,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2120 if templ_7745c5c3_Err != nil {2182 if templ_7745c5c3_Err != nil {
2121 return templ_7745c5c3_Err2183 return templ_7745c5c3_Err
2122 }2184 }
2123- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</td>")2185+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "</td>")
2124 if templ_7745c5c3_Err != nil {2186 if templ_7745c5c3_Err != nil {
2125 return templ_7745c5c3_Err2187 return templ_7745c5c3_Err
2126 }2188 }
@@ -2147,49 +2209,49 @@ func gutter(n, href string) templ.Component {
2147 }()2209 }()
2148 }2210 }
2149 ctx = templ.InitializeContext(ctx)2211 ctx = templ.InitializeContext(ctx)
2150- templ_7745c5c3_Var102 := templ.GetChildren(ctx)2212+ templ_7745c5c3_Var106 := templ.GetChildren(ctx)
2151- if templ_7745c5c3_Var102 == nil {2213+ if templ_7745c5c3_Var106 == nil {
2152- templ_7745c5c3_Var102 = templ.NopComponent2214+ templ_7745c5c3_Var106 = templ.NopComponent
2153 }2215 }
2154 ctx = templ.ClearChildren(ctx)2216 ctx = templ.ClearChildren(ctx)
2155 if href != "" {2217 if href != "" {
2156- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<a href=\"")2218+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "<a href=\"")
2157 if templ_7745c5c3_Err != nil {2219 if templ_7745c5c3_Err != nil {
2158 return templ_7745c5c3_Err2220 return templ_7745c5c3_Err
2159 }2221 }
2160- var templ_7745c5c3_Var103 templ.SafeURL2222+ var templ_7745c5c3_Var107 templ.SafeURL
2161- templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))2223+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
2162 if templ_7745c5c3_Err != nil {2224 if templ_7745c5c3_Err != nil {
2163- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 31}2225+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 415, Col: 31}
2164 }2226 }
2165- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))2227+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
2166 if templ_7745c5c3_Err != nil {2228 if templ_7745c5c3_Err != nil {
2167 return templ_7745c5c3_Err2229 return templ_7745c5c3_Err
2168 }2230 }
2169- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\" title=\"comment on this line\">")2231+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "\" title=\"comment on this line\">")
2170 if templ_7745c5c3_Err != nil {2232 if templ_7745c5c3_Err != nil {
2171 return templ_7745c5c3_Err2233 return templ_7745c5c3_Err
2172 }2234 }
2173- var templ_7745c5c3_Var104 string2235+ var templ_7745c5c3_Var108 string
2174- templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(n)2236+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2175 if templ_7745c5c3_Err != nil {2237 if templ_7745c5c3_Err != nil {
2176- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 66}2238+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 415, Col: 66}
2177 }2239 }
2178- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))2240+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
2179 if templ_7745c5c3_Err != nil {2241 if templ_7745c5c3_Err != nil {
2180 return templ_7745c5c3_Err2242 return templ_7745c5c3_Err
2181 }2243 }
2182- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</a>")2244+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "</a>")
2183 if templ_7745c5c3_Err != nil {2245 if templ_7745c5c3_Err != nil {
2184 return templ_7745c5c3_Err2246 return templ_7745c5c3_Err
2185 }2247 }
2186 } else {2248 } else {
2187- var templ_7745c5c3_Var105 string2249+ var templ_7745c5c3_Var109 string
2188- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(n)2250+ templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2189 if templ_7745c5c3_Err != nil {2251 if templ_7745c5c3_Err != nil {
2190- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 408, Col: 5}2252+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 417, Col: 5}
2191 }2253 }
2192- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))2254+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
2193 if templ_7745c5c3_Err != nil {2255 if templ_7745c5c3_Err != nil {
2194 return templ_7745c5c3_Err2256 return templ_7745c5c3_Err
2195 }2257 }
M internal/adapters/web/diff_test.go +55 -1
@@ -99,7 +99,10 @@ func TestFileNotes(t *testing.T) {
9999 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},
100100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},
101101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},
102- {"truncated", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2500}, "too large", false},
102+ {"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true},
103+ {"large", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2000, Deletions: 500}, "large diff (2500 lines changed): collapsed", true},
104+ {"budget", domain.FileDiff{Omitted: domain.OmitBudget, Additions: 40}, "collapsed to keep the page light", true},
105+ {"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser (over 8 MiB of text)", true},
103106 }
104107 for _, tt := range tests {
105108 t.Run(tt.name, func(t *testing.T) {
@@ -191,3 +194,54 @@ func TestContextGaps(t *testing.T) {
191194 contextGaps(o, odd, "/r/context/c")
192195 assert.Nil(t, o[0].Hunks[0].Gap)
193196 }
197+
198+func TestDiffNote(t *testing.T) {
199+ t.Parallel()
200+ files := func(w ...domain.FileOmission) domain.Diff {
201+ var d domain.Diff
202+ for _, o := range w {
203+ d.Files = append(d.Files, domain.FileDiff{Omitted: o})
204+ }
205+ return d
206+ }
207+ assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget), 3, 3), "collapsed files have no global note")
208+ d := files(domain.OmitNone)
209+ d.Truncated = true
210+ assert.Equal(t, "this diff is incomplete: 299 files are not listed. the totals above count everything; clone the repository to read the rest.", diffNote(d, 1, 300))
211+ assert.Equal(t, "this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.",
212+ diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable), 3, 3))
213+ d = files(domain.OmitUnavailable)
214+ d.Truncated = true
215+ assert.Contains(t, diffNote(d, 1, 5), "4 files are not listed, 1 file is too large to show")
216+}
217+
218+func TestCollapsedFileMarkup(t *testing.T) {
219+ t.Parallel()
220+ view := DiffView{ShowUnified: true}
221+ src := "/demo/commit/abc1234/files"
222+ for _, why := range []domain.FileOmission{domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget} {
223+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "go.sum", Additions: 3, Omitted: why}}
224+ fs := diffFiles(raw)
225+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
226+ out := renderToString(t, DiffFileView(fs[0], view))
227+ assert.NotContains(t, out, " open", "omission %d", why)
228+ assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&amp;from=0" data-manual>`)
229+ assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`)
230+ assert.NotContains(t, out, "<table")
231+ }
232+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "huge.txt", Additions: 3, Omitted: domain.OmitUnavailable}}
233+ fs := diffFiles(raw)
234+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
235+ out := renderToString(t, DiffFileView(fs[0], view))
236+ assert.Contains(t, out, "too large to show in the browser")
237+ assert.NotContains(t, out, "load diff")
238+ assert.NotContains(t, out, "data-manual")
239+ assert.Contains(t, out, `<a href="/demo/raw/abc1234/huge.txt">view raw</a>`)
240+
241+ // Past the first window of a merge request the link keeps the window.
242+ raw = []domain.FileDiff{{NewPath: "a", Additions: 1, Omitted: domain.OmitLarge}}
243+ fs = diffFiles(raw)
244+ collapseFiles(fs, raw, fileLinks{src: "/d/merge-requests/1/files?base=b&head=h", from: 25})
245+ assert.Equal(t, "/d/merge-requests/1/files?base=b&head=h&one=1&from=0", fs[0].Src)
246+ assert.Equal(t, "?from=25&file=0#f1", fs[0].Href)
247+}
@@ -99,7 +99,10 @@ func TestFileNotes(t *testing.T) {
99 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},99 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},
100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},
101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},
102- {"truncated", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2500}, "too large", false},102+ {"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true},
103+ {"large", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2000, Deletions: 500}, "large diff (2500 lines changed): collapsed", true},
104+ {"budget", domain.FileDiff{Omitted: domain.OmitBudget, Additions: 40}, "collapsed to keep the page light", true},
105+ {"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser (over 8 MiB of text)", true},
103 }106 }
104 for _, tt := range tests {107 for _, tt := range tests {
105 t.Run(tt.name, func(t *testing.T) {108 t.Run(tt.name, func(t *testing.T) {
@@ -191,3 +194,54 @@ func TestContextGaps(t *testing.T) {
191 contextGaps(o, odd, "/r/context/c")194 contextGaps(o, odd, "/r/context/c")
192 assert.Nil(t, o[0].Hunks[0].Gap)195 assert.Nil(t, o[0].Hunks[0].Gap)
193 }196 }
197+
198+func TestDiffNote(t *testing.T) {
199+ t.Parallel()
200+ files := func(w ...domain.FileOmission) domain.Diff {
201+ var d domain.Diff
202+ for _, o := range w {
203+ d.Files = append(d.Files, domain.FileDiff{Omitted: o})
204+ }
205+ return d
206+ }
207+ assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget), 3, 3), "collapsed files have no global note")
208+ d := files(domain.OmitNone)
209+ d.Truncated = true
210+ assert.Equal(t, "this diff is incomplete: 299 files are not listed. the totals above count everything; clone the repository to read the rest.", diffNote(d, 1, 300))
211+ assert.Equal(t, "this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.",
212+ diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable), 3, 3))
213+ d = files(domain.OmitUnavailable)
214+ d.Truncated = true
215+ assert.Contains(t, diffNote(d, 1, 5), "4 files are not listed, 1 file is too large to show")
216+}
217+
218+func TestCollapsedFileMarkup(t *testing.T) {
219+ t.Parallel()
220+ view := DiffView{ShowUnified: true}
221+ src := "/demo/commit/abc1234/files"
222+ for _, why := range []domain.FileOmission{domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget} {
223+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "go.sum", Additions: 3, Omitted: why}}
224+ fs := diffFiles(raw)
225+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
226+ out := renderToString(t, DiffFileView(fs[0], view))
227+ assert.NotContains(t, out, " open", "omission %d", why)
228+ assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&amp;from=0" data-manual>`)
229+ assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`)
230+ assert.NotContains(t, out, "<table")
231+ }
232+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "huge.txt", Additions: 3, Omitted: domain.OmitUnavailable}}
233+ fs := diffFiles(raw)
234+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
235+ out := renderToString(t, DiffFileView(fs[0], view))
236+ assert.Contains(t, out, "too large to show in the browser")
237+ assert.NotContains(t, out, "load diff")
238+ assert.NotContains(t, out, "data-manual")
239+ assert.Contains(t, out, `<a href="/demo/raw/abc1234/huge.txt">view raw</a>`)
240+
241+ // Past the first window of a merge request the link keeps the window.
242+ raw = []domain.FileDiff{{NewPath: "a", Additions: 1, Omitted: domain.OmitLarge}}
243+ fs = diffFiles(raw)
244+ collapseFiles(fs, raw, fileLinks{src: "/d/merge-requests/1/files?base=b&head=h", from: 25})
245+ assert.Equal(t, "/d/merge-requests/1/files?base=b&head=h&one=1&from=0", fs[0].Src)
246+ assert.Equal(t, "?from=25&file=0#f1", fs[0].Href)
247+}
A internal/adapters/web/jstest/lazy.test.mjs +98 -0
@@ -0,0 +1,98 @@
1+// Tests of the loader of the files of a diff (static/js/lazy.js). Run with
2+// node --test 'internal/adapters/web/jstest/*.test.mjs'
3+import assert from 'node:assert/strict';
4+import { test } from 'node:test';
5+import { readFileSync } from 'node:fs';
6+
7+const SRC = readFileSync(new URL('../static/js/lazy.js', import.meta.url), 'utf8');
8+
9+globalThis.window = globalThis;
10+const saved = { document: globalThis.document, fetch: globalThis.fetch, IO: globalThis.IntersectionObserver };
11+
12+// A placeholder: an element with a dataset; its link finds it back.
13+function placeholder(src, manual) {
14+ const details = { id: 'f1', querySelector: () => null };
15+ const ph = { dataset: manual ? { src, manual: '' } : { src }, setAttribute() {}, removeAttribute() {}, closest: (s) => (s === 'details' ? details : null) };
16+ const link = { closest: (s) => (s === '.dlazy' ? ph : s === '.dlazy[data-manual] > a' && manual ? link : null) };
17+ return { ph, link, details };
18+}
19+
20+async function setup(phs, io = true) {
21+ const observed = [];
22+ const urls = [];
23+ if (io) globalThis.IntersectionObserver = class { observe(p) { observed.push(p); } unobserve() {} };
24+ else delete globalThis.IntersectionObserver;
25+ globalThis.fetch = async (u) => { urls.push(u); return { status: 200, ok: true, text: async () => '' }; };
26+ globalThis.document = {
27+ readyState: 'complete',
28+ querySelector: (s) => (s === '#diffs' ? { dataset: {} } : null),
29+ querySelectorAll: (s) => (s.includes(':not([data-manual])') ? phs.filter((p) => !('manual' in p.dataset)) : phs),
30+ getElementById: () => null,
31+ createElement: () => ({ content: { querySelectorAll: () => [] } }),
32+ addEventListener() {},
33+ };
34+ delete globalThis.ermiteLazy;
35+ (0, eval)(SRC);
36+ return { observed, urls };
37+}
38+const settle = () => new Promise((r) => setTimeout(r, 0));
39+const restore = () => {
40+ for (const [k, v] of [['document', saved.document], ['fetch', saved.fetch], ['IntersectionObserver', saved.IO]]) {
41+ if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
42+ }
43+};
44+
45+test('manual placeholders are not observed', async () => {
46+ const a = placeholder('/r/commit/abc/files?one=1&from=0', true);
47+ const b = placeholder('/r/merge-requests/1/files?from=25', false);
48+ try {
49+ const { observed, urls } = await setup([a.ph, b.ph]);
50+ assert.deepEqual(observed, [b.ph]);
51+ assert.deepEqual(urls, []);
52+ } finally { await settle(); restore(); }
53+});
54+
55+test('without IntersectionObserver only the automatic placeholders load', async () => {
56+ const a = placeholder('/m?one=1&from=0', true);
57+ const b = placeholder('/w?from=25', false);
58+ try {
59+ const { urls } = await setup([a.ph, b.ph], false);
60+ assert.deepEqual(urls, ['/w?from=25']);
61+ } finally { await settle(); restore(); }
62+});
63+
64+test('a click on "load diff" loads that one file and does not navigate', async () => {
65+ const a = placeholder('/r/commit/abc/files?one=1&from=3', true);
66+ try {
67+ const { urls } = await setup([a.ph]);
68+ let prevented = false;
69+ const used = window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { prevented = true; } });
70+ assert.equal(used, true);
71+ assert.equal(prevented, true);
72+ assert.deepEqual(urls, ['/r/commit/abc/files?one=1&from=3']);
73+ } finally { await settle(); restore(); }
74+});
75+
76+test('Enter and Space on the link load it; other keys and modified clicks do not', async () => {
77+ const a = placeholder('/f?one=1&from=1', true);
78+ try {
79+ const { urls } = await setup([a.ph]);
80+ const key = (k, extra = {}) => window.ermiteLazy.onKey({ target: a.link, key: k, preventDefault() {}, ...extra });
81+ assert.equal(key('a'), false);
82+ assert.equal(key('Enter', { ctrlKey: true }), false);
83+ assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, ctrlKey: true, preventDefault() {} }), false);
84+ assert.equal(urls.length, 0);
85+ assert.equal(key('Enter'), true);
86+ assert.equal(key(' '), true);
87+ assert.equal(urls.length, 1, 'a file is fetched once');
88+ } finally { await settle(); restore(); }
89+});
90+
91+test('a manual placeholder without an address is a plain link', async () => {
92+ const a = placeholder('', true);
93+ a.ph.dataset.src = '';
94+ try {
95+ await setup([a.ph]);
96+ assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { assert.fail(); } }), false);
97+ } finally { await settle(); restore(); }
98+});
@@ -0,0 +1,98 @@
1+// Tests of the loader of the files of a diff (static/js/lazy.js). Run with
2+// node --test 'internal/adapters/web/jstest/*.test.mjs'
3+import assert from 'node:assert/strict';
4+import { test } from 'node:test';
5+import { readFileSync } from 'node:fs';
6+
7+const SRC = readFileSync(new URL('../static/js/lazy.js', import.meta.url), 'utf8');
8+
9+globalThis.window = globalThis;
10+const saved = { document: globalThis.document, fetch: globalThis.fetch, IO: globalThis.IntersectionObserver };
11+
12+// A placeholder: an element with a dataset; its link finds it back.
13+function placeholder(src, manual) {
14+ const details = { id: 'f1', querySelector: () => null };
15+ const ph = { dataset: manual ? { src, manual: '' } : { src }, setAttribute() {}, removeAttribute() {}, closest: (s) => (s === 'details' ? details : null) };
16+ const link = { closest: (s) => (s === '.dlazy' ? ph : s === '.dlazy[data-manual] > a' && manual ? link : null) };
17+ return { ph, link, details };
18+}
19+
20+async function setup(phs, io = true) {
21+ const observed = [];
22+ const urls = [];
23+ if (io) globalThis.IntersectionObserver = class { observe(p) { observed.push(p); } unobserve() {} };
24+ else delete globalThis.IntersectionObserver;
25+ globalThis.fetch = async (u) => { urls.push(u); return { status: 200, ok: true, text: async () => '' }; };
26+ globalThis.document = {
27+ readyState: 'complete',
28+ querySelector: (s) => (s === '#diffs' ? { dataset: {} } : null),
29+ querySelectorAll: (s) => (s.includes(':not([data-manual])') ? phs.filter((p) => !('manual' in p.dataset)) : phs),
30+ getElementById: () => null,
31+ createElement: () => ({ content: { querySelectorAll: () => [] } }),
32+ addEventListener() {},
33+ };
34+ delete globalThis.ermiteLazy;
35+ (0, eval)(SRC);
36+ return { observed, urls };
37+}
38+const settle = () => new Promise((r) => setTimeout(r, 0));
39+const restore = () => {
40+ for (const [k, v] of [['document', saved.document], ['fetch', saved.fetch], ['IntersectionObserver', saved.IO]]) {
41+ if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
42+ }
43+};
44+
45+test('manual placeholders are not observed', async () => {
46+ const a = placeholder('/r/commit/abc/files?one=1&from=0', true);
47+ const b = placeholder('/r/merge-requests/1/files?from=25', false);
48+ try {
49+ const { observed, urls } = await setup([a.ph, b.ph]);
50+ assert.deepEqual(observed, [b.ph]);
51+ assert.deepEqual(urls, []);
52+ } finally { await settle(); restore(); }
53+});
54+
55+test('without IntersectionObserver only the automatic placeholders load', async () => {
56+ const a = placeholder('/m?one=1&from=0', true);
57+ const b = placeholder('/w?from=25', false);
58+ try {
59+ const { urls } = await setup([a.ph, b.ph], false);
60+ assert.deepEqual(urls, ['/w?from=25']);
61+ } finally { await settle(); restore(); }
62+});
63+
64+test('a click on "load diff" loads that one file and does not navigate', async () => {
65+ const a = placeholder('/r/commit/abc/files?one=1&from=3', true);
66+ try {
67+ const { urls } = await setup([a.ph]);
68+ let prevented = false;
69+ const used = window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { prevented = true; } });
70+ assert.equal(used, true);
71+ assert.equal(prevented, true);
72+ assert.deepEqual(urls, ['/r/commit/abc/files?one=1&from=3']);
73+ } finally { await settle(); restore(); }
74+});
75+
76+test('Enter and Space on the link load it; other keys and modified clicks do not', async () => {
77+ const a = placeholder('/f?one=1&from=1', true);
78+ try {
79+ const { urls } = await setup([a.ph]);
80+ const key = (k, extra = {}) => window.ermiteLazy.onKey({ target: a.link, key: k, preventDefault() {}, ...extra });
81+ assert.equal(key('a'), false);
82+ assert.equal(key('Enter', { ctrlKey: true }), false);
83+ assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, ctrlKey: true, preventDefault() {} }), false);
84+ assert.equal(urls.length, 0);
85+ assert.equal(key('Enter'), true);
86+ assert.equal(key(' '), true);
87+ assert.equal(urls.length, 1, 'a file is fetched once');
88+ } finally { await settle(); restore(); }
89+});
90+
91+test('a manual placeholder without an address is a plain link', async () => {
92+ const a = placeholder('', true);
93+ a.ph.dataset.src = '';
94+ try {
95+ await setup([a.ph]);
96+ assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { assert.fail(); } }), false);
97+ } finally { await settle(); restore(); }
98+});
M internal/adapters/web/jstest/mr.test.mjs +1 -0
@@ -5,6 +5,7 @@ import { test } from 'node:test';
55
66 globalThis.window = globalThis;
77 globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };
8+await import('../static/js/lazy.js');
89 await import('../static/js/mr.js');
910 const C = window.ermiteMR.core;
1011
@@ -5,6 +5,7 @@ import { test } from 'node:test';
5 5
6 globalThis.window = globalThis;6 globalThis.window = globalThis;
7 globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };7 globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };
8+await import('../static/js/lazy.js');
8 await import('../static/js/mr.js');9 await import('../static/js/mr.js');
9 const C = window.ermiteMR.core;10 const C = window.ermiteMR.core;
10 11
M internal/adapters/web/mr.go +19 -1
@@ -568,6 +568,19 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
568568 if from > 0 && from >= len(res.Diff.Files) {
569569 return nil, 0, errNotFound
570570 }
571+ // A page without scripts: ?file=i reads that collapsed file in full.
572+ if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() {
573+ one, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
574+ SinceLastRead: since, DiffOnly: true, Base: res.DiffBaseID, Head: res.MergeRequest.HeadID,
575+ Files: &ports.FileWindow{From: i, To: i + 1, Full: true},
576+ })
577+ if err != nil {
578+ return nil, 0, err
579+ }
580+ if i < len(one.Diff.Files) {
581+ res.Diff.Files[i] = one.Diff.Files[i]
582+ }
583+ }
571584 m := res.MergeRequest
572585 signed := SignedIn(ctx)
573586 open := m.State == domain.MRStateOpen
@@ -589,6 +602,11 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
589602 Files: s.hl.files(ctx, res.Diff.Files),
590603 }
591604 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
605+ src := ""
606+ if !res.DiffBaseID.IsZero() {
607+ src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)
608+ }
609+ collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head})
592610 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
593611 return nil, 0, err
594612 }
@@ -648,7 +666,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
648666 p.Live = true
649667 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
650668 l.HasRail = true
651- l.MR = true
669+ l.MR, l.Lazy = true, true
652670 return MergeRequest(l, p), http.StatusOK, nil
653671 }
654672
@@ -568,6 +568,19 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
568 if from > 0 && from >= len(res.Diff.Files) {568 if from > 0 && from >= len(res.Diff.Files) {
569 return nil, 0, errNotFound569 return nil, 0, errNotFound
570 }570 }
571+ // A page without scripts: ?file=i reads that collapsed file in full.
572+ if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() {
573+ one, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
574+ SinceLastRead: since, DiffOnly: true, Base: res.DiffBaseID, Head: res.MergeRequest.HeadID,
575+ Files: &ports.FileWindow{From: i, To: i + 1, Full: true},
576+ })
577+ if err != nil {
578+ return nil, 0, err
579+ }
580+ if i < len(one.Diff.Files) {
581+ res.Diff.Files[i] = one.Diff.Files[i]
582+ }
583+ }
571 m := res.MergeRequest584 m := res.MergeRequest
572 signed := SignedIn(ctx)585 signed := SignedIn(ctx)
573 open := m.State == domain.MRStateOpen586 open := m.State == domain.MRStateOpen
@@ -589,6 +602,11 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
589 Files: s.hl.files(ctx, res.Diff.Files),602 Files: s.hl.files(ctx, res.Diff.Files),
590 }603 }
591 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))604 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
605+ src := ""
606+ if !res.DiffBaseID.IsZero() {
607+ src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)
608+ }
609+ collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head})
592 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {610 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
593 return nil, 0, err611 return nil, 0, err
594 }612 }
@@ -648,7 +666,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
648 p.Live = true666 p.Live = true
649 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))667 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
650 l.HasRail = true668 l.HasRail = true
651- l.MR = true669+ l.MR, l.Lazy = true, true
652 return MergeRequest(l, p), http.StatusOK, nil670 return MergeRequest(l, p), http.StatusOK, nil
653 }671 }
654 672
M internal/adapters/web/mr_files.go +30 -3
@@ -73,6 +73,18 @@ func parseFrom(r *http.Request) (int, error) {
7373 return n, nil
7474 }
7575
76+// parseOne reads ?one=1&from=i of a one-file fragment: the index i of the
77+// file, any non-negative number.
78+func parseOne(r *http.Request) (int, error) {
79+ q := r.URL.Query()
80+ s := q.Get("from")
81+ n, err := strconv.Atoi(s)
82+ if q.Get("one") != "1" || err != nil || n < 0 || strconv.Itoa(n) != s {
83+ return 0, errNotFound
84+ }
85+ return n, nil
86+}
87+
7688 // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows
7789 // a placeholder that loads their window. src is the URL of /files with the
7890 // base and the head of the diff; self the page, for the links of a page
@@ -167,7 +179,13 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
167179 if since && !SignedIn(ctx) {
168180 return nil, 0, errNotFound
169181 }
170- from, err := parseFrom(r)
182+ one := q.Get("one") == "1"
183+ var from int
184+ if one {
185+ from, err = parseOne(r)
186+ } else {
187+ from, err = parseFrom(r)
188+ }
171189 if err != nil {
172190 return nil, 0, err
173191 }
@@ -180,9 +198,12 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
180198 if err != nil {
181199 return nil, 0, err
182200 }
201+ win := &ports.FileWindow{From: from, To: from + mrWindow}
202+ if one {
203+ win = &ports.FileWindow{From: from, To: from + 1, Full: true}
204+ }
183205 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
184- SinceLastRead: since, DiffOnly: true, Base: base, Head: head,
185- Files: &ports.FileWindow{From: from, To: from + mrWindow},
206+ SinceLastRead: since, DiffOnly: true, Base: base, Head: head, Files: win,
186207 })
187208 if errors.Is(err, domain.ErrConflict) {
188209 return MRFilesMoved(), http.StatusConflict, nil
@@ -198,8 +219,14 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
198219 canWrite := signed && m.State == domain.MRStateOpen && !since
199220 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
200221 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
222+ collapseFiles(p.Files, res.Diff.Files, fileLinks{
223+ src: filesSrc(d.name(), n, since, base.String(), head.String()), from: from, repo: d.name(), rev: head.String(),
224+ })
201225 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
202226 window := p.Files[from:min(from+mrWindow, len(p.Files))]
227+ if one {
228+ window = p.Files[from : from+1]
229+ }
203230 // The layouts follow the page: it sends ?view= (the layout shown) and
204231 // ?layouts=both when it holds both layouts and switches them itself, so
205232 // that every window of the page renders the same way.
@@ -73,6 +73,18 @@ func parseFrom(r *http.Request) (int, error) {
73 return n, nil73 return n, nil
74 }74 }
75 75
76+// parseOne reads ?one=1&from=i of a one-file fragment: the index i of the
77+// file, any non-negative number.
78+func parseOne(r *http.Request) (int, error) {
79+ q := r.URL.Query()
80+ s := q.Get("from")
81+ n, err := strconv.Atoi(s)
82+ if q.Get("one") != "1" || err != nil || n < 0 || strconv.Itoa(n) != s {
83+ return 0, errNotFound
84+ }
85+ return n, nil
86+}
87+
76 // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows88 // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows
77 // a placeholder that loads their window. src is the URL of /files with the89 // a placeholder that loads their window. src is the URL of /files with the
78 // base and the head of the diff; self the page, for the links of a page90 // base and the head of the diff; self the page, for the links of a page
@@ -167,7 +179,13 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
167 if since && !SignedIn(ctx) {179 if since && !SignedIn(ctx) {
168 return nil, 0, errNotFound180 return nil, 0, errNotFound
169 }181 }
170- from, err := parseFrom(r)182+ one := q.Get("one") == "1"
183+ var from int
184+ if one {
185+ from, err = parseOne(r)
186+ } else {
187+ from, err = parseFrom(r)
188+ }
171 if err != nil {189 if err != nil {
172 return nil, 0, err190 return nil, 0, err
173 }191 }
@@ -180,9 +198,12 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
180 if err != nil {198 if err != nil {
181 return nil, 0, err199 return nil, 0, err
182 }200 }
201+ win := &ports.FileWindow{From: from, To: from + mrWindow}
202+ if one {
203+ win = &ports.FileWindow{From: from, To: from + 1, Full: true}
204+ }
183 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{205 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
184- SinceLastRead: since, DiffOnly: true, Base: base, Head: head,206+ SinceLastRead: since, DiffOnly: true, Base: base, Head: head, Files: win,
185- Files: &ports.FileWindow{From: from, To: from + mrWindow},
186 })207 })
187 if errors.Is(err, domain.ErrConflict) {208 if errors.Is(err, domain.ErrConflict) {
188 return MRFilesMoved(), http.StatusConflict, nil209 return MRFilesMoved(), http.StatusConflict, nil
@@ -198,8 +219,14 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
198 canWrite := signed && m.State == domain.MRStateOpen && !since219 canWrite := signed && m.State == domain.MRStateOpen && !since
199 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}220 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
200 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))221 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
222+ collapseFiles(p.Files, res.Diff.Files, fileLinks{
223+ src: filesSrc(d.name(), n, since, base.String(), head.String()), from: from, repo: d.name(), rev: head.String(),
224+ })
201 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)225 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))]226 window := p.Files[from:min(from+mrWindow, len(p.Files))]
227+ if one {
228+ window = p.Files[from : from+1]
229+ }
203 // The layouts follow the page: it sends ?view= (the layout shown) and230 // The layouts follow the page: it sends ?view= (the layout shown) and
204 // ?layouts=both when it holds both layouts and switches them itself, so231 // ?layouts=both when it holds both layouts and switches them itself, so
205 // that every window of the page renders the same way.232 // that every window of the page renders the same way.
M internal/adapters/web/shell.templ +3 -0
@@ -30,6 +30,9 @@ templ Shell(l Layout, rail templ.Component) {
3030 <script src={ l.Assets.Files } defer></script>
3131 <script src={ l.Assets.Passkey } defer></script>
3232 <script src={ l.Assets.Log } defer></script>
33+ if l.Lazy {
34+ <script src={ l.Assets.Lazy } defer></script>
35+ }
3336 if l.MR {
3437 <script src={ l.Assets.MR } defer></script>
3538 }
@@ -30,6 +30,9 @@ templ Shell(l Layout, rail templ.Component) {
30 <script src={ l.Assets.Files } defer></script>30 <script src={ l.Assets.Files } defer></script>
31 <script src={ l.Assets.Passkey } defer></script>31 <script src={ l.Assets.Passkey } defer></script>
32 <script src={ l.Assets.Log } defer></script>32 <script src={ l.Assets.Log } defer></script>
33+ if l.Lazy {
34+ <script src={ l.Assets.Lazy } defer></script>
35+ }
33 if l.MR {36 if l.MR {
34 <script src={ l.Assets.MR } defer></script>37 <script src={ l.Assets.MR } defer></script>
35 }38 }
M internal/adapters/web/shell_templ.go +199 -180 generated file: collapsed
M internal/adapters/web/static/css/60-mr.css +3 -1
@@ -159,7 +159,9 @@
159159 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }
160160 /* with scripts the window comes by itself: the link of the page without
161161 scripts would only flash */
162- html.js & > a { visibility: hidden; }
162+ html.js &:not([data-manual]) > a { visibility: hidden; }
163+ &[data-manual] { height: auto; padding: var(--s3); }
164+ &[data-manual][data-loading] > a { opacity: .5; }
163165 & .notice { margin: 0; }
164166 }
165167 .dloose {
@@ -159,7 +159,9 @@
159 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }159 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }
160 /* with scripts the window comes by itself: the link of the page without160 /* with scripts the window comes by itself: the link of the page without
161 scripts would only flash */161 scripts would only flash */
162- html.js & > a { visibility: hidden; }162+ html.js &:not([data-manual]) > a { visibility: hidden; }
163+ &[data-manual] { height: auto; padding: var(--s3); }
164+ &[data-manual][data-loading] > a { opacity: .5; }
163 & .notice { margin: 0; }165 & .notice { margin: 0; }
164 }166 }
165 .dloose {167 .dloose {
A internal/adapters/web/static/js/lazy.js +150 -0
@@ -0,0 +1,150 @@
1+// lazy.js: the files of a diff that are not in the page yet (commit, compare
2+// and merge request pages).
3+//
4+// A placeholder is a .dlazy[data-src] in a details.dfile. Two kinds:
5+// - a window of a merge request: loaded by itself when it comes within two
6+// screens of the view (the files of its window replace the placeholders of
7+// the same ids);
8+// - a collapsed file (generated, large) [data-manual]: never loaded by
9+// itself. Its "load diff" link loads that one file (click, or Enter / Space
10+// on the link); without scripts the link reloads the page with the file.
11+// A fragment is fetched once. Nothing observes the DOM: the one
12+// IntersectionObserver watches the automatic placeholders, and a manual one
13+// costs no more than its markup.
14+//
15+// window.ermiteLazy.load is also the loader of mr.js (reveal).
16+window.ermiteLazy = (() => {
17+ const $ = (sel, root = document) => root.querySelector(sel);
18+ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
19+ const AUTO = '.dlazy[data-src]:not([data-manual])';
20+ const loading = new Map(); // src -> promise of the swap
21+ let moved = false;
22+ let lazyIO = null; // the observer of the automatic placeholders
23+
24+ // load fetches the fragment of a placeholder and swaps its files in. The
25+ // promise resolves once they are in the page (or the page said to reload).
26+ function load(ph) {
27+ const src = ph?.dataset.src;
28+ if (!src || moved) return Promise.resolve();
29+ if (loading.has(src)) return loading.get(src);
30+ for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
31+ // The fragment renders like the page: the layout shown, and both layouts
32+ // when the page switches them itself (data-diff) or one (data-view).
33+ const d = $('#diffs')?.dataset || {};
34+ const view = d.diff || d.view;
35+ const layouts = d.diff ? 'both' : d.view ? 'one' : '';
36+ const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
37+ const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
38+ .then(async (res) => {
39+ const html = await res.text();
40+ if (res.status === 409) return stale(html);
41+ if (!res.ok) throw new Error('loading files: ' + res.status);
42+ // A fragment of the old diff after the page went stale stays out.
43+ if (!moved) swap(html);
44+ })
45+ .catch((err) => {
46+ loading.delete(src);
47+ for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
48+ window.ermite?.flash?.(err.message, 4000);
49+ });
50+ loading.set(src, p);
51+ return p;
52+ }
53+
54+ // swap replaces each placeholder file with the file of the same id. A
55+ // file the reader folded stays folded.
56+ function swap(html) {
57+ const t = document.createElement('template');
58+ t.innerHTML = html;
59+ for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
60+ const old = document.getElementById(f.id);
61+ if (!old || !old.querySelector('.dlazy')) continue;
62+ // A collapsed file opens once loaded; a file of a window keeps the fold.
63+ f.open = old.querySelector('.dlazy[data-manual]') ? true : old.open;
64+ for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
65+ old.replaceWith(f);
66+ window.htmx?.process(f);
67+ window.ermite?.watchFile?.(f, old);
68+ }
69+ }
70+
71+ // stale: the diff changed under the page. The placeholders say so; no file
72+ // of another diff comes in.
73+ function stale(html) {
74+ moved = true;
75+ for (const p of $$('.dlazy')) {
76+ p.removeAttribute('data-loading');
77+ p.innerHTML = html;
78+ }
79+ }
80+
81+ // Automatic placeholders load as they come within two screens of the view;
82+ // files that come in are marked in the list of files (ermite.js marks the
83+ // one in view). Manual ones are left alone.
84+ function watch() {
85+ if (!('IntersectionObserver' in window)) {
86+ for (const p of $$(AUTO)) load(p);
87+ return;
88+ }
89+ lazyIO = new IntersectionObserver((entries) => {
90+ for (const e of entries) {
91+ if (!e.isIntersecting) continue;
92+ lazyIO.unobserve(e.target);
93+ load(e.target);
94+ }
95+ }, { rootMargin: '200% 0px 200% 0px' });
96+ for (const p of $$(AUTO)) lazyIO.observe(p);
97+ }
98+
99+ // manualLink is the "load diff" link of an event target, when it is one that
100+ // loads in place (the placeholder has an address).
101+ const manualLink = (t) => {
102+ const a = t?.closest?.('.dlazy[data-manual] > a');
103+ return a && a.closest('.dlazy')?.dataset.src ? a : null;
104+ };
105+
106+ // loadManual loads the file of a "load diff" link and puts the focus in it:
107+ // on its first line link, else on its header. The scroll never moves for it.
108+ function loadManual(a) {
109+ const ph = a.closest('.dlazy');
110+ const id = ph.closest('details')?.id;
111+ return load(ph).then(() => {
112+ const f = id && document.getElementById(id);
113+ if (!f || f.querySelector('.dlazy[data-manual]')) return;
114+ (f.querySelector('.ln a') || f.querySelector('summary'))?.focus({ preventScroll: true });
115+ });
116+ }
117+
118+ function onClick(e) {
119+ if (e.defaultPrevented || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;
120+ const a = manualLink(e.target);
121+ if (!a) return false;
122+ e.preventDefault();
123+ loadManual(a);
124+ return true;
125+ }
126+ // Enter makes the browser click a link by itself; Space does not. Both are
127+ // handled here, once (a fragment is fetched once).
128+ function onKey(e) {
129+ if ((e.key !== 'Enter' && e.key !== ' ') || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false;
130+ const a = manualLink(e.target);
131+ if (!a) return false;
132+ e.preventDefault();
133+ loadManual(a);
134+ return true;
135+ }
136+
137+ let started = false;
138+ function start() {
139+ if (started || !$('#diffs')) return;
140+ started = true;
141+ watch();
142+ document.addEventListener('click', onClick);
143+ document.addEventListener('keydown', onKey);
144+ }
145+
146+ return { load, start, onClick, onKey };
147+})();
148+
149+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteLazy.start());
150+else window.ermiteLazy.start();
@@ -0,0 +1,150 @@
1+// lazy.js: the files of a diff that are not in the page yet (commit, compare
2+// and merge request pages).
3+//
4+// A placeholder is a .dlazy[data-src] in a details.dfile. Two kinds:
5+// - a window of a merge request: loaded by itself when it comes within two
6+// screens of the view (the files of its window replace the placeholders of
7+// the same ids);
8+// - a collapsed file (generated, large) [data-manual]: never loaded by
9+// itself. Its "load diff" link loads that one file (click, or Enter / Space
10+// on the link); without scripts the link reloads the page with the file.
11+// A fragment is fetched once. Nothing observes the DOM: the one
12+// IntersectionObserver watches the automatic placeholders, and a manual one
13+// costs no more than its markup.
14+//
15+// window.ermiteLazy.load is also the loader of mr.js (reveal).
16+window.ermiteLazy = (() => {
17+ const $ = (sel, root = document) => root.querySelector(sel);
18+ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
19+ const AUTO = '.dlazy[data-src]:not([data-manual])';
20+ const loading = new Map(); // src -> promise of the swap
21+ let moved = false;
22+ let lazyIO = null; // the observer of the automatic placeholders
23+
24+ // load fetches the fragment of a placeholder and swaps its files in. The
25+ // promise resolves once they are in the page (or the page said to reload).
26+ function load(ph) {
27+ const src = ph?.dataset.src;
28+ if (!src || moved) return Promise.resolve();
29+ if (loading.has(src)) return loading.get(src);
30+ for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
31+ // The fragment renders like the page: the layout shown, and both layouts
32+ // when the page switches them itself (data-diff) or one (data-view).
33+ const d = $('#diffs')?.dataset || {};
34+ const view = d.diff || d.view;
35+ const layouts = d.diff ? 'both' : d.view ? 'one' : '';
36+ const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
37+ const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
38+ .then(async (res) => {
39+ const html = await res.text();
40+ if (res.status === 409) return stale(html);
41+ if (!res.ok) throw new Error('loading files: ' + res.status);
42+ // A fragment of the old diff after the page went stale stays out.
43+ if (!moved) swap(html);
44+ })
45+ .catch((err) => {
46+ loading.delete(src);
47+ for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
48+ window.ermite?.flash?.(err.message, 4000);
49+ });
50+ loading.set(src, p);
51+ return p;
52+ }
53+
54+ // swap replaces each placeholder file with the file of the same id. A
55+ // file the reader folded stays folded.
56+ function swap(html) {
57+ const t = document.createElement('template');
58+ t.innerHTML = html;
59+ for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
60+ const old = document.getElementById(f.id);
61+ if (!old || !old.querySelector('.dlazy')) continue;
62+ // A collapsed file opens once loaded; a file of a window keeps the fold.
63+ f.open = old.querySelector('.dlazy[data-manual]') ? true : old.open;
64+ for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
65+ old.replaceWith(f);
66+ window.htmx?.process(f);
67+ window.ermite?.watchFile?.(f, old);
68+ }
69+ }
70+
71+ // stale: the diff changed under the page. The placeholders say so; no file
72+ // of another diff comes in.
73+ function stale(html) {
74+ moved = true;
75+ for (const p of $$('.dlazy')) {
76+ p.removeAttribute('data-loading');
77+ p.innerHTML = html;
78+ }
79+ }
80+
81+ // Automatic placeholders load as they come within two screens of the view;
82+ // files that come in are marked in the list of files (ermite.js marks the
83+ // one in view). Manual ones are left alone.
84+ function watch() {
85+ if (!('IntersectionObserver' in window)) {
86+ for (const p of $$(AUTO)) load(p);
87+ return;
88+ }
89+ lazyIO = new IntersectionObserver((entries) => {
90+ for (const e of entries) {
91+ if (!e.isIntersecting) continue;
92+ lazyIO.unobserve(e.target);
93+ load(e.target);
94+ }
95+ }, { rootMargin: '200% 0px 200% 0px' });
96+ for (const p of $$(AUTO)) lazyIO.observe(p);
97+ }
98+
99+ // manualLink is the "load diff" link of an event target, when it is one that
100+ // loads in place (the placeholder has an address).
101+ const manualLink = (t) => {
102+ const a = t?.closest?.('.dlazy[data-manual] > a');
103+ return a && a.closest('.dlazy')?.dataset.src ? a : null;
104+ };
105+
106+ // loadManual loads the file of a "load diff" link and puts the focus in it:
107+ // on its first line link, else on its header. The scroll never moves for it.
108+ function loadManual(a) {
109+ const ph = a.closest('.dlazy');
110+ const id = ph.closest('details')?.id;
111+ return load(ph).then(() => {
112+ const f = id && document.getElementById(id);
113+ if (!f || f.querySelector('.dlazy[data-manual]')) return;
114+ (f.querySelector('.ln a') || f.querySelector('summary'))?.focus({ preventScroll: true });
115+ });
116+ }
117+
118+ function onClick(e) {
119+ if (e.defaultPrevented || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;
120+ const a = manualLink(e.target);
121+ if (!a) return false;
122+ e.preventDefault();
123+ loadManual(a);
124+ return true;
125+ }
126+ // Enter makes the browser click a link by itself; Space does not. Both are
127+ // handled here, once (a fragment is fetched once).
128+ function onKey(e) {
129+ if ((e.key !== 'Enter' && e.key !== ' ') || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false;
130+ const a = manualLink(e.target);
131+ if (!a) return false;
132+ e.preventDefault();
133+ loadManual(a);
134+ return true;
135+ }
136+
137+ let started = false;
138+ function start() {
139+ if (started || !$('#diffs')) return;
140+ started = true;
141+ watch();
142+ document.addEventListener('click', onClick);
143+ document.addEventListener('keydown', onKey);
144+ }
145+
146+ return { load, start, onClick, onKey };
147+})();
148+
149+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteLazy.start());
150+else window.ermiteLazy.start();
M internal/adapters/web/static/js/mr.js +5 -83
@@ -1,10 +1,9 @@
11 // mr.js: the merge request page beyond what htmx and hx-live give.
22 //
33 // 1. Windows of files. The page holds the hunks of one window of files; the
4-// other files are placeholders (.dlazy[data-src]). A placeholder that comes
5-// near the screen fetches its window (/files?from=&base=&head=), whose
6-// files replace the placeholders of the same ids. A window is fetched once;
7-// a link to a file or to a comment of a placeholder fetches it first.
4+// other files are placeholders (.dlazy[data-src]), loaded by lazy.js (which
5+// the commit and compare pages use too) when they come near the screen. A
6+// link to a file or to a comment of a placeholder loads it first (reveal).
87 // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu
98 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter
109 // submits (the browser does it: the focus is on the button), Esc closes.
@@ -59,66 +58,8 @@ window.ermiteMR = (() => {
5958
6059 const $ = (sel, root = document) => root.querySelector(sel);
6160 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
62- const loading = new Map(); // src -> promise of the swap
63- let moved = false;
64- let lazyIO = null; // the observer of the placeholders (watch)
6561 let revealGen = 0; // the last reveal wins the scroll
66-
67- // load fetches the window of a placeholder and swaps its files in. The
68- // promise resolves once they are in the page (or the page said to reload).
69- function load(ph) {
70- const src = ph?.dataset.src;
71- if (!src || moved) return Promise.resolve();
72- if (loading.has(src)) return loading.get(src);
73- for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
74- // The window renders like the page: the layout shown, and both layouts
75- // when the page switches them itself (data-diff) or one (data-view).
76- const d = $('#diffs')?.dataset || {};
77- const view = d.diff || d.view;
78- const layouts = d.diff ? 'both' : d.view ? 'one' : '';
79- const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
80- const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
81- .then(async (res) => {
82- const html = await res.text();
83- if (res.status === 409) return stale(html);
84- if (!res.ok) throw new Error('loading files: ' + res.status);
85- // A window of the old diff after the page went stale stays out.
86- if (!moved) swap(html);
87- })
88- .catch((err) => {
89- loading.delete(src);
90- for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
91- window.ermite?.flash?.(err.message, 4000);
92- });
93- loading.set(src, p);
94- return p;
95- }
96-
97- // swap replaces each placeholder file with the file of the same id. A
98- // file the reader folded stays folded.
99- function swap(html) {
100- const t = document.createElement('template');
101- t.innerHTML = html;
102- for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
103- const old = document.getElementById(f.id);
104- if (!old || !old.querySelector('.dlazy')) continue;
105- f.open = old.open;
106- for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
107- old.replaceWith(f);
108- window.htmx?.process(f);
109- window.ermite?.watchFile?.(f, old);
110- }
111- }
112-
113- // stale: the diff changed under the page. The placeholders say so; no file
114- // of another diff comes in.
115- function stale(html) {
116- moved = true;
117- for (const p of $$('.dlazy')) {
118- p.removeAttribute('data-loading');
119- p.innerHTML = html;
120- }
121- }
62+ const load = (ph) => window.ermiteLazy.load(ph);
12263
12364 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
12465 // a placeholder, then scrolls to it. It returns false when there is nothing
@@ -136,7 +77,7 @@ window.ermiteMR = (() => {
13677 }
13778 return document.getElementById(id);
13879 };
139- let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
80+ let ph = document.getElementById(id)?.querySelector(':scope > .dlazy:not([data-manual])');
14081 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
14182 const base = id.replace(/-s$/, '');
14283 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
@@ -168,24 +109,6 @@ window.ermiteMR = (() => {
168109 return true;
169110 }
170111
171- // Placeholders load as they come within two screens of the view; files
172- // that come in are marked in the list of files (ermite.js marks the one in
173- // view).
174- function watch() {
175- if (!('IntersectionObserver' in window)) {
176- for (const p of $$('.dlazy[data-src]')) load(p);
177- return;
178- }
179- lazyIO = new IntersectionObserver((entries) => {
180- for (const e of entries) {
181- if (!e.isIntersecting) continue;
182- lazyIO.unobserve(e.target);
183- load(e.target);
184- }
185- }, { rootMargin: '200% 0px 200% 0px' });
186- for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
187- }
188-
189112 // The merge menu.
190113 const menu = () => $('#merge-menu');
191114 function openMerge() {
@@ -294,7 +217,6 @@ window.ermiteMR = (() => {
294217 // the address of the request, so that a reload does not post again.
295218 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
296219 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
297- watch();
298220 // A link to a file or a comment that is not loaded yet.
299221 const hash = () => {
300222 const id = decodeURIComponent(location.hash.slice(1));
@@ -1,10 +1,9 @@
1 // mr.js: the merge request page beyond what htmx and hx-live give.1 // mr.js: the merge request page beyond what htmx and hx-live give.
2 //2 //
3 // 1. Windows of files. The page holds the hunks of one window of files; the3 // 1. Windows of files. The page holds the hunks of one window of files; the
4-// other files are placeholders (.dlazy[data-src]). A placeholder that comes4+// other files are placeholders (.dlazy[data-src]), loaded by lazy.js (which
5-// near the screen fetches its window (/files?from=&base=&head=), whose5+// the commit and compare pages use too) when they come near the screen. A
6-// files replace the placeholders of the same ids. A window is fetched once;6+// link to a file or to a comment of a placeholder loads it first (reveal).
7-// a link to a file or to a comment of a placeholder fetches it first.
8 // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu7 // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu
9 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter8 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter
10 // submits (the browser does it: the focus is on the button), Esc closes.9 // submits (the browser does it: the focus is on the button), Esc closes.
@@ -59,66 +58,8 @@ window.ermiteMR = (() => {
59 58
60 const $ = (sel, root = document) => root.querySelector(sel);59 const $ = (sel, root = document) => root.querySelector(sel);
61 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];60 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
62- const loading = new Map(); // src -> promise of the swap
63- let moved = false;
64- let lazyIO = null; // the observer of the placeholders (watch)
65 let revealGen = 0; // the last reveal wins the scroll61 let revealGen = 0; // the last reveal wins the scroll
66-62+ const load = (ph) => window.ermiteLazy.load(ph);
67- // load fetches the window of a placeholder and swaps its files in. The
68- // promise resolves once they are in the page (or the page said to reload).
69- function load(ph) {
70- const src = ph?.dataset.src;
71- if (!src || moved) return Promise.resolve();
72- if (loading.has(src)) return loading.get(src);
73- for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
74- // The window renders like the page: the layout shown, and both layouts
75- // when the page switches them itself (data-diff) or one (data-view).
76- const d = $('#diffs')?.dataset || {};
77- const view = d.diff || d.view;
78- const layouts = d.diff ? 'both' : d.view ? 'one' : '';
79- const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
80- const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
81- .then(async (res) => {
82- const html = await res.text();
83- if (res.status === 409) return stale(html);
84- if (!res.ok) throw new Error('loading files: ' + res.status);
85- // A window of the old diff after the page went stale stays out.
86- if (!moved) swap(html);
87- })
88- .catch((err) => {
89- loading.delete(src);
90- for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
91- window.ermite?.flash?.(err.message, 4000);
92- });
93- loading.set(src, p);
94- return p;
95- }
96-
97- // swap replaces each placeholder file with the file of the same id. A
98- // file the reader folded stays folded.
99- function swap(html) {
100- const t = document.createElement('template');
101- t.innerHTML = html;
102- for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
103- const old = document.getElementById(f.id);
104- if (!old || !old.querySelector('.dlazy')) continue;
105- f.open = old.open;
106- for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
107- old.replaceWith(f);
108- window.htmx?.process(f);
109- window.ermite?.watchFile?.(f, old);
110- }
111- }
112-
113- // stale: the diff changed under the page. The placeholders say so; no file
114- // of another diff comes in.
115- function stale(html) {
116- moved = true;
117- for (const p of $$('.dlazy')) {
118- p.removeAttribute('data-loading');
119- p.innerHTML = html;
120- }
121- }
122 63
123 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is64 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
124 // a placeholder, then scrolls to it. It returns false when there is nothing65 // a placeholder, then scrolls to it. It returns false when there is nothing
@@ -136,7 +77,7 @@ window.ermiteMR = (() => {
136 }77 }
137 return document.getElementById(id);78 return document.getElementById(id);
138 };79 };
139- let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');80+ let ph = document.getElementById(id)?.querySelector(':scope > .dlazy:not([data-manual])');
140 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {81 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
141 const base = id.replace(/-s$/, '');82 const base = id.replace(/-s$/, '');
142 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));83 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
@@ -168,24 +109,6 @@ window.ermiteMR = (() => {
168 return true;109 return true;
169 }110 }
170 111
171- // Placeholders load as they come within two screens of the view; files
172- // that come in are marked in the list of files (ermite.js marks the one in
173- // view).
174- function watch() {
175- if (!('IntersectionObserver' in window)) {
176- for (const p of $$('.dlazy[data-src]')) load(p);
177- return;
178- }
179- lazyIO = new IntersectionObserver((entries) => {
180- for (const e of entries) {
181- if (!e.isIntersecting) continue;
182- lazyIO.unobserve(e.target);
183- load(e.target);
184- }
185- }, { rootMargin: '200% 0px 200% 0px' });
186- for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
187- }
188-
189 // The merge menu.112 // The merge menu.
190 const menu = () => $('#merge-menu');113 const menu = () => $('#merge-menu');
191 function openMerge() {114 function openMerge() {
@@ -294,7 +217,6 @@ window.ermiteMR = (() => {
294 // the address of the request, so that a reload does not post again.217 // the address of the request, so that a reload does not post again.
295 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;218 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
296 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);219 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
297- watch();
298 // A link to a file or a comment that is not loaded yet.220 // A link to a file or a comment that is not loaded yet.
299 const hash = () => {221 const hash = () => {
300 const id = decodeURIComponent(location.hash.slice(1));222 const id = decodeURIComponent(location.hash.slice(1));
M internal/adapters/web/view.go +3 -0
@@ -67,6 +67,9 @@ type Layout struct {
6767 // Passkeys tells that the passkey routes exist: the header then links to
6868 // them.
6969 Passkeys bool
70+ // Lazy loads the loader of the collapsed and lazy files of a diff
71+ // (lazy.js): the commit, compare and merge request pages.
72+ Lazy bool
7073 // MR loads the script of the merge request page (mr.js).
7174 MR bool
7275 // IssueForm loads the script of the public issue form (issue-form.js).
@@ -67,6 +67,9 @@ type Layout struct {
67 // Passkeys tells that the passkey routes exist: the header then links to67 // Passkeys tells that the passkey routes exist: the header then links to
68 // them.68 // them.
69 Passkeys bool69 Passkeys bool
70+ // Lazy loads the loader of the collapsed and lazy files of a diff
71+ // (lazy.js): the commit, compare and merge request pages.
72+ Lazy bool
70 // MR loads the script of the merge request page (mr.js).73 // MR loads the script of the merge request page (mr.js).
71 MR bool74 MR bool
72 // IssueForm loads the script of the public issue form (issue-form.js).75 // IssueForm loads the script of the public issue form (issue-form.js).
M internal/adapters/web/web.go +1 -0
@@ -207,6 +207,7 @@ func (s *Server) routes() *http.ServeMux {
207207 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
208208 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
209209 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
210+ mux.HandleFunc("GET /{repo}/commit/{sha}/files", s.page(s.commitFiles))
210211 mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
211212 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
212213 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
@@ -207,6 +207,7 @@ func (s *Server) routes() *http.ServeMux {
207 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))207 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
208 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))208 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
209 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))209 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
210+ mux.HandleFunc("GET /{repo}/commit/{sha}/files", s.page(s.commitFiles))
210 mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))211 mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
211 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))212 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
212 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))213 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
M internal/app/testdata/web/commit-big.html +6 -3
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -141,7 +143,6 @@
141143 </div>
142144 </details>
143145 </div>
144-<p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p>
145146 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
146147 <details class="dfile lnw-2" id="f1" data-file="big.txt">
147148 <summary>
@@ -149,9 +150,11 @@
149150 <span class="p">big.txt</span>
150151 <span class="plus">+2600</span>
151152 <span class="minus">-0</span>
152-<span class="note">diff too large to show (2600 lines changed): collapsed</span>
153+<span class="note">large diff (2600 lines changed): collapsed</span>
153154 </summary>
154-<p class="note pad">diff too large to show (2600 lines changed): collapsed</p>
155+<div class="dlazy" data-src="/diffs/commit/SHA40/files?one=1&amp;from=0" data-manual>
156+<a href="?file=0#f1">load diff</a>
157+</div>
155158 </details>
156159 </div>
157160 </main>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
@@ -141,7 +143,6 @@
141 </div>143 </div>
142 </details>144 </details>
143 </div>145 </div>
144-<p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p>
145 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">146 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
146 <details class="dfile lnw-2" id="f1" data-file="big.txt">147 <details class="dfile lnw-2" id="f1" data-file="big.txt">
147 <summary>148 <summary>
@@ -149,9 +150,11 @@
149 <span class="p">big.txt</span>150 <span class="p">big.txt</span>
150 <span class="plus">+2600</span>151 <span class="plus">+2600</span>
151 <span class="minus">-0</span>152 <span class="minus">-0</span>
152-<span class="note">diff too large to show (2600 lines changed): collapsed</span>153+<span class="note">large diff (2600 lines changed): collapsed</span>
153 </summary>154 </summary>
154-<p class="note pad">diff too large to show (2600 lines changed): collapsed</p>155+<div class="dlazy" data-src="/diffs/commit/SHA40/files?one=1&amp;from=0" data-manual>
156+<a href="?file=0#f1">load diff</a>
157+</div>
155 </details>158 </details>
156 </div>159 </div>
157 </main>160 </main>
M internal/app/testdata/web/commit-binary.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-merge.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-modify-split.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-modify.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-rename.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-root.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-trojan.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/web_commit_test.go +35 -5
@@ -112,12 +112,42 @@ func TestWebCommit(t *testing.T) {
112112 assert.Contains(t, body, "holds invisible characters")
113113 })
114114
115- t.Run("truncated", func(t *testing.T) {
115+ t.Run("large file is collapsed and loads on its own", func(t *testing.T) {
116116 body := get("/diffs/commit/"+f.big, 200)
117- assert.Contains(t, body, "diff too large to show (2600 lines changed): collapsed")
118- assert.Contains(t, body, "this diff is incomplete")
119- assert.NotRegexp(t, `<details class="dfile" id="f1"[^>]* open`, body, "a truncated file is collapsed")
120- assert.NotContains(t, body, "line 2000", "no lines of a truncated file")
117+ assert.Contains(t, body, "large diff (2600 lines changed): collapsed")
118+ assert.NotContains(t, body, "this diff is incomplete", "a collapsed file is not a global note")
119+ assert.NotRegexp(t, `<details class="dfile[^"]*" id="f1"[^>]* open`, body, "a collapsed file is closed")
120+ assert.Contains(t, body, `<div class="dlazy" data-src="/diffs/commit/`+f.big+`/files?one=1&amp;from=0" data-manual>`)
121+ assert.Contains(t, body, `<a href="?file=0#f1">load diff</a>`)
122+ assert.NotContains(t, body, "line 2000", "no rows for a collapsed file")
123+
124+ // One file, in full, over the 2,000 lines of the page.
125+ frag := get("/diffs/commit/"+f.big+"/files?one=1&from=0", 200)
126+ assert.NotContains(t, frag, "<html")
127+ assert.Equal(t, 1, strings.Count(frag, `<details class="dfile`))
128+ assert.Contains(t, frag, "line 2599")
129+ assert.NotContains(t, frag, "data-manual")
130+ assert.Contains(t, frag, `id="f1"`)
131+ both := get("/diffs/commit/"+f.big+"/files?one=1&from=0&view=split&layouts=one", 200)
132+ assert.NotContains(t, both, `class="dt uni"`)
133+ assert.Contains(t, both, `class="dt spl"`)
134+
135+ // Without scripts: the page splices the file in.
136+ page := get("/diffs/commit/"+f.big+"?file=0", 200)
137+ assert.Contains(t, page, "line 2599")
138+ assert.NotContains(t, page, "data-manual")
139+ assert.Contains(t, get("/diffs/commit/"+f.big+"?file=9", 200), "data-manual", "out of range: ignored")
140+ })
141+
142+ t.Run("one file route", func(t *testing.T) {
143+ for _, p := range []string{
144+ "/files?one=1&from=1", "/files?one=1&from=-1", "/files?one=1&from=x", "/files?from=0", "/files?one=1",
145+ } {
146+ st, _, _ := h.get("/diffs/commit/" + f.big + p)
147+ assert.Equal(t, 404, st, p)
148+ }
149+ st, _, _ := h.get("/diffs/commit/main/files?one=1&from=0")
150+ assert.Equal(t, 404, st)
121151 })
122152
123153 t.Run("not found", func(t *testing.T) {
@@ -112,12 +112,42 @@ func TestWebCommit(t *testing.T) {
112 assert.Contains(t, body, "holds invisible characters")112 assert.Contains(t, body, "holds invisible characters")
113 })113 })
114 114
115- t.Run("truncated", func(t *testing.T) {115+ t.Run("large file is collapsed and loads on its own", func(t *testing.T) {
116 body := get("/diffs/commit/"+f.big, 200)116 body := get("/diffs/commit/"+f.big, 200)
117- assert.Contains(t, body, "diff too large to show (2600 lines changed): collapsed")117+ assert.Contains(t, body, "large diff (2600 lines changed): collapsed")
118- assert.Contains(t, body, "this diff is incomplete")118+ assert.NotContains(t, body, "this diff is incomplete", "a collapsed file is not a global note")
119- assert.NotRegexp(t, `<details class="dfile" id="f1"[^>]* open`, body, "a truncated file is collapsed")119+ assert.NotRegexp(t, `<details class="dfile[^"]*" id="f1"[^>]* open`, body, "a collapsed file is closed")
120- assert.NotContains(t, body, "line 2000", "no lines of a truncated file")120+ assert.Contains(t, body, `<div class="dlazy" data-src="/diffs/commit/`+f.big+`/files?one=1&amp;from=0" data-manual>`)
121+ assert.Contains(t, body, `<a href="?file=0#f1">load diff</a>`)
122+ assert.NotContains(t, body, "line 2000", "no rows for a collapsed file")
123+
124+ // One file, in full, over the 2,000 lines of the page.
125+ frag := get("/diffs/commit/"+f.big+"/files?one=1&from=0", 200)
126+ assert.NotContains(t, frag, "<html")
127+ assert.Equal(t, 1, strings.Count(frag, `<details class="dfile`))
128+ assert.Contains(t, frag, "line 2599")
129+ assert.NotContains(t, frag, "data-manual")
130+ assert.Contains(t, frag, `id="f1"`)
131+ both := get("/diffs/commit/"+f.big+"/files?one=1&from=0&view=split&layouts=one", 200)
132+ assert.NotContains(t, both, `class="dt uni"`)
133+ assert.Contains(t, both, `class="dt spl"`)
134+
135+ // Without scripts: the page splices the file in.
136+ page := get("/diffs/commit/"+f.big+"?file=0", 200)
137+ assert.Contains(t, page, "line 2599")
138+ assert.NotContains(t, page, "data-manual")
139+ assert.Contains(t, get("/diffs/commit/"+f.big+"?file=9", 200), "data-manual", "out of range: ignored")
140+ })
141+
142+ t.Run("one file route", func(t *testing.T) {
143+ for _, p := range []string{
144+ "/files?one=1&from=1", "/files?one=1&from=-1", "/files?one=1&from=x", "/files?from=0", "/files?one=1",
145+ } {
146+ st, _, _ := h.get("/diffs/commit/" + f.big + p)
147+ assert.Equal(t, 404, st, p)
148+ }
149+ st, _, _ := h.get("/diffs/commit/main/files?one=1&from=0")
150+ assert.Equal(t, 404, st)
121 })151 })
122 152
123 t.Run("not found", func(t *testing.T) {153 t.Run("not found", func(t *testing.T) {
M internal/app/web_mr_test.go +49 -0
@@ -920,3 +920,52 @@ func TestWebMergeRequestMergeabilityLive(t *testing.T) {
920920 break
921921 }
922922 }
923+
924+// Collapsed files of a merge request: a generated file and a file over the
925+// line cap are listed closed, with no rows and a manual placeholder; /files
926+// with one=1 reads exactly one file in full, whatever its index; the page
927+// without scripts (?file=i) splices the file in.
928+func TestWebMergeRequestCollapsedFiles(t *testing.T) {
929+ r := newMRRepo(t, domain.VisibilityPublic)
930+ b := browser{r.harness}
931+ var big strings.Builder
932+ for i := range 2600 {
933+ fmt.Fprintf(&big, "row %d\n", i)
934+ }
935+ r.writeFile(r.wt, "zz/big.txt", []byte(big.String()))
936+ r.writeFile(r.wt, "vendor/lib/gen.txt", []byte("generated line\n"))
937+ r.writeFile(r.wt, "zz/small.txt", []byte("small line\n"))
938+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
939+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Collapsed").ok(t)
940+
941+ page := b.get("/proj/merge-requests/1", nil)
942+ require.Equal(t, 200, page.status)
943+ assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file")
944+ assert.Contains(t, page.body, "generated file: collapsed")
945+ assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed")
946+ assert.NotContains(t, page.body, "row 5<", "no rows for a collapsed file")
947+ assert.Contains(t, page.body, "small line")
948+ assert.NotContains(t, page.body, "this diff is incomplete")
949+ srcs := regexp.MustCompile(`data-src="([^"]*one=1&amp;from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1)
950+ require.Len(t, srcs, 2)
951+
952+ for _, m := range srcs {
953+ one := b.get(html.UnescapeString(m[1]), nil)
954+ require.Equal(t, 200, one.status, one.body)
955+ assert.Equal(t, 1, strings.Count(one.body, `<details class="dfile`), "exactly one file")
956+ assert.NotContains(t, one.body, "data-manual")
957+ }
958+ bigOne := b.get(html.UnescapeString(srcs[1][1]), nil)
959+ genOne := b.get(html.UnescapeString(srcs[0][1]), nil)
960+ assert.True(t, strings.Contains(bigOne.body, "row 2599") != strings.Contains(genOne.body, "row 2599"))
961+ assert.Contains(t, bigOne.body+genOne.body, "generated line")
962+
963+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=999", 1), nil).status)
964+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=-1", 1), nil).status)
965+
966+ for _, m := range srcs {
967+ own := b.get("/proj/merge-requests/1?file="+m[2], nil)
968+ require.Equal(t, 200, own.status)
969+ assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in")
970+ }
971+}
@@ -920,3 +920,52 @@ func TestWebMergeRequestMergeabilityLive(t *testing.T) {
920 break920 break
921 }921 }
922 }922 }
923+
924+// Collapsed files of a merge request: a generated file and a file over the
925+// line cap are listed closed, with no rows and a manual placeholder; /files
926+// with one=1 reads exactly one file in full, whatever its index; the page
927+// without scripts (?file=i) splices the file in.
928+func TestWebMergeRequestCollapsedFiles(t *testing.T) {
929+ r := newMRRepo(t, domain.VisibilityPublic)
930+ b := browser{r.harness}
931+ var big strings.Builder
932+ for i := range 2600 {
933+ fmt.Fprintf(&big, "row %d\n", i)
934+ }
935+ r.writeFile(r.wt, "zz/big.txt", []byte(big.String()))
936+ r.writeFile(r.wt, "vendor/lib/gen.txt", []byte("generated line\n"))
937+ r.writeFile(r.wt, "zz/small.txt", []byte("small line\n"))
938+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
939+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Collapsed").ok(t)
940+
941+ page := b.get("/proj/merge-requests/1", nil)
942+ require.Equal(t, 200, page.status)
943+ assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file")
944+ assert.Contains(t, page.body, "generated file: collapsed")
945+ assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed")
946+ assert.NotContains(t, page.body, "row 5<", "no rows for a collapsed file")
947+ assert.Contains(t, page.body, "small line")
948+ assert.NotContains(t, page.body, "this diff is incomplete")
949+ srcs := regexp.MustCompile(`data-src="([^"]*one=1&amp;from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1)
950+ require.Len(t, srcs, 2)
951+
952+ for _, m := range srcs {
953+ one := b.get(html.UnescapeString(m[1]), nil)
954+ require.Equal(t, 200, one.status, one.body)
955+ assert.Equal(t, 1, strings.Count(one.body, `<details class="dfile`), "exactly one file")
956+ assert.NotContains(t, one.body, "data-manual")
957+ }
958+ bigOne := b.get(html.UnescapeString(srcs[1][1]), nil)
959+ genOne := b.get(html.UnescapeString(srcs[0][1]), nil)
960+ assert.True(t, strings.Contains(bigOne.body, "row 2599") != strings.Contains(genOne.body, "row 2599"))
961+ assert.Contains(t, bigOne.body+genOne.body, "generated line")
962+
963+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=999", 1), nil).status)
964+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=-1", 1), nil).status)
965+
966+ for _, m := range srcs {
967+ own := b.get("/proj/merge-requests/1?file="+m[2], nil)
968+ require.Equal(t, 200, own.status)
969+ assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in")
970+ }
971+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close