detached 81bd47f

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

commit
81bd47f1210167bc6d04ebacbd3b9b214275d798
author
brice · · 2026-10-20 08:00 UTC
committer
brice · · 2026-10-20 09:16 UTC
parent
1b58f08
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
29 files · +1173 -475
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
@@ -237,12 +237,19 @@ templ DiffFileView(f DiffFile, p DiffView) {
237237 }
238238 </summary>
239239 if f.Lazy {
240- // A placeholder: mr.js fetches the window of the file when it comes
240+ // A placeholder: lazy.js fetches the window of the file when it comes
241241 // near the screen and swaps the files of the window in. Without
242242 // scripts the link opens the page of that window.
243243 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
244244 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
245245 </div>
246+ } else if f.Manual {
247+ // A collapsed file (generated, large, over the page budget): no
248+ // rows. "load diff" reads this one file and swaps it in place
249+ // (lazy.js); without scripts the link reloads the page with the file.
250+ <div class="dlazy" data-src={ f.Src } data-manual>
251+ <a href={ templ.SafeURL(f.Href) }>load diff</a>
252+ </div>
246253 } else if len(f.Hunks) > 0 {
247254 <div class="dscroll">
248255 if p.ShowUnified {
@@ -306,6 +313,8 @@ templ DiffFileView(f DiffFile, p DiffView) {
306313 </table>
307314 }
308315 </div>
316+ } else if f.Raw != "" {
317+ <p class="note pad">{ f.Note } <a href={ templ.SafeURL(f.Raw) }>view raw</a></p>
309318 } else {
310319 <p class="note pad">
311320 if f.Note != "" {
@@ -237,12 +237,19 @@ templ DiffFileView(f DiffFile, p DiffView) {
237 }237 }
238 </summary>238 </summary>
239 if f.Lazy {239 if f.Lazy {
240- // A placeholder: mr.js fetches the window of the file when it comes240+ // A placeholder: lazy.js fetches the window of the file when it comes
241 // near the screen and swaps the files of the window in. Without241 // near the screen and swaps the files of the window in. Without
242 // scripts the link opens the page of that window.242 // scripts the link opens the page of that window.
243 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>243 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
244 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>244 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
245 </div>245 </div>
246+ } else if f.Manual {
247+ // A collapsed file (generated, large, over the page budget): no
248+ // rows. "load diff" reads this one file and swaps it in place
249+ // (lazy.js); without scripts the link reloads the page with the file.
250+ <div class="dlazy" data-src={ f.Src } data-manual>
251+ <a href={ templ.SafeURL(f.Href) }>load diff</a>
252+ </div>
246 } else if len(f.Hunks) > 0 {253 } else if len(f.Hunks) > 0 {
247 <div class="dscroll">254 <div class="dscroll">
248 if p.ShowUnified {255 if p.ShowUnified {
@@ -306,6 +313,8 @@ templ DiffFileView(f DiffFile, p DiffView) {
306 </table>313 </table>
307 }314 }
308 </div>315 </div>
316+ } else if f.Raw != "" {
317+ <p class="note pad">{ f.Note } <a href={ templ.SafeURL(f.Raw) }>view raw</a></p>
309 } else {318 } else {
310 <p class="note pad">319 <p class="note pad">
311 if f.Note != "" {320 if f.Note != "" {
M internal/adapters/web/diff_templ.go +235 -173
@@ -1423,31 +1423,62 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
14231423 if templ_7745c5c3_Err != nil {
14241424 return templ_7745c5c3_Err
14251425 }
1426+ } else if f.Manual {
1427+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, " <div class=\"dlazy\" data-src=\"")
1428+ if templ_7745c5c3_Err != nil {
1429+ return templ_7745c5c3_Err
1430+ }
1431+ var templ_7745c5c3_Var75 string
1432+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Src)
1433+ if templ_7745c5c3_Err != nil {
1434+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 250, Col: 38}
1435+ }
1436+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1437+ if templ_7745c5c3_Err != nil {
1438+ return templ_7745c5c3_Err
1439+ }
1440+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" data-manual><a href=\"")
1441+ if templ_7745c5c3_Err != nil {
1442+ return templ_7745c5c3_Err
1443+ }
1444+ var templ_7745c5c3_Var76 templ.SafeURL
1445+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Href))
1446+ if templ_7745c5c3_Err != nil {
1447+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 251, Col: 35}
1448+ }
1449+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1450+ if templ_7745c5c3_Err != nil {
1451+ return templ_7745c5c3_Err
1452+ }
1453+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\">load diff</a></div>")
1454+ if templ_7745c5c3_Err != nil {
1455+ return templ_7745c5c3_Err
1456+ }
14261457 } else if len(f.Hunks) > 0 {
1427- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<div class=\"dscroll\">")
1458+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<div class=\"dscroll\">")
14281459 if templ_7745c5c3_Err != nil {
14291460 return templ_7745c5c3_Err
14301461 }
14311462 if p.ShowUnified {
1432- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<table class=\"dt uni\" aria-label=\"")
1463+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<table class=\"dt uni\" aria-label=\"")
14331464 if templ_7745c5c3_Err != nil {
14341465 return templ_7745c5c3_Err
14351466 }
1436- var templ_7745c5c3_Var75 string
1437- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
1467+ var templ_7745c5c3_Var77 string
1468+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
14381469 if templ_7745c5c3_Err != nil {
1439- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 249, Col: 60}
1470+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 256, Col: 60}
14401471 }
1441- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1472+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
14421473 if templ_7745c5c3_Err != nil {
14431474 return templ_7745c5c3_Err
14441475 }
1445- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\">")
1476+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "\">")
14461477 if templ_7745c5c3_Err != nil {
14471478 return templ_7745c5c3_Err
14481479 }
14491480 for _, h := range f.Hunks {
1450- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<tbody>")
1481+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tbody>")
14511482 if templ_7745c5c3_Err != nil {
14521483 return templ_7745c5c3_Err
14531484 }
@@ -1457,43 +1488,43 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
14571488 return templ_7745c5c3_Err
14581489 }
14591490 }
1460- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<tr class=\"hunk\"><td colspan=\"4\">")
1491+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "<tr class=\"hunk\"><td colspan=\"4\">")
14611492 if templ_7745c5c3_Err != nil {
14621493 return templ_7745c5c3_Err
14631494 }
1464- var templ_7745c5c3_Var76 string
1465- templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1495+ var templ_7745c5c3_Var78 string
1496+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
14661497 if templ_7745c5c3_Err != nil {
1467- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 255, Col: 51}
1498+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 262, Col: 51}
14681499 }
1469- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1500+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
14701501 if templ_7745c5c3_Err != nil {
14711502 return templ_7745c5c3_Err
14721503 }
1473- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</td></tr>")
1504+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td></tr>")
14741505 if templ_7745c5c3_Err != nil {
14751506 return templ_7745c5c3_Err
14761507 }
14771508 for _, ln := range h.Lines {
1478- var templ_7745c5c3_Var77 = []any{ln.Kind, templ.KV("sel", ln.Selected)}
1479- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var77...)
1509+ var templ_7745c5c3_Var79 = []any{ln.Kind, templ.KV("sel", ln.Selected)}
1510+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var79...)
14801511 if templ_7745c5c3_Err != nil {
14811512 return templ_7745c5c3_Err
14821513 }
1483- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tr class=\"")
1514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "<tr class=\"")
14841515 if templ_7745c5c3_Err != nil {
14851516 return templ_7745c5c3_Err
14861517 }
1487- var templ_7745c5c3_Var78 string
1488- templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var77).String())
1518+ var templ_7745c5c3_Var80 string
1519+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var79).String())
14891520 if templ_7745c5c3_Err != nil {
14901521 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
14911522 }
1492- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)
1523+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
14931524 if templ_7745c5c3_Err != nil {
14941525 return templ_7745c5c3_Err
14951526 }
1496- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\"><td class=\"ln\">")
1527+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\"><td class=\"ln\">")
14971528 if templ_7745c5c3_Err != nil {
14981529 return templ_7745c5c3_Err
14991530 }
@@ -1501,7 +1532,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15011532 if templ_7745c5c3_Err != nil {
15021533 return templ_7745c5c3_Err
15031534 }
1504- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td><td class=\"ln\">")
1535+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</td><td class=\"ln\">")
15051536 if templ_7745c5c3_Err != nil {
15061537 return templ_7745c5c3_Err
15071538 }
@@ -1509,20 +1540,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15091540 if templ_7745c5c3_Err != nil {
15101541 return templ_7745c5c3_Err
15111542 }
1512- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</td><td class=\"m\">")
1543+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</td><td class=\"m\">")
15131544 if templ_7745c5c3_Err != nil {
15141545 return templ_7745c5c3_Err
15151546 }
1516- var templ_7745c5c3_Var79 string
1517- templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1547+ var templ_7745c5c3_Var81 string
1548+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
15181549 if templ_7745c5c3_Err != nil {
1519- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 260, Col: 33}
1550+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 267, Col: 33}
15201551 }
1521- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))
1552+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
15221553 if templ_7745c5c3_Err != nil {
15231554 return templ_7745c5c3_Err
15241555 }
1525- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td><td class=\"tx chroma\">")
1556+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</td><td class=\"tx chroma\">")
15261557 if templ_7745c5c3_Err != nil {
15271558 return templ_7745c5c3_Err
15281559 }
@@ -1530,12 +1561,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15301561 if templ_7745c5c3_Err != nil {
15311562 return templ_7745c5c3_Err
15321563 }
1533- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</td></tr>")
1564+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "</td></tr>")
15341565 if templ_7745c5c3_Err != nil {
15351566 return templ_7745c5c3_Err
15361567 }
15371568 if len(ln.Notes) > 0 || ln.Form != nil {
1538- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
1569+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
15391570 if templ_7745c5c3_Err != nil {
15401571 return templ_7745c5c3_Err
15411572 }
@@ -1543,19 +1574,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15431574 if templ_7745c5c3_Err != nil {
15441575 return templ_7745c5c3_Err
15451576 }
1546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</td></tr>")
1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</td></tr>")
15471578 if templ_7745c5c3_Err != nil {
15481579 return templ_7745c5c3_Err
15491580 }
15501581 }
15511582 }
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 }
15561587 }
15571588 if f.Tail != nil {
1558- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "<tbody>")
1589+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<tbody>")
15591590 if templ_7745c5c3_Err != nil {
15601591 return templ_7745c5c3_Err
15611592 }
@@ -1563,36 +1594,36 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15631594 if templ_7745c5c3_Err != nil {
15641595 return templ_7745c5c3_Err
15651596 }
1566- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</tbody>")
1597+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "</tbody>")
15671598 if templ_7745c5c3_Err != nil {
15681599 return templ_7745c5c3_Err
15691600 }
15701601 }
1571- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "</table>")
1602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "</table>")
15721603 if templ_7745c5c3_Err != nil {
15731604 return templ_7745c5c3_Err
15741605 }
15751606 }
15761607 if p.ShowSplit {
1577- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<table class=\"dt spl\" aria-label=\"")
1608+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<table class=\"dt spl\" aria-label=\"")
15781609 if templ_7745c5c3_Err != nil {
15791610 return templ_7745c5c3_Err
15801611 }
1581- var templ_7745c5c3_Var80 string
1582- templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
1612+ var templ_7745c5c3_Var82 string
1613+ templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
15831614 if templ_7745c5c3_Err != nil {
1584- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 280, Col: 58}
1615+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 287, Col: 58}
15851616 }
1586- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1617+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
15871618 if templ_7745c5c3_Err != nil {
15881619 return templ_7745c5c3_Err
15891620 }
1590- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
1621+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
15911622 if templ_7745c5c3_Err != nil {
15921623 return templ_7745c5c3_Err
15931624 }
15941625 for _, h := range f.Hunks {
1595- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<tbody>")
1626+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<tbody>")
15961627 if templ_7745c5c3_Err != nil {
15971628 return templ_7745c5c3_Err
15981629 }
@@ -1602,25 +1633,25 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16021633 return templ_7745c5c3_Err
16031634 }
16041635 }
1605- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<tr class=\"hunk\"><td colspan=\"6\">")
1636+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<tr class=\"hunk\"><td colspan=\"6\">")
16061637 if templ_7745c5c3_Err != nil {
16071638 return templ_7745c5c3_Err
16081639 }
1609- var templ_7745c5c3_Var81 string
1610- templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1640+ var templ_7745c5c3_Var83 string
1641+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
16111642 if templ_7745c5c3_Err != nil {
1612- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 287, Col: 51}
1643+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 294, Col: 51}
16131644 }
1614- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
1645+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
16151646 if templ_7745c5c3_Err != nil {
16161647 return templ_7745c5c3_Err
16171648 }
1618- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</td></tr>")
1649+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</td></tr>")
16191650 if templ_7745c5c3_Err != nil {
16201651 return templ_7745c5c3_Err
16211652 }
16221653 for _, row := range h.Split {
1623- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<tr>")
1654+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<tr>")
16241655 if templ_7745c5c3_Err != nil {
16251656 return templ_7745c5c3_Err
16261657 }
@@ -1632,12 +1663,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16321663 if templ_7745c5c3_Err != nil {
16331664 return templ_7745c5c3_Err
16341665 }
1635- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</tr>")
1666+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</tr>")
16361667 if templ_7745c5c3_Err != nil {
16371668 return templ_7745c5c3_Err
16381669 }
16391670 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
1640- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<tr class=\"fnote\"><td colspan=\"6\">")
1671+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<tr class=\"fnote\"><td colspan=\"6\">")
16411672 if templ_7745c5c3_Err != nil {
16421673 return templ_7745c5c3_Err
16431674 }
@@ -1645,19 +1676,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16451676 if templ_7745c5c3_Err != nil {
16461677 return templ_7745c5c3_Err
16471678 }
1648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</td></tr>")
1679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</td></tr>")
16491680 if templ_7745c5c3_Err != nil {
16501681 return templ_7745c5c3_Err
16511682 }
16521683 }
16531684 }
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 }
16581689 }
16591690 if f.Tail != nil {
1660- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<tbody>")
1691+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "<tbody>")
16611692 if templ_7745c5c3_Err != nil {
16621693 return templ_7745c5c3_Err
16631694 }
@@ -1665,48 +1696,79 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16651696 if templ_7745c5c3_Err != nil {
16661697 return templ_7745c5c3_Err
16671698 }
1668- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</tbody>")
1699+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</tbody>")
16691700 if templ_7745c5c3_Err != nil {
16701701 return templ_7745c5c3_Err
16711702 }
16721703 }
1673- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</table>")
1704+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</table>")
16741705 if templ_7745c5c3_Err != nil {
16751706 return templ_7745c5c3_Err
16761707 }
16771708 }
1678- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</div>")
1709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "</div>")
1710+ if templ_7745c5c3_Err != nil {
1711+ return templ_7745c5c3_Err
1712+ }
1713+ } else if f.Raw != "" {
1714+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "<p class=\"note pad\">")
1715+ if templ_7745c5c3_Err != nil {
1716+ return templ_7745c5c3_Err
1717+ }
1718+ var templ_7745c5c3_Var84 string
1719+ templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1720+ if templ_7745c5c3_Err != nil {
1721+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 317, Col: 31}
1722+ }
1723+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84))
1724+ if templ_7745c5c3_Err != nil {
1725+ return templ_7745c5c3_Err
1726+ }
1727+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, " <a href=\"")
1728+ if templ_7745c5c3_Err != nil {
1729+ return templ_7745c5c3_Err
1730+ }
1731+ var templ_7745c5c3_Var85 templ.SafeURL
1732+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Raw))
1733+ if templ_7745c5c3_Err != nil {
1734+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 317, Col: 64}
1735+ }
1736+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
1737+ if templ_7745c5c3_Err != nil {
1738+ return templ_7745c5c3_Err
1739+ }
1740+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "\">view raw</a></p>")
16791741 if templ_7745c5c3_Err != nil {
16801742 return templ_7745c5c3_Err
16811743 }
16821744 } else {
1683- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<p class=\"note pad\">")
1745+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<p class=\"note pad\">")
16841746 if templ_7745c5c3_Err != nil {
16851747 return templ_7745c5c3_Err
16861748 }
16871749 if f.Note != "" {
1688- var templ_7745c5c3_Var82 string
1689- templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1750+ var templ_7745c5c3_Var86 string
1751+ templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
16901752 if templ_7745c5c3_Err != nil {
1691- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 312, Col: 13}
1753+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 321, Col: 13}
16921754 }
1693- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82))
1755+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
16941756 if templ_7745c5c3_Err != nil {
16951757 return templ_7745c5c3_Err
16961758 }
16971759 } else {
1698- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "no textual change.")
1760+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "no textual change.")
16991761 if templ_7745c5c3_Err != nil {
17001762 return templ_7745c5c3_Err
17011763 }
17021764 }
1703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "</p>")
1765+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "</p>")
17041766 if templ_7745c5c3_Err != nil {
17051767 return templ_7745c5c3_Err
17061768 }
17071769 }
17081770 if len(f.Loose) > 0 {
1709- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "<div class=\"dloose\">")
1771+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "<div class=\"dloose\">")
17101772 if templ_7745c5c3_Err != nil {
17111773 return templ_7745c5c3_Err
17121774 }
@@ -1716,12 +1778,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
17161778 return templ_7745c5c3_Err
17171779 }
17181780 }
1719- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</div>")
1781+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</div>")
17201782 if templ_7745c5c3_Err != nil {
17211783 return templ_7745c5c3_Err
17221784 }
17231785 }
1724- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</details>")
1786+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</details>")
17251787 if templ_7745c5c3_Err != nil {
17261788 return templ_7745c5c3_Err
17271789 }
@@ -1747,9 +1809,9 @@ func MRFiles(p MRFilesPage) templ.Component {
17471809 }()
17481810 }
17491811 ctx = templ.InitializeContext(ctx)
1750- templ_7745c5c3_Var83 := templ.GetChildren(ctx)
1751- if templ_7745c5c3_Var83 == nil {
1752- templ_7745c5c3_Var83 = templ.NopComponent
1812+ templ_7745c5c3_Var87 := templ.GetChildren(ctx)
1813+ if templ_7745c5c3_Var87 == nil {
1814+ templ_7745c5c3_Var87 = templ.NopComponent
17531815 }
17541816 ctx = templ.ClearChildren(ctx)
17551817 for _, f := range p.Files {
@@ -1780,12 +1842,12 @@ func MRFilesMoved() templ.Component {
17801842 }()
17811843 }
17821844 ctx = templ.InitializeContext(ctx)
1783- templ_7745c5c3_Var84 := templ.GetChildren(ctx)
1784- if templ_7745c5c3_Var84 == nil {
1785- templ_7745c5c3_Var84 = templ.NopComponent
1845+ templ_7745c5c3_Var88 := templ.GetChildren(ctx)
1846+ if templ_7745c5c3_Var88 == nil {
1847+ templ_7745c5c3_Var88 = templ.NopComponent
17861848 }
17871849 ctx = templ.ClearChildren(ctx)
1788- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
1850+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
17891851 if templ_7745c5c3_Err != nil {
17901852 return templ_7745c5c3_Err
17911853 }
@@ -1812,64 +1874,64 @@ func gapRow(g DiffGap, cols int) templ.Component {
18121874 }()
18131875 }
18141876 ctx = templ.InitializeContext(ctx)
1815- templ_7745c5c3_Var85 := templ.GetChildren(ctx)
1816- if templ_7745c5c3_Var85 == nil {
1817- templ_7745c5c3_Var85 = templ.NopComponent
1877+ templ_7745c5c3_Var89 := templ.GetChildren(ctx)
1878+ if templ_7745c5c3_Var89 == nil {
1879+ templ_7745c5c3_Var89 = templ.NopComponent
18181880 }
18191881 ctx = templ.ClearChildren(ctx)
1820- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<tr class=\"gap\" data-js data-gap=\"")
1882+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<tr class=\"gap\" data-js data-gap=\"")
18211883 if templ_7745c5c3_Err != nil {
18221884 return templ_7745c5c3_Err
18231885 }
1824- var templ_7745c5c3_Var86 string
1825- templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)
1886+ var templ_7745c5c3_Var90 string
1887+ templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)
18261888 if templ_7745c5c3_Err != nil {
1827- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 346, Col: 41}
1889+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 355, Col: 41}
18281890 }
1829- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)
1891+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)
18301892 if templ_7745c5c3_Err != nil {
18311893 return templ_7745c5c3_Err
18321894 }
1833- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"><td colspan=\"")
1895+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "\"><td colspan=\"")
18341896 if templ_7745c5c3_Err != nil {
18351897 return templ_7745c5c3_Err
18361898 }
1837- var templ_7745c5c3_Var87 string
1838- templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))
1899+ var templ_7745c5c3_Var91 string
1900+ templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))
18391901 if templ_7745c5c3_Err != nil {
1840- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 347, Col: 26}
1902+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 356, Col: 26}
18411903 }
1842- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)
1904+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
18431905 if templ_7745c5c3_Err != nil {
18441906 return templ_7745c5c3_Err
18451907 }
1846- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"><button type=\"button\" data-src=\"")
1908+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "\"><button type=\"button\" data-src=\"")
18471909 if templ_7745c5c3_Err != nil {
18481910 return templ_7745c5c3_Err
18491911 }
1850- var templ_7745c5c3_Var88 string
1851- templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)
1912+ var templ_7745c5c3_Var92 string
1913+ templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)
18521914 if templ_7745c5c3_Err != nil {
1853- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 348, Col: 41}
1915+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 357, Col: 41}
18541916 }
1855- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)
1917+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
18561918 if templ_7745c5c3_Err != nil {
18571919 return templ_7745c5c3_Err
18581920 }
1859- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "\"><span aria-hidden=\"true\">⋯</span> ")
1921+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "\"><span aria-hidden=\"true\">⋯</span> ")
18601922 if templ_7745c5c3_Err != nil {
18611923 return templ_7745c5c3_Err
18621924 }
1863- var templ_7745c5c3_Var89 string
1864- templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())
1925+ var templ_7745c5c3_Var93 string
1926+ templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())
18651927 if templ_7745c5c3_Err != nil {
1866- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 349, Col: 51}
1928+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 358, Col: 51}
18671929 }
1868- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
1930+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
18691931 if templ_7745c5c3_Err != nil {
18701932 return templ_7745c5c3_Err
18711933 }
1872- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</button></td></tr>")
1934+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</button></td></tr>")
18731935 if templ_7745c5c3_Err != nil {
18741936 return templ_7745c5c3_Err
18751937 }
@@ -1895,56 +1957,56 @@ func DiffContext(c ContextRows) templ.Component {
18951957 }()
18961958 }
18971959 ctx = templ.InitializeContext(ctx)
1898- templ_7745c5c3_Var90 := templ.GetChildren(ctx)
1899- if templ_7745c5c3_Var90 == nil {
1900- templ_7745c5c3_Var90 = templ.NopComponent
1960+ templ_7745c5c3_Var94 := templ.GetChildren(ctx)
1961+ if templ_7745c5c3_Var94 == nil {
1962+ templ_7745c5c3_Var94 = templ.NopComponent
19011963 }
19021964 ctx = templ.ClearChildren(ctx)
1903- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<table><tbody data-for=\"uni\">")
1965+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<table><tbody data-for=\"uni\">")
19041966 if templ_7745c5c3_Err != nil {
19051967 return templ_7745c5c3_Err
19061968 }
19071969 for _, ln := range c.Lines {
1908- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<tr class=\"ctx\"><td class=\"ln\">")
1970+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<tr class=\"ctx\"><td class=\"ln\">")
19091971 if templ_7745c5c3_Err != nil {
19101972 return templ_7745c5c3_Err
19111973 }
1912- var templ_7745c5c3_Var91 string
1913- templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
1974+ var templ_7745c5c3_Var95 string
1975+ templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
19141976 if templ_7745c5c3_Err != nil {
1915- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 362, Col: 28}
1977+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 371, Col: 28}
19161978 }
1917- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
1979+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
19181980 if templ_7745c5c3_Err != nil {
19191981 return templ_7745c5c3_Err
19201982 }
1921- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "</td><td class=\"ln\">")
1983+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</td><td class=\"ln\">")
19221984 if templ_7745c5c3_Err != nil {
19231985 return templ_7745c5c3_Err
19241986 }
1925- var templ_7745c5c3_Var92 string
1926- templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
1987+ var templ_7745c5c3_Var96 string
1988+ templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
19271989 if templ_7745c5c3_Err != nil {
1928- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 363, Col: 28}
1990+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 372, Col: 28}
19291991 }
1930- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
1992+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
19311993 if templ_7745c5c3_Err != nil {
19321994 return templ_7745c5c3_Err
19331995 }
1934- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</td><td class=\"m\">")
1996+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</td><td class=\"m\">")
19351997 if templ_7745c5c3_Err != nil {
19361998 return templ_7745c5c3_Err
19371999 }
1938- var templ_7745c5c3_Var93 string
1939- templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
2000+ var templ_7745c5c3_Var97 string
2001+ templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
19402002 if templ_7745c5c3_Err != nil {
1941- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 364, Col: 28}
2003+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 373, Col: 28}
19422004 }
1943- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
2005+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
19442006 if templ_7745c5c3_Err != nil {
19452007 return templ_7745c5c3_Err
19462008 }
1947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</td><td class=\"tx chroma\">")
2009+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "</td><td class=\"tx chroma\">")
19482010 if templ_7745c5c3_Err != nil {
19492011 return templ_7745c5c3_Err
19502012 }
@@ -1952,7 +2014,7 @@ func DiffContext(c ContextRows) templ.Component {
19522014 if templ_7745c5c3_Err != nil {
19532015 return templ_7745c5c3_Err
19542016 }
1955- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</td></tr>")
2017+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</td></tr>")
19562018 if templ_7745c5c3_Err != nil {
19572019 return templ_7745c5c3_Err
19582020 }
@@ -1963,12 +2025,12 @@ func DiffContext(c ContextRows) templ.Component {
19632025 return templ_7745c5c3_Err
19642026 }
19652027 }
1966- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "</tbody> <tbody data-for=\"spl\">")
2028+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "</tbody> <tbody data-for=\"spl\">")
19672029 if templ_7745c5c3_Err != nil {
19682030 return templ_7745c5c3_Err
19692031 }
19702032 for i := range c.Lines {
1971- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<tr>")
2033+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<tr>")
19722034 if templ_7745c5c3_Err != nil {
19732035 return templ_7745c5c3_Err
19742036 }
@@ -1980,7 +2042,7 @@ func DiffContext(c ContextRows) templ.Component {
19802042 if templ_7745c5c3_Err != nil {
19812043 return templ_7745c5c3_Err
19822044 }
1983- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</tr>")
2045+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</tr>")
19842046 if templ_7745c5c3_Err != nil {
19852047 return templ_7745c5c3_Err
19862048 }
@@ -1991,7 +2053,7 @@ func DiffContext(c ContextRows) templ.Component {
19912053 return templ_7745c5c3_Err
19922054 }
19932055 }
1994- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</tbody></table>")
2056+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</tbody></table>")
19952057 if templ_7745c5c3_Err != nil {
19962058 return templ_7745c5c3_Err
19972059 }
@@ -2017,36 +2079,36 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20172079 }()
20182080 }
20192081 ctx = templ.InitializeContext(ctx)
2020- templ_7745c5c3_Var94 := templ.GetChildren(ctx)
2021- if templ_7745c5c3_Var94 == nil {
2022- templ_7745c5c3_Var94 = templ.NopComponent
2082+ templ_7745c5c3_Var98 := templ.GetChildren(ctx)
2083+ if templ_7745c5c3_Var98 == nil {
2084+ templ_7745c5c3_Var98 = templ.NopComponent
20232085 }
20242086 ctx = templ.ClearChildren(ctx)
20252087 if ln == nil {
2026- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
2088+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
20272089 if templ_7745c5c3_Err != nil {
20282090 return templ_7745c5c3_Err
20292091 }
20302092 } else {
2031- var templ_7745c5c3_Var95 = []any{"ln", ln.Kind, templ.KV("sel", ln.Selected)}
2032- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var95...)
2093+ var templ_7745c5c3_Var99 = []any{"ln", ln.Kind, templ.KV("sel", ln.Selected)}
2094+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var99...)
20332095 if templ_7745c5c3_Err != nil {
20342096 return templ_7745c5c3_Err
20352097 }
2036- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "<td class=\"")
2098+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "<td class=\"")
20372099 if templ_7745c5c3_Err != nil {
20382100 return templ_7745c5c3_Err
20392101 }
2040- var templ_7745c5c3_Var96 string
2041- templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var95).String())
2102+ var templ_7745c5c3_Var100 string
2103+ templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var99).String())
20422104 if templ_7745c5c3_Err != nil {
20432105 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
20442106 }
2045- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var96)
2107+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var100)
20462108 if templ_7745c5c3_Err != nil {
20472109 return templ_7745c5c3_Err
20482110 }
2049- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "\">")
2111+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "\">")
20502112 if templ_7745c5c3_Err != nil {
20512113 return templ_7745c5c3_Err
20522114 }
@@ -2061,64 +2123,64 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20612123 return templ_7745c5c3_Err
20622124 }
20632125 }
2064- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</td>")
2126+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "</td>")
20652127 if templ_7745c5c3_Err != nil {
20662128 return templ_7745c5c3_Err
20672129 }
2068- var templ_7745c5c3_Var97 = []any{"m", ln.Kind, templ.KV("sel", ln.Selected)}
2069- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var97...)
2130+ var templ_7745c5c3_Var101 = []any{"m", ln.Kind, templ.KV("sel", ln.Selected)}
2131+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var101...)
20702132 if templ_7745c5c3_Err != nil {
20712133 return templ_7745c5c3_Err
20722134 }
2073- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "<td class=\"")
2135+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "<td class=\"")
20742136 if templ_7745c5c3_Err != nil {
20752137 return templ_7745c5c3_Err
20762138 }
2077- var templ_7745c5c3_Var98 string
2078- templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var97).String())
2139+ var templ_7745c5c3_Var102 string
2140+ templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var101).String())
20792141 if templ_7745c5c3_Err != nil {
20802142 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
20812143 }
2082- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var98)
2144+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var102)
20832145 if templ_7745c5c3_Err != nil {
20842146 return templ_7745c5c3_Err
20852147 }
2086- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "\">")
2148+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "\">")
20872149 if templ_7745c5c3_Err != nil {
20882150 return templ_7745c5c3_Err
20892151 }
2090- var templ_7745c5c3_Var99 string
2091- templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
2152+ var templ_7745c5c3_Var103 string
2153+ templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
20922154 if templ_7745c5c3_Err != nil {
2093- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 401, Col: 68}
2155+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 410, Col: 68}
20942156 }
2095- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
2157+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
20962158 if templ_7745c5c3_Err != nil {
20972159 return templ_7745c5c3_Err
20982160 }
2099- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "</td>")
2161+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</td>")
21002162 if templ_7745c5c3_Err != nil {
21012163 return templ_7745c5c3_Err
21022164 }
2103- var templ_7745c5c3_Var100 = []any{"tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected)}
2104- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var100...)
2165+ var templ_7745c5c3_Var104 = []any{"tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected)}
2166+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var104...)
21052167 if templ_7745c5c3_Err != nil {
21062168 return templ_7745c5c3_Err
21072169 }
2108- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "<td class=\"")
2170+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "<td class=\"")
21092171 if templ_7745c5c3_Err != nil {
21102172 return templ_7745c5c3_Err
21112173 }
2112- var templ_7745c5c3_Var101 string
2113- templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var100).String())
2174+ var templ_7745c5c3_Var105 string
2175+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var104).String())
21142176 if templ_7745c5c3_Err != nil {
21152177 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
21162178 }
2117- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var101)
2179+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var105)
21182180 if templ_7745c5c3_Err != nil {
21192181 return templ_7745c5c3_Err
21202182 }
2121- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "\">")
2183+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "\">")
21222184 if templ_7745c5c3_Err != nil {
21232185 return templ_7745c5c3_Err
21242186 }
@@ -2126,7 +2188,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
21262188 if templ_7745c5c3_Err != nil {
21272189 return templ_7745c5c3_Err
21282190 }
2129- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "</td>")
2191+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "</td>")
21302192 if templ_7745c5c3_Err != nil {
21312193 return templ_7745c5c3_Err
21322194 }
@@ -2153,49 +2215,49 @@ func gutter(n, href string) templ.Component {
21532215 }()
21542216 }
21552217 ctx = templ.InitializeContext(ctx)
2156- templ_7745c5c3_Var102 := templ.GetChildren(ctx)
2157- if templ_7745c5c3_Var102 == nil {
2158- templ_7745c5c3_Var102 = templ.NopComponent
2218+ templ_7745c5c3_Var106 := templ.GetChildren(ctx)
2219+ if templ_7745c5c3_Var106 == nil {
2220+ templ_7745c5c3_Var106 = templ.NopComponent
21592221 }
21602222 ctx = templ.ClearChildren(ctx)
21612223 if href != "" {
2162- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "<a href=\"")
2224+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "<a href=\"")
21632225 if templ_7745c5c3_Err != nil {
21642226 return templ_7745c5c3_Err
21652227 }
2166- var templ_7745c5c3_Var103 templ.SafeURL
2167- templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
2228+ var templ_7745c5c3_Var107 templ.SafeURL
2229+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
21682230 if templ_7745c5c3_Err != nil {
2169- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 410, Col: 31}
2231+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 419, Col: 31}
21702232 }
2171- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
2233+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
21722234 if templ_7745c5c3_Err != nil {
21732235 return templ_7745c5c3_Err
21742236 }
2175- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "\" title=\"comment on this line\">")
2237+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "\" title=\"comment on this line\">")
21762238 if templ_7745c5c3_Err != nil {
21772239 return templ_7745c5c3_Err
21782240 }
2179- var templ_7745c5c3_Var104 string
2180- templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2241+ var templ_7745c5c3_Var108 string
2242+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(n)
21812243 if templ_7745c5c3_Err != nil {
2182- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 410, Col: 66}
2244+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 419, Col: 66}
21832245 }
2184- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
2246+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
21852247 if templ_7745c5c3_Err != nil {
21862248 return templ_7745c5c3_Err
21872249 }
2188- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</a>")
2250+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "</a>")
21892251 if templ_7745c5c3_Err != nil {
21902252 return templ_7745c5c3_Err
21912253 }
21922254 } else {
2193- var templ_7745c5c3_Var105 string
2194- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2255+ var templ_7745c5c3_Var109 string
2256+ templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(n)
21952257 if templ_7745c5c3_Err != nil {
2196- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 412, Col: 5}
2258+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 421, Col: 5}
21972259 }
2198- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
2260+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
21992261 if templ_7745c5c3_Err != nil {
22002262 return templ_7745c5c3_Err
22012263 }
@@ -1423,31 +1423,62 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1423 if templ_7745c5c3_Err != nil {1423 if templ_7745c5c3_Err != nil {
1424 return templ_7745c5c3_Err1424 return templ_7745c5c3_Err
1425 }1425 }
1426+ } else if f.Manual {
1427+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, " <div class=\"dlazy\" data-src=\"")
1428+ if templ_7745c5c3_Err != nil {
1429+ return templ_7745c5c3_Err
1430+ }
1431+ var templ_7745c5c3_Var75 string
1432+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Src)
1433+ if templ_7745c5c3_Err != nil {
1434+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 250, Col: 38}
1435+ }
1436+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1437+ if templ_7745c5c3_Err != nil {
1438+ return templ_7745c5c3_Err
1439+ }
1440+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" data-manual><a href=\"")
1441+ if templ_7745c5c3_Err != nil {
1442+ return templ_7745c5c3_Err
1443+ }
1444+ var templ_7745c5c3_Var76 templ.SafeURL
1445+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Href))
1446+ if templ_7745c5c3_Err != nil {
1447+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 251, Col: 35}
1448+ }
1449+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1450+ if templ_7745c5c3_Err != nil {
1451+ return templ_7745c5c3_Err
1452+ }
1453+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\">load diff</a></div>")
1454+ if templ_7745c5c3_Err != nil {
1455+ return templ_7745c5c3_Err
1456+ }
1426 } else if len(f.Hunks) > 0 {1457 } else if len(f.Hunks) > 0 {
1427- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<div class=\"dscroll\">")1458+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<div class=\"dscroll\">")
1428 if templ_7745c5c3_Err != nil {1459 if templ_7745c5c3_Err != nil {
1429 return templ_7745c5c3_Err1460 return templ_7745c5c3_Err
1430 }1461 }
1431 if p.ShowUnified {1462 if p.ShowUnified {
1432- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<table class=\"dt uni\" aria-label=\"")1463+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<table class=\"dt uni\" aria-label=\"")
1433 if templ_7745c5c3_Err != nil {1464 if templ_7745c5c3_Err != nil {
1434 return templ_7745c5c3_Err1465 return templ_7745c5c3_Err
1435 }1466 }
1436- var templ_7745c5c3_Var75 string1467+ var templ_7745c5c3_Var77 string
1437- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")1468+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
1438 if templ_7745c5c3_Err != nil {1469 if templ_7745c5c3_Err != nil {
1439- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 249, Col: 60}1470+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 256, Col: 60}
1440 }1471 }
1441- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)1472+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
1442 if templ_7745c5c3_Err != nil {1473 if templ_7745c5c3_Err != nil {
1443 return templ_7745c5c3_Err1474 return templ_7745c5c3_Err
1444 }1475 }
1445- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\">")1476+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "\">")
1446 if templ_7745c5c3_Err != nil {1477 if templ_7745c5c3_Err != nil {
1447 return templ_7745c5c3_Err1478 return templ_7745c5c3_Err
1448 }1479 }
1449 for _, h := range f.Hunks {1480 for _, h := range f.Hunks {
1450- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<tbody>")1481+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tbody>")
1451 if templ_7745c5c3_Err != nil {1482 if templ_7745c5c3_Err != nil {
1452 return templ_7745c5c3_Err1483 return templ_7745c5c3_Err
1453 }1484 }
@@ -1457,43 +1488,43 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1457 return templ_7745c5c3_Err1488 return templ_7745c5c3_Err
1458 }1489 }
1459 }1490 }
1460- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<tr class=\"hunk\"><td colspan=\"4\">")1491+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "<tr class=\"hunk\"><td colspan=\"4\">")
1461 if templ_7745c5c3_Err != nil {1492 if templ_7745c5c3_Err != nil {
1462 return templ_7745c5c3_Err1493 return templ_7745c5c3_Err
1463 }1494 }
1464- var templ_7745c5c3_Var76 string1495+ var templ_7745c5c3_Var78 string
1465- templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)1496+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1466 if templ_7745c5c3_Err != nil {1497 if templ_7745c5c3_Err != nil {
1467- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 255, Col: 51}1498+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 262, Col: 51}
1468 }1499 }
1469- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))1500+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
1470 if templ_7745c5c3_Err != nil {1501 if templ_7745c5c3_Err != nil {
1471 return templ_7745c5c3_Err1502 return templ_7745c5c3_Err
1472 }1503 }
1473- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</td></tr>")1504+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td></tr>")
1474 if templ_7745c5c3_Err != nil {1505 if templ_7745c5c3_Err != nil {
1475 return templ_7745c5c3_Err1506 return templ_7745c5c3_Err
1476 }1507 }
1477 for _, ln := range h.Lines {1508 for _, ln := range h.Lines {
1478- var templ_7745c5c3_Var77 = []any{ln.Kind, templ.KV("sel", ln.Selected)}1509+ var templ_7745c5c3_Var79 = []any{ln.Kind, templ.KV("sel", ln.Selected)}
1479- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var77...)1510+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var79...)
1480 if templ_7745c5c3_Err != nil {1511 if templ_7745c5c3_Err != nil {
1481 return templ_7745c5c3_Err1512 return templ_7745c5c3_Err
1482 }1513 }
1483- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tr class=\"")1514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "<tr class=\"")
1484 if templ_7745c5c3_Err != nil {1515 if templ_7745c5c3_Err != nil {
1485 return templ_7745c5c3_Err1516 return templ_7745c5c3_Err
1486 }1517 }
1487- var templ_7745c5c3_Var78 string1518+ var templ_7745c5c3_Var80 string
1488- templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var77).String())1519+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var79).String())
1489 if templ_7745c5c3_Err != nil {1520 if templ_7745c5c3_Err != nil {
1490 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}1521 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
1491 }1522 }
1492- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)1523+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1493 if templ_7745c5c3_Err != nil {1524 if templ_7745c5c3_Err != nil {
1494 return templ_7745c5c3_Err1525 return templ_7745c5c3_Err
1495 }1526 }
1496- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\"><td class=\"ln\">")1527+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\"><td class=\"ln\">")
1497 if templ_7745c5c3_Err != nil {1528 if templ_7745c5c3_Err != nil {
1498 return templ_7745c5c3_Err1529 return templ_7745c5c3_Err
1499 }1530 }
@@ -1501,7 +1532,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1501 if templ_7745c5c3_Err != nil {1532 if templ_7745c5c3_Err != nil {
1502 return templ_7745c5c3_Err1533 return templ_7745c5c3_Err
1503 }1534 }
1504- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td><td class=\"ln\">")1535+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</td><td class=\"ln\">")
1505 if templ_7745c5c3_Err != nil {1536 if templ_7745c5c3_Err != nil {
1506 return templ_7745c5c3_Err1537 return templ_7745c5c3_Err
1507 }1538 }
@@ -1509,20 +1540,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1509 if templ_7745c5c3_Err != nil {1540 if templ_7745c5c3_Err != nil {
1510 return templ_7745c5c3_Err1541 return templ_7745c5c3_Err
1511 }1542 }
1512- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</td><td class=\"m\">")1543+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</td><td class=\"m\">")
1513 if templ_7745c5c3_Err != nil {1544 if templ_7745c5c3_Err != nil {
1514 return templ_7745c5c3_Err1545 return templ_7745c5c3_Err
1515 }1546 }
1516- var templ_7745c5c3_Var79 string1547+ var templ_7745c5c3_Var81 string
1517- templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)1548+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1518 if templ_7745c5c3_Err != nil {1549 if templ_7745c5c3_Err != nil {
1519- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 260, Col: 33}1550+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 267, Col: 33}
1520 }1551 }
1521- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))1552+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
1522 if templ_7745c5c3_Err != nil {1553 if templ_7745c5c3_Err != nil {
1523 return templ_7745c5c3_Err1554 return templ_7745c5c3_Err
1524 }1555 }
1525- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td><td class=\"tx chroma\">")1556+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</td><td class=\"tx chroma\">")
1526 if templ_7745c5c3_Err != nil {1557 if templ_7745c5c3_Err != nil {
1527 return templ_7745c5c3_Err1558 return templ_7745c5c3_Err
1528 }1559 }
@@ -1530,12 +1561,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1530 if templ_7745c5c3_Err != nil {1561 if templ_7745c5c3_Err != nil {
1531 return templ_7745c5c3_Err1562 return templ_7745c5c3_Err
1532 }1563 }
1533- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</td></tr>")1564+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "</td></tr>")
1534 if templ_7745c5c3_Err != nil {1565 if templ_7745c5c3_Err != nil {
1535 return templ_7745c5c3_Err1566 return templ_7745c5c3_Err
1536 }1567 }
1537 if len(ln.Notes) > 0 || ln.Form != nil {1568 if len(ln.Notes) > 0 || ln.Form != nil {
1538- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")1569+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
1539 if templ_7745c5c3_Err != nil {1570 if templ_7745c5c3_Err != nil {
1540 return templ_7745c5c3_Err1571 return templ_7745c5c3_Err
1541 }1572 }
@@ -1543,19 +1574,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1543 if templ_7745c5c3_Err != nil {1574 if templ_7745c5c3_Err != nil {
1544 return templ_7745c5c3_Err1575 return templ_7745c5c3_Err
1545 }1576 }
1546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</td></tr>")1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</td></tr>")
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 }1582 }
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 }
1556 }1587 }
1557 if f.Tail != nil {1588 if f.Tail != nil {
1558- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "<tbody>")1589+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<tbody>")
1559 if templ_7745c5c3_Err != nil {1590 if templ_7745c5c3_Err != nil {
1560 return templ_7745c5c3_Err1591 return templ_7745c5c3_Err
1561 }1592 }
@@ -1563,36 +1594,36 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1563 if templ_7745c5c3_Err != nil {1594 if templ_7745c5c3_Err != nil {
1564 return templ_7745c5c3_Err1595 return templ_7745c5c3_Err
1565 }1596 }
1566- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</tbody>")1597+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "</tbody>")
1567 if templ_7745c5c3_Err != nil {1598 if templ_7745c5c3_Err != nil {
1568 return templ_7745c5c3_Err1599 return templ_7745c5c3_Err
1569 }1600 }
1570 }1601 }
1571- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "</table>")1602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "</table>")
1572 if templ_7745c5c3_Err != nil {1603 if templ_7745c5c3_Err != nil {
1573 return templ_7745c5c3_Err1604 return templ_7745c5c3_Err
1574 }1605 }
1575 }1606 }
1576 if p.ShowSplit {1607 if p.ShowSplit {
1577- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<table class=\"dt spl\" aria-label=\"")1608+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<table class=\"dt spl\" aria-label=\"")
1578 if templ_7745c5c3_Err != nil {1609 if templ_7745c5c3_Err != nil {
1579 return templ_7745c5c3_Err1610 return templ_7745c5c3_Err
1580 }1611 }
1581- var templ_7745c5c3_Var80 string1612+ var templ_7745c5c3_Var82 string
1582- templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")1613+ templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
1583 if templ_7745c5c3_Err != nil {1614 if templ_7745c5c3_Err != nil {
1584- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 280, Col: 58}1615+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 287, Col: 58}
1585 }1616 }
1586- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)1617+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
1587 if templ_7745c5c3_Err != nil {1618 if templ_7745c5c3_Err != nil {
1588 return templ_7745c5c3_Err1619 return templ_7745c5c3_Err
1589 }1620 }
1590- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")1621+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
1591 if templ_7745c5c3_Err != nil {1622 if templ_7745c5c3_Err != nil {
1592 return templ_7745c5c3_Err1623 return templ_7745c5c3_Err
1593 }1624 }
1594 for _, h := range f.Hunks {1625 for _, h := range f.Hunks {
1595- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<tbody>")1626+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<tbody>")
1596 if templ_7745c5c3_Err != nil {1627 if templ_7745c5c3_Err != nil {
1597 return templ_7745c5c3_Err1628 return templ_7745c5c3_Err
1598 }1629 }
@@ -1602,25 +1633,25 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1602 return templ_7745c5c3_Err1633 return templ_7745c5c3_Err
1603 }1634 }
1604 }1635 }
1605- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<tr class=\"hunk\"><td colspan=\"6\">")1636+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<tr class=\"hunk\"><td colspan=\"6\">")
1606 if templ_7745c5c3_Err != nil {1637 if templ_7745c5c3_Err != nil {
1607 return templ_7745c5c3_Err1638 return templ_7745c5c3_Err
1608 }1639 }
1609- var templ_7745c5c3_Var81 string1640+ var templ_7745c5c3_Var83 string
1610- templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)1641+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1611 if templ_7745c5c3_Err != nil {1642 if templ_7745c5c3_Err != nil {
1612- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 287, Col: 51}1643+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 294, Col: 51}
1613 }1644 }
1614- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))1645+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
1615 if templ_7745c5c3_Err != nil {1646 if templ_7745c5c3_Err != nil {
1616 return templ_7745c5c3_Err1647 return templ_7745c5c3_Err
1617 }1648 }
1618- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</td></tr>")1649+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</td></tr>")
1619 if templ_7745c5c3_Err != nil {1650 if templ_7745c5c3_Err != nil {
1620 return templ_7745c5c3_Err1651 return templ_7745c5c3_Err
1621 }1652 }
1622 for _, row := range h.Split {1653 for _, row := range h.Split {
1623- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<tr>")1654+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<tr>")
1624 if templ_7745c5c3_Err != nil {1655 if templ_7745c5c3_Err != nil {
1625 return templ_7745c5c3_Err1656 return templ_7745c5c3_Err
1626 }1657 }
@@ -1632,12 +1663,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1632 if templ_7745c5c3_Err != nil {1663 if templ_7745c5c3_Err != nil {
1633 return templ_7745c5c3_Err1664 return templ_7745c5c3_Err
1634 }1665 }
1635- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</tr>")1666+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</tr>")
1636 if templ_7745c5c3_Err != nil {1667 if templ_7745c5c3_Err != nil {
1637 return templ_7745c5c3_Err1668 return templ_7745c5c3_Err
1638 }1669 }
1639 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {1670 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
1640- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<tr class=\"fnote\"><td colspan=\"6\">")1671+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<tr class=\"fnote\"><td colspan=\"6\">")
1641 if templ_7745c5c3_Err != nil {1672 if templ_7745c5c3_Err != nil {
1642 return templ_7745c5c3_Err1673 return templ_7745c5c3_Err
1643 }1674 }
@@ -1645,19 +1676,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1645 if templ_7745c5c3_Err != nil {1676 if templ_7745c5c3_Err != nil {
1646 return templ_7745c5c3_Err1677 return templ_7745c5c3_Err
1647 }1678 }
1648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</td></tr>")1679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</td></tr>")
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 }1684 }
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 }
1658 }1689 }
1659 if f.Tail != nil {1690 if f.Tail != nil {
1660- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<tbody>")1691+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "<tbody>")
1661 if templ_7745c5c3_Err != nil {1692 if templ_7745c5c3_Err != nil {
1662 return templ_7745c5c3_Err1693 return templ_7745c5c3_Err
1663 }1694 }
@@ -1665,48 +1696,79 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1665 if templ_7745c5c3_Err != nil {1696 if templ_7745c5c3_Err != nil {
1666 return templ_7745c5c3_Err1697 return templ_7745c5c3_Err
1667 }1698 }
1668- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</tbody>")1699+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</tbody>")
1669 if templ_7745c5c3_Err != nil {1700 if templ_7745c5c3_Err != nil {
1670 return templ_7745c5c3_Err1701 return templ_7745c5c3_Err
1671 }1702 }
1672 }1703 }
1673- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</table>")1704+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</table>")
1674 if templ_7745c5c3_Err != nil {1705 if templ_7745c5c3_Err != nil {
1675 return templ_7745c5c3_Err1706 return templ_7745c5c3_Err
1676 }1707 }
1677 }1708 }
1678- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</div>")1709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "</div>")
1710+ if templ_7745c5c3_Err != nil {
1711+ return templ_7745c5c3_Err
1712+ }
1713+ } else if f.Raw != "" {
1714+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "<p class=\"note pad\">")
1715+ if templ_7745c5c3_Err != nil {
1716+ return templ_7745c5c3_Err
1717+ }
1718+ var templ_7745c5c3_Var84 string
1719+ templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1720+ if templ_7745c5c3_Err != nil {
1721+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 317, Col: 31}
1722+ }
1723+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84))
1724+ if templ_7745c5c3_Err != nil {
1725+ return templ_7745c5c3_Err
1726+ }
1727+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, " <a href=\"")
1728+ if templ_7745c5c3_Err != nil {
1729+ return templ_7745c5c3_Err
1730+ }
1731+ var templ_7745c5c3_Var85 templ.SafeURL
1732+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Raw))
1733+ if templ_7745c5c3_Err != nil {
1734+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 317, Col: 64}
1735+ }
1736+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
1737+ if templ_7745c5c3_Err != nil {
1738+ return templ_7745c5c3_Err
1739+ }
1740+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "\">view raw</a></p>")
1679 if templ_7745c5c3_Err != nil {1741 if templ_7745c5c3_Err != nil {
1680 return templ_7745c5c3_Err1742 return templ_7745c5c3_Err
1681 }1743 }
1682 } else {1744 } else {
1683- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<p class=\"note pad\">")1745+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<p class=\"note pad\">")
1684 if templ_7745c5c3_Err != nil {1746 if templ_7745c5c3_Err != nil {
1685 return templ_7745c5c3_Err1747 return templ_7745c5c3_Err
1686 }1748 }
1687 if f.Note != "" {1749 if f.Note != "" {
1688- var templ_7745c5c3_Var82 string1750+ var templ_7745c5c3_Var86 string
1689- templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)1751+ templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1690 if templ_7745c5c3_Err != nil {1752 if templ_7745c5c3_Err != nil {
1691- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 312, Col: 13}1753+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 321, Col: 13}
1692 }1754 }
1693- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82))1755+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
1694 if templ_7745c5c3_Err != nil {1756 if templ_7745c5c3_Err != nil {
1695 return templ_7745c5c3_Err1757 return templ_7745c5c3_Err
1696 }1758 }
1697 } else {1759 } else {
1698- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "no textual change.")1760+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "no textual change.")
1699 if templ_7745c5c3_Err != nil {1761 if templ_7745c5c3_Err != nil {
1700 return templ_7745c5c3_Err1762 return templ_7745c5c3_Err
1701 }1763 }
1702 }1764 }
1703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "</p>")1765+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "</p>")
1704 if templ_7745c5c3_Err != nil {1766 if templ_7745c5c3_Err != nil {
1705 return templ_7745c5c3_Err1767 return templ_7745c5c3_Err
1706 }1768 }
1707 }1769 }
1708 if len(f.Loose) > 0 {1770 if len(f.Loose) > 0 {
1709- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "<div class=\"dloose\">")1771+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "<div class=\"dloose\">")
1710 if templ_7745c5c3_Err != nil {1772 if templ_7745c5c3_Err != nil {
1711 return templ_7745c5c3_Err1773 return templ_7745c5c3_Err
1712 }1774 }
@@ -1716,12 +1778,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1716 return templ_7745c5c3_Err1778 return templ_7745c5c3_Err
1717 }1779 }
1718 }1780 }
1719- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</div>")1781+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</div>")
1720 if templ_7745c5c3_Err != nil {1782 if templ_7745c5c3_Err != nil {
1721 return templ_7745c5c3_Err1783 return templ_7745c5c3_Err
1722 }1784 }
1723 }1785 }
1724- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</details>")1786+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</details>")
1725 if templ_7745c5c3_Err != nil {1787 if templ_7745c5c3_Err != nil {
1726 return templ_7745c5c3_Err1788 return templ_7745c5c3_Err
1727 }1789 }
@@ -1747,9 +1809,9 @@ func MRFiles(p MRFilesPage) templ.Component {
1747 }()1809 }()
1748 }1810 }
1749 ctx = templ.InitializeContext(ctx)1811 ctx = templ.InitializeContext(ctx)
1750- templ_7745c5c3_Var83 := templ.GetChildren(ctx)1812+ templ_7745c5c3_Var87 := templ.GetChildren(ctx)
1751- if templ_7745c5c3_Var83 == nil {1813+ if templ_7745c5c3_Var87 == nil {
1752- templ_7745c5c3_Var83 = templ.NopComponent1814+ templ_7745c5c3_Var87 = templ.NopComponent
1753 }1815 }
1754 ctx = templ.ClearChildren(ctx)1816 ctx = templ.ClearChildren(ctx)
1755 for _, f := range p.Files {1817 for _, f := range p.Files {
@@ -1780,12 +1842,12 @@ func MRFilesMoved() templ.Component {
1780 }()1842 }()
1781 }1843 }
1782 ctx = templ.InitializeContext(ctx)1844 ctx = templ.InitializeContext(ctx)
1783- templ_7745c5c3_Var84 := templ.GetChildren(ctx)1845+ templ_7745c5c3_Var88 := templ.GetChildren(ctx)
1784- if templ_7745c5c3_Var84 == nil {1846+ if templ_7745c5c3_Var88 == nil {
1785- templ_7745c5c3_Var84 = templ.NopComponent1847+ templ_7745c5c3_Var88 = templ.NopComponent
1786 }1848 }
1787 ctx = templ.ClearChildren(ctx)1849 ctx = templ.ClearChildren(ctx)
1788- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")1850+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
1789 if templ_7745c5c3_Err != nil {1851 if templ_7745c5c3_Err != nil {
1790 return templ_7745c5c3_Err1852 return templ_7745c5c3_Err
1791 }1853 }
@@ -1812,64 +1874,64 @@ func gapRow(g DiffGap, cols int) templ.Component {
1812 }()1874 }()
1813 }1875 }
1814 ctx = templ.InitializeContext(ctx)1876 ctx = templ.InitializeContext(ctx)
1815- templ_7745c5c3_Var85 := templ.GetChildren(ctx)1877+ templ_7745c5c3_Var89 := templ.GetChildren(ctx)
1816- if templ_7745c5c3_Var85 == nil {1878+ if templ_7745c5c3_Var89 == nil {
1817- templ_7745c5c3_Var85 = templ.NopComponent1879+ templ_7745c5c3_Var89 = templ.NopComponent
1818 }1880 }
1819 ctx = templ.ClearChildren(ctx)1881 ctx = templ.ClearChildren(ctx)
1820- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<tr class=\"gap\" data-js data-gap=\"")1882+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<tr class=\"gap\" data-js data-gap=\"")
1821 if templ_7745c5c3_Err != nil {1883 if templ_7745c5c3_Err != nil {
1822 return templ_7745c5c3_Err1884 return templ_7745c5c3_Err
1823 }1885 }
1824- var templ_7745c5c3_Var86 string1886+ var templ_7745c5c3_Var90 string
1825- templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)1887+ templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)
1826 if templ_7745c5c3_Err != nil {1888 if templ_7745c5c3_Err != nil {
1827- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 346, Col: 41}1889+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 355, Col: 41}
1828 }1890 }
1829- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)1891+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)
1830 if templ_7745c5c3_Err != nil {1892 if templ_7745c5c3_Err != nil {
1831 return templ_7745c5c3_Err1893 return templ_7745c5c3_Err
1832 }1894 }
1833- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"><td colspan=\"")1895+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "\"><td colspan=\"")
1834 if templ_7745c5c3_Err != nil {1896 if templ_7745c5c3_Err != nil {
1835 return templ_7745c5c3_Err1897 return templ_7745c5c3_Err
1836 }1898 }
1837- var templ_7745c5c3_Var87 string1899+ var templ_7745c5c3_Var91 string
1838- templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))1900+ templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))
1839 if templ_7745c5c3_Err != nil {1901 if templ_7745c5c3_Err != nil {
1840- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 347, Col: 26}1902+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 356, Col: 26}
1841 }1903 }
1842- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)1904+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
1843 if templ_7745c5c3_Err != nil {1905 if templ_7745c5c3_Err != nil {
1844 return templ_7745c5c3_Err1906 return templ_7745c5c3_Err
1845 }1907 }
1846- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"><button type=\"button\" data-src=\"")1908+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "\"><button type=\"button\" data-src=\"")
1847 if templ_7745c5c3_Err != nil {1909 if templ_7745c5c3_Err != nil {
1848 return templ_7745c5c3_Err1910 return templ_7745c5c3_Err
1849 }1911 }
1850- var templ_7745c5c3_Var88 string1912+ var templ_7745c5c3_Var92 string
1851- templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)1913+ templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)
1852 if templ_7745c5c3_Err != nil {1914 if templ_7745c5c3_Err != nil {
1853- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 348, Col: 41}1915+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 357, Col: 41}
1854 }1916 }
1855- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)1917+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
1856 if templ_7745c5c3_Err != nil {1918 if templ_7745c5c3_Err != nil {
1857 return templ_7745c5c3_Err1919 return templ_7745c5c3_Err
1858 }1920 }
1859- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "\"><span aria-hidden=\"true\">⋯</span> ")1921+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "\"><span aria-hidden=\"true\">⋯</span> ")
1860 if templ_7745c5c3_Err != nil {1922 if templ_7745c5c3_Err != nil {
1861 return templ_7745c5c3_Err1923 return templ_7745c5c3_Err
1862 }1924 }
1863- var templ_7745c5c3_Var89 string1925+ var templ_7745c5c3_Var93 string
1864- templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())1926+ templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())
1865 if templ_7745c5c3_Err != nil {1927 if templ_7745c5c3_Err != nil {
1866- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 349, Col: 51}1928+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 358, Col: 51}
1867 }1929 }
1868- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))1930+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
1869 if templ_7745c5c3_Err != nil {1931 if templ_7745c5c3_Err != nil {
1870 return templ_7745c5c3_Err1932 return templ_7745c5c3_Err
1871 }1933 }
1872- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</button></td></tr>")1934+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</button></td></tr>")
1873 if templ_7745c5c3_Err != nil {1935 if templ_7745c5c3_Err != nil {
1874 return templ_7745c5c3_Err1936 return templ_7745c5c3_Err
1875 }1937 }
@@ -1895,56 +1957,56 @@ func DiffContext(c ContextRows) templ.Component {
1895 }()1957 }()
1896 }1958 }
1897 ctx = templ.InitializeContext(ctx)1959 ctx = templ.InitializeContext(ctx)
1898- templ_7745c5c3_Var90 := templ.GetChildren(ctx)1960+ templ_7745c5c3_Var94 := templ.GetChildren(ctx)
1899- if templ_7745c5c3_Var90 == nil {1961+ if templ_7745c5c3_Var94 == nil {
1900- templ_7745c5c3_Var90 = templ.NopComponent1962+ templ_7745c5c3_Var94 = templ.NopComponent
1901 }1963 }
1902 ctx = templ.ClearChildren(ctx)1964 ctx = templ.ClearChildren(ctx)
1903- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<table><tbody data-for=\"uni\">")1965+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<table><tbody data-for=\"uni\">")
1904 if templ_7745c5c3_Err != nil {1966 if templ_7745c5c3_Err != nil {
1905 return templ_7745c5c3_Err1967 return templ_7745c5c3_Err
1906 }1968 }
1907 for _, ln := range c.Lines {1969 for _, ln := range c.Lines {
1908- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<tr class=\"ctx\"><td class=\"ln\">")1970+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<tr class=\"ctx\"><td class=\"ln\">")
1909 if templ_7745c5c3_Err != nil {1971 if templ_7745c5c3_Err != nil {
1910 return templ_7745c5c3_Err1972 return templ_7745c5c3_Err
1911 }1973 }
1912- var templ_7745c5c3_Var91 string1974+ var templ_7745c5c3_Var95 string
1913- templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)1975+ templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
1914 if templ_7745c5c3_Err != nil {1976 if templ_7745c5c3_Err != nil {
1915- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 362, Col: 28}1977+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 371, Col: 28}
1916 }1978 }
1917- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))1979+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
1918 if templ_7745c5c3_Err != nil {1980 if templ_7745c5c3_Err != nil {
1919 return templ_7745c5c3_Err1981 return templ_7745c5c3_Err
1920 }1982 }
1921- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "</td><td class=\"ln\">")1983+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</td><td class=\"ln\">")
1922 if templ_7745c5c3_Err != nil {1984 if templ_7745c5c3_Err != nil {
1923 return templ_7745c5c3_Err1985 return templ_7745c5c3_Err
1924 }1986 }
1925- var templ_7745c5c3_Var92 string1987+ var templ_7745c5c3_Var96 string
1926- templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)1988+ templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
1927 if templ_7745c5c3_Err != nil {1989 if templ_7745c5c3_Err != nil {
1928- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 363, Col: 28}1990+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 372, Col: 28}
1929 }1991 }
1930- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))1992+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
1931 if templ_7745c5c3_Err != nil {1993 if templ_7745c5c3_Err != nil {
1932 return templ_7745c5c3_Err1994 return templ_7745c5c3_Err
1933 }1995 }
1934- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</td><td class=\"m\">")1996+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</td><td class=\"m\">")
1935 if templ_7745c5c3_Err != nil {1997 if templ_7745c5c3_Err != nil {
1936 return templ_7745c5c3_Err1998 return templ_7745c5c3_Err
1937 }1999 }
1938- var templ_7745c5c3_Var93 string2000+ var templ_7745c5c3_Var97 string
1939- templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)2001+ templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1940 if templ_7745c5c3_Err != nil {2002 if templ_7745c5c3_Err != nil {
1941- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 364, Col: 28}2003+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 373, Col: 28}
1942 }2004 }
1943- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))2005+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
1944 if templ_7745c5c3_Err != nil {2006 if templ_7745c5c3_Err != nil {
1945 return templ_7745c5c3_Err2007 return templ_7745c5c3_Err
1946 }2008 }
1947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</td><td class=\"tx chroma\">")2009+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "</td><td class=\"tx chroma\">")
1948 if templ_7745c5c3_Err != nil {2010 if templ_7745c5c3_Err != nil {
1949 return templ_7745c5c3_Err2011 return templ_7745c5c3_Err
1950 }2012 }
@@ -1952,7 +2014,7 @@ func DiffContext(c ContextRows) templ.Component {
1952 if templ_7745c5c3_Err != nil {2014 if templ_7745c5c3_Err != nil {
1953 return templ_7745c5c3_Err2015 return templ_7745c5c3_Err
1954 }2016 }
1955- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</td></tr>")2017+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</td></tr>")
1956 if templ_7745c5c3_Err != nil {2018 if templ_7745c5c3_Err != nil {
1957 return templ_7745c5c3_Err2019 return templ_7745c5c3_Err
1958 }2020 }
@@ -1963,12 +2025,12 @@ func DiffContext(c ContextRows) templ.Component {
1963 return templ_7745c5c3_Err2025 return templ_7745c5c3_Err
1964 }2026 }
1965 }2027 }
1966- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "</tbody> <tbody data-for=\"spl\">")2028+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "</tbody> <tbody data-for=\"spl\">")
1967 if templ_7745c5c3_Err != nil {2029 if templ_7745c5c3_Err != nil {
1968 return templ_7745c5c3_Err2030 return templ_7745c5c3_Err
1969 }2031 }
1970 for i := range c.Lines {2032 for i := range c.Lines {
1971- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<tr>")2033+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<tr>")
1972 if templ_7745c5c3_Err != nil {2034 if templ_7745c5c3_Err != nil {
1973 return templ_7745c5c3_Err2035 return templ_7745c5c3_Err
1974 }2036 }
@@ -1980,7 +2042,7 @@ func DiffContext(c ContextRows) templ.Component {
1980 if templ_7745c5c3_Err != nil {2042 if templ_7745c5c3_Err != nil {
1981 return templ_7745c5c3_Err2043 return templ_7745c5c3_Err
1982 }2044 }
1983- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</tr>")2045+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</tr>")
1984 if templ_7745c5c3_Err != nil {2046 if templ_7745c5c3_Err != nil {
1985 return templ_7745c5c3_Err2047 return templ_7745c5c3_Err
1986 }2048 }
@@ -1991,7 +2053,7 @@ func DiffContext(c ContextRows) templ.Component {
1991 return templ_7745c5c3_Err2053 return templ_7745c5c3_Err
1992 }2054 }
1993 }2055 }
1994- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</tbody></table>")2056+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</tbody></table>")
1995 if templ_7745c5c3_Err != nil {2057 if templ_7745c5c3_Err != nil {
1996 return templ_7745c5c3_Err2058 return templ_7745c5c3_Err
1997 }2059 }
@@ -2017,36 +2079,36 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2017 }()2079 }()
2018 }2080 }
2019 ctx = templ.InitializeContext(ctx)2081 ctx = templ.InitializeContext(ctx)
2020- templ_7745c5c3_Var94 := templ.GetChildren(ctx)2082+ templ_7745c5c3_Var98 := templ.GetChildren(ctx)
2021- if templ_7745c5c3_Var94 == nil {2083+ if templ_7745c5c3_Var98 == nil {
2022- templ_7745c5c3_Var94 = templ.NopComponent2084+ templ_7745c5c3_Var98 = templ.NopComponent
2023 }2085 }
2024 ctx = templ.ClearChildren(ctx)2086 ctx = templ.ClearChildren(ctx)
2025 if ln == nil {2087 if ln == nil {
2026- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")2088+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
2027 if templ_7745c5c3_Err != nil {2089 if templ_7745c5c3_Err != nil {
2028 return templ_7745c5c3_Err2090 return templ_7745c5c3_Err
2029 }2091 }
2030 } else {2092 } else {
2031- var templ_7745c5c3_Var95 = []any{"ln", ln.Kind, templ.KV("sel", ln.Selected)}2093+ var templ_7745c5c3_Var99 = []any{"ln", ln.Kind, templ.KV("sel", ln.Selected)}
2032- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var95...)2094+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var99...)
2033 if templ_7745c5c3_Err != nil {2095 if templ_7745c5c3_Err != nil {
2034 return templ_7745c5c3_Err2096 return templ_7745c5c3_Err
2035 }2097 }
2036- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "<td class=\"")2098+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "<td class=\"")
2037 if templ_7745c5c3_Err != nil {2099 if templ_7745c5c3_Err != nil {
2038 return templ_7745c5c3_Err2100 return templ_7745c5c3_Err
2039 }2101 }
2040- var templ_7745c5c3_Var96 string2102+ var templ_7745c5c3_Var100 string
2041- templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var95).String())2103+ templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var99).String())
2042 if templ_7745c5c3_Err != nil {2104 if templ_7745c5c3_Err != nil {
2043 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}2105 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
2044 }2106 }
2045- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var96)2107+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var100)
2046 if templ_7745c5c3_Err != nil {2108 if templ_7745c5c3_Err != nil {
2047 return templ_7745c5c3_Err2109 return templ_7745c5c3_Err
2048 }2110 }
2049- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "\">")2111+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "\">")
2050 if templ_7745c5c3_Err != nil {2112 if templ_7745c5c3_Err != nil {
2051 return templ_7745c5c3_Err2113 return templ_7745c5c3_Err
2052 }2114 }
@@ -2061,64 +2123,64 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2061 return templ_7745c5c3_Err2123 return templ_7745c5c3_Err
2062 }2124 }
2063 }2125 }
2064- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</td>")2126+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "</td>")
2065 if templ_7745c5c3_Err != nil {2127 if templ_7745c5c3_Err != nil {
2066 return templ_7745c5c3_Err2128 return templ_7745c5c3_Err
2067 }2129 }
2068- var templ_7745c5c3_Var97 = []any{"m", ln.Kind, templ.KV("sel", ln.Selected)}2130+ var templ_7745c5c3_Var101 = []any{"m", ln.Kind, templ.KV("sel", ln.Selected)}
2069- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var97...)2131+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var101...)
2070 if templ_7745c5c3_Err != nil {2132 if templ_7745c5c3_Err != nil {
2071 return templ_7745c5c3_Err2133 return templ_7745c5c3_Err
2072 }2134 }
2073- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "<td class=\"")2135+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "<td class=\"")
2074 if templ_7745c5c3_Err != nil {2136 if templ_7745c5c3_Err != nil {
2075 return templ_7745c5c3_Err2137 return templ_7745c5c3_Err
2076 }2138 }
2077- var templ_7745c5c3_Var98 string2139+ var templ_7745c5c3_Var102 string
2078- templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var97).String())2140+ templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var101).String())
2079 if templ_7745c5c3_Err != nil {2141 if templ_7745c5c3_Err != nil {
2080 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}2142 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
2081 }2143 }
2082- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var98)2144+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var102)
2083 if templ_7745c5c3_Err != nil {2145 if templ_7745c5c3_Err != nil {
2084 return templ_7745c5c3_Err2146 return templ_7745c5c3_Err
2085 }2147 }
2086- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "\">")2148+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "\">")
2087 if templ_7745c5c3_Err != nil {2149 if templ_7745c5c3_Err != nil {
2088 return templ_7745c5c3_Err2150 return templ_7745c5c3_Err
2089 }2151 }
2090- var templ_7745c5c3_Var99 string2152+ var templ_7745c5c3_Var103 string
2091- templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)2153+ templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
2092 if templ_7745c5c3_Err != nil {2154 if templ_7745c5c3_Err != nil {
2093- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 401, Col: 68}2155+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 410, Col: 68}
2094 }2156 }
2095- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))2157+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
2096 if templ_7745c5c3_Err != nil {2158 if templ_7745c5c3_Err != nil {
2097 return templ_7745c5c3_Err2159 return templ_7745c5c3_Err
2098 }2160 }
2099- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "</td>")2161+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</td>")
2100 if templ_7745c5c3_Err != nil {2162 if templ_7745c5c3_Err != nil {
2101 return templ_7745c5c3_Err2163 return templ_7745c5c3_Err
2102 }2164 }
2103- var templ_7745c5c3_Var100 = []any{"tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected)}2165+ var templ_7745c5c3_Var104 = []any{"tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected)}
2104- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var100...)2166+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var104...)
2105 if templ_7745c5c3_Err != nil {2167 if templ_7745c5c3_Err != nil {
2106 return templ_7745c5c3_Err2168 return templ_7745c5c3_Err
2107 }2169 }
2108- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "<td class=\"")2170+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "<td class=\"")
2109 if templ_7745c5c3_Err != nil {2171 if templ_7745c5c3_Err != nil {
2110 return templ_7745c5c3_Err2172 return templ_7745c5c3_Err
2111 }2173 }
2112- var templ_7745c5c3_Var101 string2174+ var templ_7745c5c3_Var105 string
2113- templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var100).String())2175+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var104).String())
2114 if templ_7745c5c3_Err != nil {2176 if templ_7745c5c3_Err != nil {
2115 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}2177 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
2116 }2178 }
2117- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var101)2179+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var105)
2118 if templ_7745c5c3_Err != nil {2180 if templ_7745c5c3_Err != nil {
2119 return templ_7745c5c3_Err2181 return templ_7745c5c3_Err
2120 }2182 }
2121- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "\">")2183+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "\">")
2122 if templ_7745c5c3_Err != nil {2184 if templ_7745c5c3_Err != nil {
2123 return templ_7745c5c3_Err2185 return templ_7745c5c3_Err
2124 }2186 }
@@ -2126,7 +2188,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2126 if templ_7745c5c3_Err != nil {2188 if templ_7745c5c3_Err != nil {
2127 return templ_7745c5c3_Err2189 return templ_7745c5c3_Err
2128 }2190 }
2129- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "</td>")2191+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "</td>")
2130 if templ_7745c5c3_Err != nil {2192 if templ_7745c5c3_Err != nil {
2131 return templ_7745c5c3_Err2193 return templ_7745c5c3_Err
2132 }2194 }
@@ -2153,49 +2215,49 @@ func gutter(n, href string) templ.Component {
2153 }()2215 }()
2154 }2216 }
2155 ctx = templ.InitializeContext(ctx)2217 ctx = templ.InitializeContext(ctx)
2156- templ_7745c5c3_Var102 := templ.GetChildren(ctx)2218+ templ_7745c5c3_Var106 := templ.GetChildren(ctx)
2157- if templ_7745c5c3_Var102 == nil {2219+ if templ_7745c5c3_Var106 == nil {
2158- templ_7745c5c3_Var102 = templ.NopComponent2220+ templ_7745c5c3_Var106 = templ.NopComponent
2159 }2221 }
2160 ctx = templ.ClearChildren(ctx)2222 ctx = templ.ClearChildren(ctx)
2161 if href != "" {2223 if href != "" {
2162- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "<a href=\"")2224+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "<a href=\"")
2163 if templ_7745c5c3_Err != nil {2225 if templ_7745c5c3_Err != nil {
2164 return templ_7745c5c3_Err2226 return templ_7745c5c3_Err
2165 }2227 }
2166- var templ_7745c5c3_Var103 templ.SafeURL2228+ var templ_7745c5c3_Var107 templ.SafeURL
2167- templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))2229+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
2168 if templ_7745c5c3_Err != nil {2230 if templ_7745c5c3_Err != nil {
2169- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 410, Col: 31}2231+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 419, Col: 31}
2170 }2232 }
2171- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))2233+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
2172 if templ_7745c5c3_Err != nil {2234 if templ_7745c5c3_Err != nil {
2173 return templ_7745c5c3_Err2235 return templ_7745c5c3_Err
2174 }2236 }
2175- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "\" title=\"comment on this line\">")2237+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "\" title=\"comment on this line\">")
2176 if templ_7745c5c3_Err != nil {2238 if templ_7745c5c3_Err != nil {
2177 return templ_7745c5c3_Err2239 return templ_7745c5c3_Err
2178 }2240 }
2179- var templ_7745c5c3_Var104 string2241+ var templ_7745c5c3_Var108 string
2180- templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(n)2242+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2181 if templ_7745c5c3_Err != nil {2243 if templ_7745c5c3_Err != nil {
2182- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 410, Col: 66}2244+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 419, Col: 66}
2183 }2245 }
2184- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))2246+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
2185 if templ_7745c5c3_Err != nil {2247 if templ_7745c5c3_Err != nil {
2186 return templ_7745c5c3_Err2248 return templ_7745c5c3_Err
2187 }2249 }
2188- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</a>")2250+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "</a>")
2189 if templ_7745c5c3_Err != nil {2251 if templ_7745c5c3_Err != nil {
2190 return templ_7745c5c3_Err2252 return templ_7745c5c3_Err
2191 }2253 }
2192 } else {2254 } else {
2193- var templ_7745c5c3_Var105 string2255+ var templ_7745c5c3_Var109 string
2194- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(n)2256+ templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2195 if templ_7745c5c3_Err != nil {2257 if templ_7745c5c3_Err != nil {
2196- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 412, Col: 5}2258+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 421, Col: 5}
2197 }2259 }
2198- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))2260+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
2199 if templ_7745c5c3_Err != nil {2261 if templ_7745c5c3_Err != nil {
2200 return templ_7745c5c3_Err2262 return templ_7745c5c3_Err
2201 }2263 }
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 +156 -0
@@ -0,0 +1,156 @@
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() {} disconnect() {} };
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+});
99+
100+test('onSwap runs after a window came in', async () => {
101+ const b = placeholder('/w?from=25', false);
102+ try {
103+ await setup([b.ph]);
104+ let n = 0;
105+ window.ermiteLazy.onSwap(() => n++);
106+ await window.ermiteLazy.load(b.ph);
107+ assert.equal(n, 1);
108+ } finally { await settle(); restore(); }
109+});
110+
111+test('reset drops the response of a load in flight and lets the same window load again', async () => {
112+ const b = placeholder('/w?from=25', false);
113+ try {
114+ await setup([b.ph]);
115+ let release;
116+ const gate = new Promise((r) => { release = r; });
117+ let fetches = 0;
118+ globalThis.fetch = async () => { fetches++; await gate; return { status: 200, ok: true, text: async () => '' }; };
119+ let n = 0;
120+ window.ermiteLazy.onSwap(() => n++);
121+ const first = window.ermiteLazy.load(b.ph);
122+ window.ermiteLazy.reset();
123+ release();
124+ await first;
125+ assert.equal(n, 0, 'the old generation does not swap');
126+ await window.ermiteLazy.load(b.ph);
127+ assert.equal(fetches, 2);
128+ assert.equal(n, 1);
129+ } finally { await settle(); restore(); }
130+});
131+
132+test('reset clears the stale state; watch disconnects the previous observer', async () => {
133+ const b = placeholder('/w?from=25', false);
134+ try {
135+ await setup([b.ph]);
136+ const made = [];
137+ globalThis.IntersectionObserver = class {
138+ constructor() { this.off = false; made.push(this); }
139+ observe() {} unobserve() {} disconnect() { this.off = true; }
140+ };
141+ globalThis.fetch = async () => ({ status: 409, ok: false, text: async () => 'moved' });
142+ await window.ermiteLazy.load(b.ph);
143+ const urls = [];
144+ globalThis.fetch = async (u) => { urls.push(u); return { status: 200, ok: true, text: async () => '' }; };
145+ await window.ermiteLazy.load(b.ph);
146+ assert.deepEqual(urls, [], 'stale: nothing loads');
147+ window.ermiteLazy.watch();
148+ window.ermiteLazy.watch();
149+ assert.equal(made.length, 2);
150+ assert.ok(made[0].off && !made[1].off);
151+ window.ermiteLazy.reset();
152+ assert.ok(made[1].off);
153+ await window.ermiteLazy.load(b.ph);
154+ assert.equal(urls.length, 1);
155+ } finally { await settle(); restore(); }
156+});
@@ -0,0 +1,156 @@
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() {} disconnect() {} };
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+});
99+
100+test('onSwap runs after a window came in', async () => {
101+ const b = placeholder('/w?from=25', false);
102+ try {
103+ await setup([b.ph]);
104+ let n = 0;
105+ window.ermiteLazy.onSwap(() => n++);
106+ await window.ermiteLazy.load(b.ph);
107+ assert.equal(n, 1);
108+ } finally { await settle(); restore(); }
109+});
110+
111+test('reset drops the response of a load in flight and lets the same window load again', async () => {
112+ const b = placeholder('/w?from=25', false);
113+ try {
114+ await setup([b.ph]);
115+ let release;
116+ const gate = new Promise((r) => { release = r; });
117+ let fetches = 0;
118+ globalThis.fetch = async () => { fetches++; await gate; return { status: 200, ok: true, text: async () => '' }; };
119+ let n = 0;
120+ window.ermiteLazy.onSwap(() => n++);
121+ const first = window.ermiteLazy.load(b.ph);
122+ window.ermiteLazy.reset();
123+ release();
124+ await first;
125+ assert.equal(n, 0, 'the old generation does not swap');
126+ await window.ermiteLazy.load(b.ph);
127+ assert.equal(fetches, 2);
128+ assert.equal(n, 1);
129+ } finally { await settle(); restore(); }
130+});
131+
132+test('reset clears the stale state; watch disconnects the previous observer', async () => {
133+ const b = placeholder('/w?from=25', false);
134+ try {
135+ await setup([b.ph]);
136+ const made = [];
137+ globalThis.IntersectionObserver = class {
138+ constructor() { this.off = false; made.push(this); }
139+ observe() {} unobserve() {} disconnect() { this.off = true; }
140+ };
141+ globalThis.fetch = async () => ({ status: 409, ok: false, text: async () => 'moved' });
142+ await window.ermiteLazy.load(b.ph);
143+ const urls = [];
144+ globalThis.fetch = async (u) => { urls.push(u); return { status: 200, ok: true, text: async () => '' }; };
145+ await window.ermiteLazy.load(b.ph);
146+ assert.deepEqual(urls, [], 'stale: nothing loads');
147+ window.ermiteLazy.watch();
148+ window.ermiteLazy.watch();
149+ assert.equal(made.length, 2);
150+ assert.ok(made[0].off && !made[1].off);
151+ window.ermiteLazy.reset();
152+ assert.ok(made[1].off);
153+ await window.ermiteLazy.load(b.ph);
154+ assert.equal(urls.length, 1);
155+ } finally { await settle(); restore(); }
156+});
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
@@ -569,6 +569,19 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
569569 if from > 0 && from >= len(res.Diff.Files) {
570570 return nil, 0, errNotFound
571571 }
572+ // A page without scripts: ?file=i reads that collapsed file in full.
573+ if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() {
574+ one, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
575+ SinceLastRead: since, DiffOnly: true, Base: res.DiffBaseID, Head: res.MergeRequest.HeadID,
576+ Files: &ports.FileWindow{From: i, To: i + 1, Full: true},
577+ })
578+ if err != nil {
579+ return nil, 0, err
580+ }
581+ if i < len(one.Diff.Files) {
582+ res.Diff.Files[i] = one.Diff.Files[i]
583+ }
584+ }
572585 m := res.MergeRequest
573586 signed := SignedIn(ctx)
574587 open := m.State == domain.MRStateOpen
@@ -590,6 +603,11 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
590603 Files: s.hl.files(ctx, res.Diff.Files),
591604 }
592605 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
606+ src := ""
607+ if !res.DiffBaseID.IsZero() {
608+ src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)
609+ }
610+ collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head})
593611 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
594612 return nil, 0, err
595613 }
@@ -649,7 +667,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
649667 p.Live = true
650668 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))
651669 l.HasRail = true
652- l.MR = true
670+ l.MR, l.Lazy = true, true
653671 return MergeRequest(l, p), http.StatusOK, nil
654672 }
655673
@@ -569,6 +569,19 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
569 if from > 0 && from >= len(res.Diff.Files) {569 if from > 0 && from >= len(res.Diff.Files) {
570 return nil, 0, errNotFound570 return nil, 0, errNotFound
571 }571 }
572+ // A page without scripts: ?file=i reads that collapsed file in full.
573+ if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() {
574+ one, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
575+ SinceLastRead: since, DiffOnly: true, Base: res.DiffBaseID, Head: res.MergeRequest.HeadID,
576+ Files: &ports.FileWindow{From: i, To: i + 1, Full: true},
577+ })
578+ if err != nil {
579+ return nil, 0, err
580+ }
581+ if i < len(one.Diff.Files) {
582+ res.Diff.Files[i] = one.Diff.Files[i]
583+ }
584+ }
572 m := res.MergeRequest585 m := res.MergeRequest
573 signed := SignedIn(ctx)586 signed := SignedIn(ctx)
574 open := m.State == domain.MRStateOpen587 open := m.State == domain.MRStateOpen
@@ -590,6 +603,11 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
590 Files: s.hl.files(ctx, res.Diff.Files),603 Files: s.hl.files(ctx, res.Diff.Files),
591 }604 }
592 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))605 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
606+ src := ""
607+ if !res.DiffBaseID.IsZero() {
608+ src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)
609+ }
610+ collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head})
593 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {611 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
594 return nil, 0, err612 return nil, 0, err
595 }613 }
@@ -649,7 +667,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
649 p.Live = true667 p.Live = true
650 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))668 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))
651 l.HasRail = true669 l.HasRail = true
652- l.MR = true670+ l.MR, l.Lazy = true, true
653 return MergeRequest(l, p), http.StatusOK, nil671 return MergeRequest(l, p), http.StatusOK, nil
654 }672 }
655 673
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
@@ -191,7 +191,9 @@
191191 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }
192192 /* with scripts the window comes by itself: the link of the page without
193193 scripts would only flash */
194- html.js & > a { visibility: hidden; }
194+ html.js &:not([data-manual]) > a { visibility: hidden; }
195+ &[data-manual] { height: auto; padding: var(--s3); }
196+ &[data-manual][data-loading] > a { opacity: .5; }
195197 & .notice { margin: 0; }
196198 }
197199 .dloose {
@@ -191,7 +191,9 @@
191 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }191 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }
192 /* with scripts the window comes by itself: the link of the page without192 /* with scripts the window comes by itself: the link of the page without
193 scripts would only flash */193 scripts would only flash */
194- html.js & > a { visibility: hidden; }194+ html.js &:not([data-manual]) > a { visibility: hidden; }
195+ &[data-manual] { height: auto; padding: var(--s3); }
196+ &[data-manual][data-loading] > a { opacity: .5; }
195 & .notice { margin: 0; }197 & .notice { margin: 0; }
196 }198 }
197 .dloose {199 .dloose {
A internal/adapters/web/static/js/lazy.js +166 -0
@@ -0,0 +1,166 @@
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+ let generation = 0; // bumped by reset: older responses are ignored
24+ let onSwap = () => {};
25+
26+ // load fetches the fragment of a placeholder and swaps its files in. The
27+ // promise resolves once they are in the page (or the page said to reload).
28+ function load(ph) {
29+ const gen = generation;
30+ const src = ph?.dataset.src;
31+ if (!src || moved) return Promise.resolve();
32+ if (loading.has(src)) return loading.get(src);
33+ for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
34+ // The fragment renders like the page: the layout shown, and both layouts
35+ // when the page switches them itself (data-diff) or one (data-view).
36+ const d = $('#diffs')?.dataset || {};
37+ const view = d.diff || d.view;
38+ const layouts = d.diff ? 'both' : d.view ? 'one' : '';
39+ const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
40+ const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
41+ .then(async (res) => {
42+ const html = await res.text();
43+ if (gen !== generation) return;
44+ if (res.status === 409) return stale(html);
45+ if (!res.ok) throw new Error('loading files: ' + res.status);
46+ // A fragment of the old diff after the page went stale stays out.
47+ if (!moved) swap(html);
48+ })
49+ .catch((err) => {
50+ if (gen !== generation) return;
51+ loading.delete(src);
52+ for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
53+ window.ermite?.flash?.(err.message, 4000);
54+ });
55+ loading.set(src, p);
56+ return p;
57+ }
58+
59+ // swap replaces each placeholder file with the file of the same id. A
60+ // file the reader folded stays folded.
61+ function swap(html) {
62+ const t = document.createElement('template');
63+ t.innerHTML = html;
64+ for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
65+ const old = document.getElementById(f.id);
66+ if (!old || !old.querySelector('.dlazy')) continue;
67+ // A collapsed file opens once loaded; a file of a window keeps the fold.
68+ f.open = old.querySelector('.dlazy[data-manual]') ? true : old.open;
69+ for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
70+ old.replaceWith(f);
71+ window.htmx?.process(f);
72+ window.ermite?.watchFile?.(f, old);
73+ }
74+ onSwap();
75+ }
76+
77+ // stale: the diff changed under the page. The placeholders say so; no file
78+ // of another diff comes in.
79+ function stale(html) {
80+ moved = true;
81+ for (const p of $$('.dlazy')) {
82+ p.removeAttribute('data-loading');
83+ p.innerHTML = html;
84+ }
85+ }
86+
87+ // Automatic placeholders load as they come within two screens of the view;
88+ // files that come in are marked in the list of files (ermite.js marks the
89+ // one in view). Manual ones are left alone.
90+ function watch() {
91+ lazyIO?.disconnect();
92+ if (!('IntersectionObserver' in window)) {
93+ for (const p of $$(AUTO)) load(p);
94+ return;
95+ }
96+ lazyIO = new IntersectionObserver((entries) => {
97+ for (const e of entries) {
98+ if (!e.isIntersecting) continue;
99+ lazyIO.unobserve(e.target);
100+ load(e.target);
101+ }
102+ }, { rootMargin: '200% 0px 200% 0px' });
103+ for (const p of $$(AUTO)) lazyIO.observe(p);
104+ }
105+
106+ // reset forgets the loads in flight and the stale state: the page shows
107+ // another diff (refresh in place). watch starts the observer again.
108+ function reset() {
109+ generation++;
110+ moved = false;
111+ loading.clear();
112+ lazyIO?.disconnect();
113+ }
114+
115+ // manualLink is the "load diff" link of an event target, when it is one that
116+ // loads in place (the placeholder has an address).
117+ const manualLink = (t) => {
118+ const a = t?.closest?.('.dlazy[data-manual] > a');
119+ return a && a.closest('.dlazy')?.dataset.src ? a : null;
120+ };
121+
122+ // loadManual loads the file of a "load diff" link and puts the focus in it:
123+ // on its first line link, else on its header. The scroll never moves for it.
124+ function loadManual(a) {
125+ const ph = a.closest('.dlazy');
126+ const id = ph.closest('details')?.id;
127+ return load(ph).then(() => {
128+ const f = id && document.getElementById(id);
129+ if (!f || f.querySelector('.dlazy[data-manual]')) return;
130+ (f.querySelector('.ln a') || f.querySelector('summary'))?.focus({ preventScroll: true });
131+ });
132+ }
133+
134+ function onClick(e) {
135+ if (e.defaultPrevented || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;
136+ const a = manualLink(e.target);
137+ if (!a) return false;
138+ e.preventDefault();
139+ loadManual(a);
140+ return true;
141+ }
142+ // Enter makes the browser click a link by itself; Space does not. Both are
143+ // handled here, once (a fragment is fetched once).
144+ function onKey(e) {
145+ if ((e.key !== 'Enter' && e.key !== ' ') || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false;
146+ const a = manualLink(e.target);
147+ if (!a) return false;
148+ e.preventDefault();
149+ loadManual(a);
150+ return true;
151+ }
152+
153+ let started = false;
154+ function start() {
155+ if (started || !$('#diffs')) return;
156+ started = true;
157+ watch();
158+ document.addEventListener('click', onClick);
159+ document.addEventListener('keydown', onKey);
160+ }
161+
162+ return { load, start, watch, reset, onSwap: (fn) => { onSwap = fn; }, onClick, onKey };
163+})();
164+
165+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteLazy.start());
166+else window.ermiteLazy.start();
@@ -0,0 +1,166 @@
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+ let generation = 0; // bumped by reset: older responses are ignored
24+ let onSwap = () => {};
25+
26+ // load fetches the fragment of a placeholder and swaps its files in. The
27+ // promise resolves once they are in the page (or the page said to reload).
28+ function load(ph) {
29+ const gen = generation;
30+ const src = ph?.dataset.src;
31+ if (!src || moved) return Promise.resolve();
32+ if (loading.has(src)) return loading.get(src);
33+ for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
34+ // The fragment renders like the page: the layout shown, and both layouts
35+ // when the page switches them itself (data-diff) or one (data-view).
36+ const d = $('#diffs')?.dataset || {};
37+ const view = d.diff || d.view;
38+ const layouts = d.diff ? 'both' : d.view ? 'one' : '';
39+ const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
40+ const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
41+ .then(async (res) => {
42+ const html = await res.text();
43+ if (gen !== generation) return;
44+ if (res.status === 409) return stale(html);
45+ if (!res.ok) throw new Error('loading files: ' + res.status);
46+ // A fragment of the old diff after the page went stale stays out.
47+ if (!moved) swap(html);
48+ })
49+ .catch((err) => {
50+ if (gen !== generation) return;
51+ loading.delete(src);
52+ for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
53+ window.ermite?.flash?.(err.message, 4000);
54+ });
55+ loading.set(src, p);
56+ return p;
57+ }
58+
59+ // swap replaces each placeholder file with the file of the same id. A
60+ // file the reader folded stays folded.
61+ function swap(html) {
62+ const t = document.createElement('template');
63+ t.innerHTML = html;
64+ for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
65+ const old = document.getElementById(f.id);
66+ if (!old || !old.querySelector('.dlazy')) continue;
67+ // A collapsed file opens once loaded; a file of a window keeps the fold.
68+ f.open = old.querySelector('.dlazy[data-manual]') ? true : old.open;
69+ for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
70+ old.replaceWith(f);
71+ window.htmx?.process(f);
72+ window.ermite?.watchFile?.(f, old);
73+ }
74+ onSwap();
75+ }
76+
77+ // stale: the diff changed under the page. The placeholders say so; no file
78+ // of another diff comes in.
79+ function stale(html) {
80+ moved = true;
81+ for (const p of $$('.dlazy')) {
82+ p.removeAttribute('data-loading');
83+ p.innerHTML = html;
84+ }
85+ }
86+
87+ // Automatic placeholders load as they come within two screens of the view;
88+ // files that come in are marked in the list of files (ermite.js marks the
89+ // one in view). Manual ones are left alone.
90+ function watch() {
91+ lazyIO?.disconnect();
92+ if (!('IntersectionObserver' in window)) {
93+ for (const p of $$(AUTO)) load(p);
94+ return;
95+ }
96+ lazyIO = new IntersectionObserver((entries) => {
97+ for (const e of entries) {
98+ if (!e.isIntersecting) continue;
99+ lazyIO.unobserve(e.target);
100+ load(e.target);
101+ }
102+ }, { rootMargin: '200% 0px 200% 0px' });
103+ for (const p of $$(AUTO)) lazyIO.observe(p);
104+ }
105+
106+ // reset forgets the loads in flight and the stale state: the page shows
107+ // another diff (refresh in place). watch starts the observer again.
108+ function reset() {
109+ generation++;
110+ moved = false;
111+ loading.clear();
112+ lazyIO?.disconnect();
113+ }
114+
115+ // manualLink is the "load diff" link of an event target, when it is one that
116+ // loads in place (the placeholder has an address).
117+ const manualLink = (t) => {
118+ const a = t?.closest?.('.dlazy[data-manual] > a');
119+ return a && a.closest('.dlazy')?.dataset.src ? a : null;
120+ };
121+
122+ // loadManual loads the file of a "load diff" link and puts the focus in it:
123+ // on its first line link, else on its header. The scroll never moves for it.
124+ function loadManual(a) {
125+ const ph = a.closest('.dlazy');
126+ const id = ph.closest('details')?.id;
127+ return load(ph).then(() => {
128+ const f = id && document.getElementById(id);
129+ if (!f || f.querySelector('.dlazy[data-manual]')) return;
130+ (f.querySelector('.ln a') || f.querySelector('summary'))?.focus({ preventScroll: true });
131+ });
132+ }
133+
134+ function onClick(e) {
135+ if (e.defaultPrevented || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;
136+ const a = manualLink(e.target);
137+ if (!a) return false;
138+ e.preventDefault();
139+ loadManual(a);
140+ return true;
141+ }
142+ // Enter makes the browser click a link by itself; Space does not. Both are
143+ // handled here, once (a fragment is fetched once).
144+ function onKey(e) {
145+ if ((e.key !== 'Enter' && e.key !== ' ') || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false;
146+ const a = manualLink(e.target);
147+ if (!a) return false;
148+ e.preventDefault();
149+ loadManual(a);
150+ return true;
151+ }
152+
153+ let started = false;
154+ function start() {
155+ if (started || !$('#diffs')) return;
156+ started = true;
157+ watch();
158+ document.addEventListener('click', onClick);
159+ document.addEventListener('keydown', onKey);
160+ }
161+
162+ return { load, start, watch, reset, onSwap: (fn) => { onSwap = fn; }, onClick, onKey };
163+})();
164+
165+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteLazy.start());
166+else window.ermiteLazy.start();
M internal/adapters/web/static/js/mr.js +8 -91
@@ -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.
@@ -80,72 +79,10 @@ window.ermiteMR = (() => {
8079
8180 const $ = (sel, root = document) => root.querySelector(sel);
8281 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
83- const loading = new Map(); // src -> promise of the swap
8482 let refreshTouchComments = () => {};
85- let moved = false;
86- let lazyIO = null; // the observer of the placeholders (watch)
87- let diffGeneration = 0;
8883 let revealGen = 0; // the last reveal wins the scroll
89-
90- // load fetches the window of a placeholder and swaps its files in. The
91- // promise resolves once they are in the page (or the page said to reload).
92- function load(ph) {
93- const generation = diffGeneration;
94- const src = ph?.dataset.src;
95- if (!src || moved) return Promise.resolve();
96- if (loading.has(src)) return loading.get(src);
97- for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
98- // The window renders like the page: the layout shown, and both layouts
99- // when the page switches them itself (data-diff) or one (data-view).
100- const d = $('#diffs')?.dataset || {};
101- const view = d.diff || d.view;
102- const layouts = d.diff ? 'both' : d.view ? 'one' : '';
103- const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
104- const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
105- .then(async (res) => {
106- const html = await res.text();
107- if (generation !== diffGeneration) return;
108- if (res.status === 409) return stale(html);
109- if (!res.ok) throw new Error('loading files: ' + res.status);
110- // A window of the old diff after the page went stale stays out.
111- if (!moved) swap(html);
112- })
113- .catch((err) => {
114- if (generation !== diffGeneration) return;
115- loading.delete(src);
116- for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
117- window.ermite?.flash?.(err.message, 4000);
118- });
119- loading.set(src, p);
120- return p;
121- }
122-
123- // swap replaces each placeholder file with the file of the same id. A
124- // file the reader folded stays folded.
125- function swap(html) {
126- const t = document.createElement('template');
127- t.innerHTML = html;
128- for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
129- const old = document.getElementById(f.id);
130- if (!old || !old.querySelector('.dlazy')) continue;
131- f.open = old.open;
132- for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
133- old.replaceWith(f);
134- window.htmx?.process(f);
135- window.ermite?.watchFile?.(f, old);
136- }
137- refreshTouchComments();
138- }
139-
140- // stale: the diff changed under the page. The placeholders say so; no file
141- // of another diff comes in.
142- function stale(html) {
143- moved = true;
144- for (const p of $$('.dlazy')) {
145- p.removeAttribute('data-loading');
146- p.innerHTML = html;
147- }
148- }
84+ const load = (ph) => window.ermiteLazy.load(ph);
85+ window.ermiteLazy.onSwap(() => refreshTouchComments());
14986
15087 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
15188 // a placeholder, then scrolls to it. It returns false when there is nothing
@@ -163,7 +100,7 @@ window.ermiteMR = (() => {
163100 }
164101 return document.getElementById(id);
165102 };
166- let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
103+ let ph = document.getElementById(id)?.querySelector(':scope > .dlazy:not([data-manual])');
167104 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
168105 const base = id.replace(/-s$/, '');
169106 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
@@ -195,25 +132,6 @@ window.ermiteMR = (() => {
195132 return true;
196133 }
197134
198- // Placeholders load as they come within two screens of the view; files
199- // that come in are marked in the list of files (ermite.js marks the one in
200- // view).
201- function watch() {
202- lazyIO?.disconnect();
203- if (!('IntersectionObserver' in window)) {
204- for (const p of $$('.dlazy[data-src]')) load(p);
205- return;
206- }
207- lazyIO = new IntersectionObserver((entries) => {
208- for (const e of entries) {
209- if (!e.isIntersecting) continue;
210- lazyIO.unobserve(e.target);
211- load(e.target);
212- }
213- }, { rootMargin: '200% 0px 200% 0px' });
214- for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
215- }
216-
217135 // The merge menu.
218136 const menu = () => $('#merge-menu');
219137 function openMerge() {
@@ -598,7 +516,7 @@ window.ermiteMR = (() => {
598516 const active = document.activeElement;
599517 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
600518 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
601- ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();
519+ ++revealGen; window.ermiteLazy.reset();
602520 $('#diffs', page).dataset.mrStarted = 'true';
603521 // The fresh footer comes in with the panels as they are, so that an open
604522 // panel does not fold and open again while the other regions swap.
@@ -624,7 +542,7 @@ window.ermiteMR = (() => {
624542 touchComments(false);
625543 $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);
626544 window.ermite?.refreshFiles?.();
627- watch();
545+ window.ermiteLazy.watch();
628546 if (focus && active !== document.body) {
629547 const region = focus.region ? $(focus.region) : document;
630548 const menu = focus.menu && document.getElementById(focus.menu);
@@ -700,7 +618,6 @@ window.ermiteMR = (() => {
700618 // the address of the request, so that a reload does not post again.
701619 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
702620 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
703- watch();
704621 // A link to a file or a comment that is not loaded yet.
705622 const hash = () => {
706623 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.
@@ -80,72 +79,10 @@ window.ermiteMR = (() => {
80 79
81 const $ = (sel, root = document) => root.querySelector(sel);80 const $ = (sel, root = document) => root.querySelector(sel);
82 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];81 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
83- const loading = new Map(); // src -> promise of the swap
84 let refreshTouchComments = () => {};82 let refreshTouchComments = () => {};
85- let moved = false;
86- let lazyIO = null; // the observer of the placeholders (watch)
87- let diffGeneration = 0;
88 let revealGen = 0; // the last reveal wins the scroll83 let revealGen = 0; // the last reveal wins the scroll
89-84+ const load = (ph) => window.ermiteLazy.load(ph);
90- // load fetches the window of a placeholder and swaps its files in. The85+ window.ermiteLazy.onSwap(() => refreshTouchComments());
91- // promise resolves once they are in the page (or the page said to reload).
92- function load(ph) {
93- const generation = diffGeneration;
94- const src = ph?.dataset.src;
95- if (!src || moved) return Promise.resolve();
96- if (loading.has(src)) return loading.get(src);
97- for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
98- // The window renders like the page: the layout shown, and both layouts
99- // when the page switches them itself (data-diff) or one (data-view).
100- const d = $('#diffs')?.dataset || {};
101- const view = d.diff || d.view;
102- const layouts = d.diff ? 'both' : d.view ? 'one' : '';
103- const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
104- const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
105- .then(async (res) => {
106- const html = await res.text();
107- if (generation !== diffGeneration) return;
108- if (res.status === 409) return stale(html);
109- if (!res.ok) throw new Error('loading files: ' + res.status);
110- // A window of the old diff after the page went stale stays out.
111- if (!moved) swap(html);
112- })
113- .catch((err) => {
114- if (generation !== diffGeneration) return;
115- loading.delete(src);
116- for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
117- window.ermite?.flash?.(err.message, 4000);
118- });
119- loading.set(src, p);
120- return p;
121- }
122-
123- // swap replaces each placeholder file with the file of the same id. A
124- // file the reader folded stays folded.
125- function swap(html) {
126- const t = document.createElement('template');
127- t.innerHTML = html;
128- for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
129- const old = document.getElementById(f.id);
130- if (!old || !old.querySelector('.dlazy')) continue;
131- f.open = old.open;
132- for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
133- old.replaceWith(f);
134- window.htmx?.process(f);
135- window.ermite?.watchFile?.(f, old);
136- }
137- refreshTouchComments();
138- }
139-
140- // stale: the diff changed under the page. The placeholders say so; no file
141- // of another diff comes in.
142- function stale(html) {
143- moved = true;
144- for (const p of $$('.dlazy')) {
145- p.removeAttribute('data-loading');
146- p.innerHTML = html;
147- }
148- }
149 86
150 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is87 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
151 // a placeholder, then scrolls to it. It returns false when there is nothing88 // a placeholder, then scrolls to it. It returns false when there is nothing
@@ -163,7 +100,7 @@ window.ermiteMR = (() => {
163 }100 }
164 return document.getElementById(id);101 return document.getElementById(id);
165 };102 };
166- let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');103+ let ph = document.getElementById(id)?.querySelector(':scope > .dlazy:not([data-manual])');
167 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {104 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
168 const base = id.replace(/-s$/, '');105 const base = id.replace(/-s$/, '');
169 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));106 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
@@ -195,25 +132,6 @@ window.ermiteMR = (() => {
195 return true;132 return true;
196 }133 }
197 134
198- // Placeholders load as they come within two screens of the view; files
199- // that come in are marked in the list of files (ermite.js marks the one in
200- // view).
201- function watch() {
202- lazyIO?.disconnect();
203- if (!('IntersectionObserver' in window)) {
204- for (const p of $$('.dlazy[data-src]')) load(p);
205- return;
206- }
207- lazyIO = new IntersectionObserver((entries) => {
208- for (const e of entries) {
209- if (!e.isIntersecting) continue;
210- lazyIO.unobserve(e.target);
211- load(e.target);
212- }
213- }, { rootMargin: '200% 0px 200% 0px' });
214- for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
215- }
216-
217 // The merge menu.135 // The merge menu.
218 const menu = () => $('#merge-menu');136 const menu = () => $('#merge-menu');
219 function openMerge() {137 function openMerge() {
@@ -598,7 +516,7 @@ window.ermiteMR = (() => {
598 const active = document.activeElement;516 const active = document.activeElement;
599 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };517 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
600 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);518 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
601- ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();519+ ++revealGen; window.ermiteLazy.reset();
602 $('#diffs', page).dataset.mrStarted = 'true';520 $('#diffs', page).dataset.mrStarted = 'true';
603 // The fresh footer comes in with the panels as they are, so that an open521 // The fresh footer comes in with the panels as they are, so that an open
604 // panel does not fold and open again while the other regions swap.522 // panel does not fold and open again while the other regions swap.
@@ -624,7 +542,7 @@ window.ermiteMR = (() => {
624 touchComments(false);542 touchComments(false);
625 $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);543 $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);
626 window.ermite?.refreshFiles?.();544 window.ermite?.refreshFiles?.();
627- watch();545+ window.ermiteLazy.watch();
628 if (focus && active !== document.body) {546 if (focus && active !== document.body) {
629 const region = focus.region ? $(focus.region) : document;547 const region = focus.region ? $(focus.region) : document;
630 const menu = focus.menu && document.getElementById(focus.menu);548 const menu = focus.menu && document.getElementById(focus.menu);
@@ -700,7 +618,6 @@ window.ermiteMR = (() => {
700 // the address of the request, so that a reload does not post again.618 // the address of the request, so that a reload does not post again.
701 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;619 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
702 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);620 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
703- watch();
704 // A link to a file or a comment that is not loaded yet.621 // A link to a file or a comment that is not loaded yet.
705 const hash = () => {622 const hash = () => {
706 const id = decodeURIComponent(location.hash.slice(1));623 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
@@ -921,3 +921,52 @@ func TestWebMergeRequestMergeabilityLive(t *testing.T) {
921921 break
922922 }
923923 }
924+
925+// Collapsed files of a merge request: a generated file and a file over the
926+// line cap are listed closed, with no rows and a manual placeholder; /files
927+// with one=1 reads exactly one file in full, whatever its index; the page
928+// without scripts (?file=i) splices the file in.
929+func TestWebMergeRequestCollapsedFiles(t *testing.T) {
930+ r := newMRRepo(t, domain.VisibilityPublic)
931+ b := browser{r.harness}
932+ var big strings.Builder
933+ for i := range 2600 {
934+ fmt.Fprintf(&big, "row %d\n", i)
935+ }
936+ r.writeFile(r.wt, "zz/big.txt", []byte(big.String()))
937+ r.writeFile(r.wt, "vendor/lib/gen.txt", []byte("generated line\n"))
938+ r.writeFile(r.wt, "zz/small.txt", []byte("small line\n"))
939+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
940+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Collapsed").ok(t)
941+
942+ page := b.get("/proj/merge-requests/1", nil)
943+ require.Equal(t, 200, page.status)
944+ assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file")
945+ assert.Contains(t, page.body, "generated file: collapsed")
946+ assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed")
947+ assert.NotContains(t, page.body, "row 5<", "no rows for a collapsed file")
948+ assert.Contains(t, page.body, "small line")
949+ assert.NotContains(t, page.body, "this diff is incomplete")
950+ srcs := regexp.MustCompile(`data-src="([^"]*one=1&amp;from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1)
951+ require.Len(t, srcs, 2)
952+
953+ for _, m := range srcs {
954+ one := b.get(html.UnescapeString(m[1]), nil)
955+ require.Equal(t, 200, one.status, one.body)
956+ assert.Equal(t, 1, strings.Count(one.body, `<details class="dfile`), "exactly one file")
957+ assert.NotContains(t, one.body, "data-manual")
958+ }
959+ bigOne := b.get(html.UnescapeString(srcs[1][1]), nil)
960+ genOne := b.get(html.UnescapeString(srcs[0][1]), nil)
961+ assert.True(t, strings.Contains(bigOne.body, "row 2599") != strings.Contains(genOne.body, "row 2599"))
962+ assert.Contains(t, bigOne.body+genOne.body, "generated line")
963+
964+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=999", 1), nil).status)
965+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=-1", 1), nil).status)
966+
967+ for _, m := range srcs {
968+ own := b.get("/proj/merge-requests/1?file="+m[2], nil)
969+ require.Equal(t, 200, own.status)
970+ assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in")
971+ }
972+}
@@ -921,3 +921,52 @@ func TestWebMergeRequestMergeabilityLive(t *testing.T) {
921 break921 break
922 }922 }
923 }923 }
924+
925+// Collapsed files of a merge request: a generated file and a file over the
926+// line cap are listed closed, with no rows and a manual placeholder; /files
927+// with one=1 reads exactly one file in full, whatever its index; the page
928+// without scripts (?file=i) splices the file in.
929+func TestWebMergeRequestCollapsedFiles(t *testing.T) {
930+ r := newMRRepo(t, domain.VisibilityPublic)
931+ b := browser{r.harness}
932+ var big strings.Builder
933+ for i := range 2600 {
934+ fmt.Fprintf(&big, "row %d\n", i)
935+ }
936+ r.writeFile(r.wt, "zz/big.txt", []byte(big.String()))
937+ r.writeFile(r.wt, "vendor/lib/gen.txt", []byte("generated line\n"))
938+ r.writeFile(r.wt, "zz/small.txt", []byte("small line\n"))
939+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
940+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Collapsed").ok(t)
941+
942+ page := b.get("/proj/merge-requests/1", nil)
943+ require.Equal(t, 200, page.status)
944+ assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file")
945+ assert.Contains(t, page.body, "generated file: collapsed")
946+ assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed")
947+ assert.NotContains(t, page.body, "row 5<", "no rows for a collapsed file")
948+ assert.Contains(t, page.body, "small line")
949+ assert.NotContains(t, page.body, "this diff is incomplete")
950+ srcs := regexp.MustCompile(`data-src="([^"]*one=1&amp;from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1)
951+ require.Len(t, srcs, 2)
952+
953+ for _, m := range srcs {
954+ one := b.get(html.UnescapeString(m[1]), nil)
955+ require.Equal(t, 200, one.status, one.body)
956+ assert.Equal(t, 1, strings.Count(one.body, `<details class="dfile`), "exactly one file")
957+ assert.NotContains(t, one.body, "data-manual")
958+ }
959+ bigOne := b.get(html.UnescapeString(srcs[1][1]), nil)
960+ genOne := b.get(html.UnescapeString(srcs[0][1]), nil)
961+ assert.True(t, strings.Contains(bigOne.body, "row 2599") != strings.Contains(genOne.body, "row 2599"))
962+ assert.Contains(t, bigOne.body+genOne.body, "generated line")
963+
964+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=999", 1), nil).status)
965+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=-1", 1), nil).status)
966+
967+ for _, m := range srcs {
968+ own := b.get("/proj/merge-requests/1?file="+m[2], nil)
969+ require.Equal(t, 200, own.status)
970+ assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in")
971+ }
972+}

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