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 generated file: collapsed
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
@@ -220,15 +220,15 @@ func Shell(l Layout, rail templ.Component) templ.Component {
220220 if templ_7745c5c3_Err != nil {
221221 return templ_7745c5c3_Err
222222 }
223- if l.MR {
223+ if l.Lazy {
224224 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<script src=\"")
225225 if templ_7745c5c3_Err != nil {
226226 return templ_7745c5c3_Err
227227 }
228228 var templ_7745c5c3_Var16 string
229- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.MR)
229+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Lazy)
230230 if templ_7745c5c3_Err != nil {
231- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 34, Col: 29}
231+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 34, Col: 31}
232232 }
233233 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
234234 if templ_7745c5c3_Err != nil {
@@ -239,15 +239,15 @@ func Shell(l Layout, rail templ.Component) templ.Component {
239239 return templ_7745c5c3_Err
240240 }
241241 }
242- if l.IssueForm {
242+ if l.MR {
243243 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<script src=\"")
244244 if templ_7745c5c3_Err != nil {
245245 return templ_7745c5c3_Err
246246 }
247247 var templ_7745c5c3_Var17 string
248- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.IssueForm)
248+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.MR)
249249 if templ_7745c5c3_Err != nil {
250- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 37, Col: 36}
250+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 37, Col: 29}
251251 }
252252 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
253253 if templ_7745c5c3_Err != nil {
@@ -258,69 +258,88 @@ func Shell(l Layout, rail templ.Component) templ.Component {
258258 return templ_7745c5c3_Err
259259 }
260260 }
261- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</head><body data-ref=\"")
261+ if l.IssueForm {
262+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<script src=\"")
263+ if templ_7745c5c3_Err != nil {
264+ return templ_7745c5c3_Err
265+ }
266+ var templ_7745c5c3_Var18 string
267+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.IssueForm)
268+ if templ_7745c5c3_Err != nil {
269+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 40, Col: 36}
270+ }
271+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
272+ if templ_7745c5c3_Err != nil {
273+ return templ_7745c5c3_Err
274+ }
275+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" defer></script>")
276+ if templ_7745c5c3_Err != nil {
277+ return templ_7745c5c3_Err
278+ }
279+ }
280+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</head><body data-ref=\"")
262281 if templ_7745c5c3_Err != nil {
263282 return templ_7745c5c3_Err
264283 }
265- var templ_7745c5c3_Var18 string
266- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath)
284+ var templ_7745c5c3_Var19 string
285+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath)
267286 if templ_7745c5c3_Err != nil {
268- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 41, Col: 23}
287+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 44, Col: 23}
269288 }
270- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
289+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
271290 if templ_7745c5c3_Err != nil {
272291 return templ_7745c5c3_Err
273292 }
274- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-sha=\"")
293+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" data-sha=\"")
275294 if templ_7745c5c3_Err != nil {
276295 return templ_7745c5c3_Err
277296 }
278- var templ_7745c5c3_Var19 string
279- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA)
297+ var templ_7745c5c3_Var20 string
298+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA)
280299 if templ_7745c5c3_Err != nil {
281- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 42, Col: 19}
300+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 45, Col: 19}
282301 }
283- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
302+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
284303 if templ_7745c5c3_Err != nil {
285304 return templ_7745c5c3_Err
286305 }
287- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-sel=\"\" data-nav=\"\"")
306+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" data-sel=\"\" data-nav=\"\"")
288307 if templ_7745c5c3_Err != nil {
289308 return templ_7745c5c3_Err
290309 }
291310 if l.PathsURL != "" {
292- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " data-paths=\"")
311+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " data-paths=\"")
293312 if templ_7745c5c3_Err != nil {
294313 return templ_7745c5c3_Err
295314 }
296- var templ_7745c5c3_Var20 string
297- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL)
315+ var templ_7745c5c3_Var21 string
316+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL)
298317 if templ_7745c5c3_Err != nil {
299- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 46, Col: 27}
318+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 49, Col: 27}
300319 }
301- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
320+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
302321 if templ_7745c5c3_Err != nil {
303322 return templ_7745c5c3_Err
304323 }
305- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" data-blob=\"")
324+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" data-blob=\"")
306325 if templ_7745c5c3_Err != nil {
307326 return templ_7745c5c3_Err
308327 }
309- var templ_7745c5c3_Var21 string
310- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase)
328+ var templ_7745c5c3_Var22 string
329+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase)
311330 if templ_7745c5c3_Err != nil {
312- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 47, Col: 26}
331+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 50, Col: 26}
313332 }
314- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
333+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
315334 if templ_7745c5c3_Err != nil {
316335 return templ_7745c5c3_Err
317336 }
318- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\"")
337+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\"")
319338 if templ_7745c5c3_Err != nil {
320339 return templ_7745c5c3_Err
321340 }
322341 }
323- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "><a class=\"skip\" href=\"#main\">skip to content</a>")
342+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "><a class=\"skip\" href=\"#main\">skip to content</a>")
324343 if templ_7745c5c3_Err != nil {
325344 return templ_7745c5c3_Err
326345 }
@@ -328,48 +347,48 @@ func Shell(l Layout, rail templ.Component) templ.Component {
328347 if templ_7745c5c3_Err != nil {
329348 return templ_7745c5c3_Err
330349 }
331- var templ_7745c5c3_Var22 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)}
332- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var22...)
350+ var templ_7745c5c3_Var23 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)}
351+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...)
333352 if templ_7745c5c3_Err != nil {
334353 return templ_7745c5c3_Err
335354 }
336- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"")
355+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"")
337356 if templ_7745c5c3_Err != nil {
338357 return templ_7745c5c3_Err
339358 }
340- var templ_7745c5c3_Var23 string
341- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var22).String())
359+ var templ_7745c5c3_Var24 string
360+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var23).String())
342361 if templ_7745c5c3_Err != nil {
343362 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 1, Col: 0}
344363 }
345- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
364+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
346365 if templ_7745c5c3_Err != nil {
347366 return templ_7745c5c3_Err
348367 }
349- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
368+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">")
350369 if templ_7745c5c3_Err != nil {
351370 return templ_7745c5c3_Err
352371 }
353372 if l.Lede != "" {
354- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">")
373+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">")
355374 if templ_7745c5c3_Err != nil {
356375 return templ_7745c5c3_Err
357376 }
358- var templ_7745c5c3_Var24 string
359- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede)
377+ var templ_7745c5c3_Var25 string
378+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede)
360379 if templ_7745c5c3_Err != nil {
361- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 54, Col: 81}
380+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 57, Col: 81}
362381 }
363- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
382+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
364383 if templ_7745c5c3_Err != nil {
365384 return templ_7745c5c3_Err
366385 }
367- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</p></section>")
386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</p></section>")
368387 if templ_7745c5c3_Err != nil {
369388 return templ_7745c5c3_Err
370389 }
371390 }
372- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<main id=\"main\" tabindex=\"-1\">")
391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<main id=\"main\" tabindex=\"-1\">")
373392 if templ_7745c5c3_Err != nil {
374393 return templ_7745c5c3_Err
375394 }
@@ -377,12 +396,12 @@ func Shell(l Layout, rail templ.Component) templ.Component {
377396 if templ_7745c5c3_Err != nil {
378397 return templ_7745c5c3_Err
379398 }
380- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</main>")
399+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</main>")
381400 if templ_7745c5c3_Err != nil {
382401 return templ_7745c5c3_Err
383402 }
384403 if l.HasRail && rail != nil {
385- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<aside class=\"rail\" aria-label=\"Context\">")
404+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<aside class=\"rail\" aria-label=\"Context\">")
386405 if templ_7745c5c3_Err != nil {
387406 return templ_7745c5c3_Err
388407 }
@@ -390,12 +409,12 @@ func Shell(l Layout, rail templ.Component) templ.Component {
390409 if templ_7745c5c3_Err != nil {
391410 return templ_7745c5c3_Err
392411 }
393- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</aside>")
412+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</aside>")
394413 if templ_7745c5c3_Err != nil {
395414 return templ_7745c5c3_Err
396415 }
397416 }
398- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div>")
417+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div>")
399418 if templ_7745c5c3_Err != nil {
400419 return templ_7745c5c3_Err
401420 }
@@ -423,7 +442,7 @@ func Shell(l Layout, rail templ.Component) templ.Component {
423442 if templ_7745c5c3_Err != nil {
424443 return templ_7745c5c3_Err
425444 }
426- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</body></html>")
445+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</body></html>")
427446 if templ_7745c5c3_Err != nil {
428447 return templ_7745c5c3_Err
429448 }
@@ -449,366 +468,366 @@ func Header(l Layout) templ.Component {
449468 }()
450469 }
451470 ctx = templ.InitializeContext(ctx)
452- templ_7745c5c3_Var25 := templ.GetChildren(ctx)
453- if templ_7745c5c3_Var25 == nil {
454- templ_7745c5c3_Var25 = templ.NopComponent
471+ templ_7745c5c3_Var26 := templ.GetChildren(ctx)
472+ if templ_7745c5c3_Var26 == nil {
473+ templ_7745c5c3_Var26 = templ.NopComponent
455474 }
456475 ctx = templ.ClearChildren(ctx)
457- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")
476+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")
458477 if templ_7745c5c3_Err != nil {
459478 return templ_7745c5c3_Err
460479 }
461480 if l.Inbox != nil {
462- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a class=\"btn inbox-btn\" href=\"")
481+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<a class=\"btn inbox-btn\" href=\"")
463482 if templ_7745c5c3_Err != nil {
464483 return templ_7745c5c3_Err
465484 }
466- var templ_7745c5c3_Var26 templ.SafeURL
467- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.Inbox.Href))
485+ var templ_7745c5c3_Var27 templ.SafeURL
486+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.Inbox.Href))
468487 if templ_7745c5c3_Err != nil {
469- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 86, Col: 39}
488+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 89, Col: 39}
470489 }
471- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
490+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
472491 if templ_7745c5c3_Err != nil {
473492 return templ_7745c5c3_Err
474493 }
475- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" title=\"inbox\"><span class=\"ico i-mailbox\" aria-hidden=\"true\"></span> Inbox ")
494+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" title=\"inbox\"><span class=\"ico i-mailbox\" aria-hidden=\"true\"></span> Inbox ")
476495 if templ_7745c5c3_Err != nil {
477496 return templ_7745c5c3_Err
478497 }
479498 if l.Inbox.Count >= 0 {
480- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<span class=\"count\">")
499+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span class=\"count\">")
481500 if templ_7745c5c3_Err != nil {
482501 return templ_7745c5c3_Err
483502 }
484- var templ_7745c5c3_Var27 string
485- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(l.Inbox.Count))
503+ var templ_7745c5c3_Var28 string
504+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(l.Inbox.Count))
486505 if templ_7745c5c3_Err != nil {
487- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 92, Col: 47}
506+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 95, Col: 47}
488507 }
489- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
508+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
490509 if templ_7745c5c3_Err != nil {
491510 return templ_7745c5c3_Err
492511 }
493- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span>")
512+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span>")
494513 if templ_7745c5c3_Err != nil {
495514 return templ_7745c5c3_Err
496515 }
497516 }
498- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</a> ")
517+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</a> ")
499518 if templ_7745c5c3_Err != nil {
500519 return templ_7745c5c3_Err
501520 }
502521 }
503522 if len(l.Crumbs) > 0 {
504- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
523+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
505524 if templ_7745c5c3_Err != nil {
506525 return templ_7745c5c3_Err
507526 }
508527 for _, c := range l.Crumbs {
509- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<li>")
528+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<li>")
510529 if templ_7745c5c3_Err != nil {
511530 return templ_7745c5c3_Err
512531 }
513532 if c.Href != "" {
514- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<a href=\"")
533+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<a href=\"")
515534 if templ_7745c5c3_Err != nil {
516535 return templ_7745c5c3_Err
517536 }
518- var templ_7745c5c3_Var28 templ.SafeURL
519- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
537+ var templ_7745c5c3_Var29 templ.SafeURL
538+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
520539 if templ_7745c5c3_Err != nil {
521- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 103, Col: 38}
540+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 106, Col: 38}
522541 }
523- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
542+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
524543 if templ_7745c5c3_Err != nil {
525544 return templ_7745c5c3_Err
526545 }
527- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"")
546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\"")
528547 if templ_7745c5c3_Err != nil {
529548 return templ_7745c5c3_Err
530549 }
531550 if c.Current {
532- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " aria-current=\"page\"")
551+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " aria-current=\"page\"")
533552 if templ_7745c5c3_Err != nil {
534553 return templ_7745c5c3_Err
535554 }
536555 }
537- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, ">")
556+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, ">")
538557 if templ_7745c5c3_Err != nil {
539558 return templ_7745c5c3_Err
540559 }
541- var templ_7745c5c3_Var29 string
542- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
560+ var templ_7745c5c3_Var30 string
561+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
543562 if templ_7745c5c3_Err != nil {
544- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 107, Col: 19}
563+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 110, Col: 19}
545564 }
546- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
565+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
547566 if templ_7745c5c3_Err != nil {
548567 return templ_7745c5c3_Err
549568 }
550- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a>")
569+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</a>")
551570 if templ_7745c5c3_Err != nil {
552571 return templ_7745c5c3_Err
553572 }
554573 } else {
555- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span")
574+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span")
556575 if templ_7745c5c3_Err != nil {
557576 return templ_7745c5c3_Err
558577 }
559578 if c.Current {
560- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " aria-current=\"page\"")
579+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " aria-current=\"page\"")
561580 if templ_7745c5c3_Err != nil {
562581 return templ_7745c5c3_Err
563582 }
564583 }
565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">")
584+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">")
566585 if templ_7745c5c3_Err != nil {
567586 return templ_7745c5c3_Err
568587 }
569- var templ_7745c5c3_Var30 string
570- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
588+ var templ_7745c5c3_Var31 string
589+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
571590 if templ_7745c5c3_Err != nil {
572- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 113, Col: 19}
591+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 116, Col: 19}
573592 }
574- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
593+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
575594 if templ_7745c5c3_Err != nil {
576595 return templ_7745c5c3_Err
577596 }
578- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span>")
597+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</span>")
579598 if templ_7745c5c3_Err != nil {
580599 return templ_7745c5c3_Err
581600 }
582601 }
583- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</li>")
602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</li>")
584603 if templ_7745c5c3_Err != nil {
585604 return templ_7745c5c3_Err
586605 }
587606 }
588- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</ol></nav>")
607+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</ol></nav>")
589608 if templ_7745c5c3_Err != nil {
590609 return templ_7745c5c3_Err
591610 }
592611 }
593- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"hdr-acts\">")
612+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div class=\"hdr-acts\">")
594613 if templ_7745c5c3_Err != nil {
595614 return templ_7745c5c3_Err
596615 }
597616 if l.Ref != "" {
598- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<a class=\"btn ref\" href=\"")
617+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<a class=\"btn ref\" href=\"")
599618 if templ_7745c5c3_Err != nil {
600619 return templ_7745c5c3_Err
601620 }
602- var templ_7745c5c3_Var31 templ.SafeURL
603- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
621+ var templ_7745c5c3_Var32 templ.SafeURL
622+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
604623 if templ_7745c5c3_Err != nil {
605- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 124, Col: 52}
624+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 127, Col: 52}
606625 }
607- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
626+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
608627 if templ_7745c5c3_Err != nil {
609628 return templ_7745c5c3_Err
610629 }
611- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
630+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
612631 if templ_7745c5c3_Err != nil {
613632 return templ_7745c5c3_Err
614633 }
615- var templ_7745c5c3_Var32 string
616- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
634+ var templ_7745c5c3_Var33 string
635+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
617636 if templ_7745c5c3_Err != nil {
618- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 130, Col: 36}
637+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 133, Col: 36}
619638 }
620- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
639+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
621640 if templ_7745c5c3_Err != nil {
622641 return templ_7745c5c3_Err
623642 }
624- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span> <span class=\"sha\">")
643+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span> <span class=\"sha\">")
625644 if templ_7745c5c3_Err != nil {
626645 return templ_7745c5c3_Err
627646 }
628- var templ_7745c5c3_Var33 string
629- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
647+ var templ_7745c5c3_Var34 string
648+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
630649 if templ_7745c5c3_Err != nil {
631- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 131, Col: 33}
650+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 134, Col: 33}
632651 }
633- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
652+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
634653 if templ_7745c5c3_Err != nil {
635654 return templ_7745c5c3_Err
636655 }
637- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</span></a> ")
656+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span></a> ")
638657 if templ_7745c5c3_Err != nil {
639658 return templ_7745c5c3_Err
640659 }
641660 }
642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
661+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
643662 if templ_7745c5c3_Err != nil {
644663 return templ_7745c5c3_Err
645664 }
646665 if SignedIn(ctx) {
647- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")
666+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")
648667 if templ_7745c5c3_Err != nil {
649668 return templ_7745c5c3_Err
650669 }
651- var templ_7745c5c3_Var34 string
652- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
670+ var templ_7745c5c3_Var35 string
671+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
653672 if templ_7745c5c3_Err != nil {
654- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 150, Col: 123}
673+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 153, Col: 123}
655674 }
656- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
675+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
657676 if templ_7745c5c3_Err != nil {
658677 return templ_7745c5c3_Err
659678 }
660- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</span> ")
679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</span> ")
661680 if templ_7745c5c3_Err != nil {
662681 return templ_7745c5c3_Err
663682 }
664683 if l.Passkeys {
665- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")
684+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")
666685 if templ_7745c5c3_Err != nil {
667686 return templ_7745c5c3_Err
668687 }
669688 }
670- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")
689+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")
671690 if templ_7745c5c3_Err != nil {
672691 return templ_7745c5c3_Err
673692 }
674693 } else if l.Passkeys {
675- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")
694+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")
676695 if templ_7745c5c3_Err != nil {
677696 return templ_7745c5c3_Err
678697 }
679698 }
680- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div></div>")
699+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div></div>")
681700 if templ_7745c5c3_Err != nil {
682701 return templ_7745c5c3_Err
683702 }
684703 if len(l.Sections) > 0 {
685- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
704+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
686705 if templ_7745c5c3_Err != nil {
687706 return templ_7745c5c3_Err
688707 }
689708 for _, s := range l.Sections {
690- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<li><a href=\"")
709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<li><a href=\"")
691710 if templ_7745c5c3_Err != nil {
692711 return templ_7745c5c3_Err
693712 }
694- var templ_7745c5c3_Var35 templ.SafeURL
695- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
713+ var templ_7745c5c3_Var36 templ.SafeURL
714+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
696715 if templ_7745c5c3_Err != nil {
697- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 167, Col: 36}
716+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 170, Col: 36}
698717 }
699- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
718+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
700719 if templ_7745c5c3_Err != nil {
701720 return templ_7745c5c3_Err
702721 }
703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" data-key=\"")
722+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" data-key=\"")
704723 if templ_7745c5c3_Err != nil {
705724 return templ_7745c5c3_Err
706725 }
707- var templ_7745c5c3_Var36 string
708- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
726+ var templ_7745c5c3_Var37 string
727+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
709728 if templ_7745c5c3_Err != nil {
710- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 168, Col: 24}
729+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 171, Col: 24}
711730 }
712- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
731+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
713732 if templ_7745c5c3_Err != nil {
714733 return templ_7745c5c3_Err
715734 }
716- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" aria-keyshortcuts=\"")
735+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" aria-keyshortcuts=\"")
717736 if templ_7745c5c3_Err != nil {
718737 return templ_7745c5c3_Err
719738 }
720- var templ_7745c5c3_Var37 string
721- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
739+ var templ_7745c5c3_Var38 string
740+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
722741 if templ_7745c5c3_Err != nil {
723- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 169, Col: 33}
742+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 172, Col: 33}
724743 }
725- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
744+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
726745 if templ_7745c5c3_Err != nil {
727746 return templ_7745c5c3_Err
728747 }
729- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\"")
748+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\"")
730749 if templ_7745c5c3_Err != nil {
731750 return templ_7745c5c3_Err
732751 }
733752 if s.Current {
734- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " aria-current=\"page\"")
753+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " aria-current=\"page\"")
735754 if templ_7745c5c3_Err != nil {
736755 return templ_7745c5c3_Err
737756 }
738757 }
739- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, ">")
758+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, ">")
740759 if templ_7745c5c3_Err != nil {
741760 return templ_7745c5c3_Err
742761 }
743- var templ_7745c5c3_Var38 string
744- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
762+ var templ_7745c5c3_Var39 string
763+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
745764 if templ_7745c5c3_Err != nil {
746- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 174, Col: 17}
765+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 177, Col: 17}
747766 }
748- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
767+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
749768 if templ_7745c5c3_Err != nil {
750769 return templ_7745c5c3_Err
751770 }
752- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " ")
771+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " ")
753772 if templ_7745c5c3_Err != nil {
754773 return templ_7745c5c3_Err
755774 }
756775 if s.Count != "" {
757- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span class=\"count\">")
776+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span class=\"count\">")
758777 if templ_7745c5c3_Err != nil {
759778 return templ_7745c5c3_Err
760779 }
761- var templ_7745c5c3_Var39 string
762- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
780+ var templ_7745c5c3_Var40 string
781+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
763782 if templ_7745c5c3_Err != nil {
764- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 38}
783+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 179, Col: 38}
765784 }
766- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
785+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
767786 if templ_7745c5c3_Err != nil {
768787 return templ_7745c5c3_Err
769788 }
770- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"sr\">")
789+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"sr\">")
771790 if templ_7745c5c3_Err != nil {
772791 return templ_7745c5c3_Err
773792 }
774- var templ_7745c5c3_Var40 string
775- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
793+ var templ_7745c5c3_Var41 string
794+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
776795 if templ_7745c5c3_Err != nil {
777- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 66}
796+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 179, Col: 66}
778797 }
779- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
798+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
780799 if templ_7745c5c3_Err != nil {
781800 return templ_7745c5c3_Err
782801 }
783- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span></span> ")
802+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span></span> ")
784803 if templ_7745c5c3_Err != nil {
785804 return templ_7745c5c3_Err
786805 }
787806 }
788- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<kbd aria-hidden=\"true\">")
807+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<kbd aria-hidden=\"true\">")
789808 if templ_7745c5c3_Err != nil {
790809 return templ_7745c5c3_Err
791810 }
792- var templ_7745c5c3_Var41 string
793- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
811+ var templ_7745c5c3_Var42 string
812+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
794813 if templ_7745c5c3_Err != nil {
795- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 178, Col: 39}
814+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 181, Col: 39}
796815 }
797- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
816+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
798817 if templ_7745c5c3_Err != nil {
799818 return templ_7745c5c3_Err
800819 }
801- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</kbd></a></li>")
820+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</kbd></a></li>")
802821 if templ_7745c5c3_Err != nil {
803822 return templ_7745c5c3_Err
804823 }
805824 }
806- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</ul></nav>")
825+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</ul></nav>")
807826 if templ_7745c5c3_Err != nil {
808827 return templ_7745c5c3_Err
809828 }
810829 }
811- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</header>")
830+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</header>")
812831 if templ_7745c5c3_Err != nil {
813832 return templ_7745c5c3_Err
814833 }
@@ -834,38 +853,38 @@ func Footer(l Layout) templ.Component {
834853 }()
835854 }
836855 ctx = templ.InitializeContext(ctx)
837- templ_7745c5c3_Var42 := templ.GetChildren(ctx)
838- if templ_7745c5c3_Var42 == nil {
839- templ_7745c5c3_Var42 = templ.NopComponent
856+ templ_7745c5c3_Var43 := templ.GetChildren(ctx)
857+ if templ_7745c5c3_Var43 == nil {
858+ templ_7745c5c3_Var43 = templ.NopComponent
840859 }
841860 ctx = templ.ClearChildren(ctx)
842- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<footer class=\"foot\"><span>ermite ")
861+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<footer class=\"foot\"><span>ermite ")
843862 if templ_7745c5c3_Err != nil {
844863 return templ_7745c5c3_Err
845864 }
846- var templ_7745c5c3_Var43 string
847- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
865+ var templ_7745c5c3_Var44 string
866+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
848867 if templ_7745c5c3_Err != nil {
849- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 192, Col: 26}
868+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 195, Col: 26}
850869 }
851- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
870+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
852871 if templ_7745c5c3_Err != nil {
853872 return templ_7745c5c3_Err
854873 }
855- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</span> <a href=\"")
874+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span> <a href=\"")
856875 if templ_7745c5c3_Err != nil {
857876 return templ_7745c5c3_Err
858877 }
859- var templ_7745c5c3_Var44 templ.SafeURL
860- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
878+ var templ_7745c5c3_Var45 templ.SafeURL
879+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
861880 if templ_7745c5c3_Err != nil {
862- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 193, Col: 38}
881+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 196, Col: 38}
863882 }
864- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
883+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
865884 if templ_7745c5c3_Err != nil {
866885 return templ_7745c5c3_Err
867886 }
868- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>")
887+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>")
869888 if templ_7745c5c3_Err != nil {
870889 return templ_7745c5c3_Err
871890 }
@@ -893,12 +912,12 @@ func Hint(l Layout) templ.Component {
893912 }()
894913 }
895914 ctx = templ.InitializeContext(ctx)
896- templ_7745c5c3_Var45 := templ.GetChildren(ctx)
897- if templ_7745c5c3_Var45 == nil {
898- templ_7745c5c3_Var45 = templ.NopComponent
915+ templ_7745c5c3_Var46 := templ.GetChildren(ctx)
916+ if templ_7745c5c3_Var46 == nil {
917+ templ_7745c5c3_Var46 = templ.NopComponent
899918 }
900919 ctx = templ.ClearChildren(ctx)
901- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
920+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
902921 if templ_7745c5c3_Err != nil {
903922 return templ_7745c5c3_Err
904923 }
@@ -906,7 +925,7 @@ func Hint(l Layout) templ.Component {
906925 if templ_7745c5c3_Err != nil {
907926 return templ_7745c5c3_Err
908927 }
909- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</div>")
928+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div>")
910929 if templ_7745c5c3_Err != nil {
911930 return templ_7745c5c3_Err
912931 }
@@ -220,15 +220,15 @@ func Shell(l Layout, rail templ.Component) templ.Component {
220 if templ_7745c5c3_Err != nil {220 if templ_7745c5c3_Err != nil {
221 return templ_7745c5c3_Err221 return templ_7745c5c3_Err
222 }222 }
223- if l.MR {223+ if l.Lazy {
224 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<script src=\"")224 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<script src=\"")
225 if templ_7745c5c3_Err != nil {225 if templ_7745c5c3_Err != nil {
226 return templ_7745c5c3_Err226 return templ_7745c5c3_Err
227 }227 }
228 var templ_7745c5c3_Var16 string228 var templ_7745c5c3_Var16 string
229- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.MR)229+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Lazy)
230 if templ_7745c5c3_Err != nil {230 if templ_7745c5c3_Err != nil {
231- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 34, Col: 29}231+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 34, Col: 31}
232 }232 }
233 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)233 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
234 if templ_7745c5c3_Err != nil {234 if templ_7745c5c3_Err != nil {
@@ -239,15 +239,15 @@ func Shell(l Layout, rail templ.Component) templ.Component {
239 return templ_7745c5c3_Err239 return templ_7745c5c3_Err
240 }240 }
241 }241 }
242- if l.IssueForm {242+ if l.MR {
243 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<script src=\"")243 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<script src=\"")
244 if templ_7745c5c3_Err != nil {244 if templ_7745c5c3_Err != nil {
245 return templ_7745c5c3_Err245 return templ_7745c5c3_Err
246 }246 }
247 var templ_7745c5c3_Var17 string247 var templ_7745c5c3_Var17 string
248- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.IssueForm)248+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.MR)
249 if templ_7745c5c3_Err != nil {249 if templ_7745c5c3_Err != nil {
250- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 37, Col: 36}250+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 37, Col: 29}
251 }251 }
252 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)252 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
253 if templ_7745c5c3_Err != nil {253 if templ_7745c5c3_Err != nil {
@@ -258,69 +258,88 @@ func Shell(l Layout, rail templ.Component) templ.Component {
258 return templ_7745c5c3_Err258 return templ_7745c5c3_Err
259 }259 }
260 }260 }
261- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</head><body data-ref=\"")261+ if l.IssueForm {
262+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<script src=\"")
263+ if templ_7745c5c3_Err != nil {
264+ return templ_7745c5c3_Err
265+ }
266+ var templ_7745c5c3_Var18 string
267+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.IssueForm)
268+ if templ_7745c5c3_Err != nil {
269+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 40, Col: 36}
270+ }
271+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
272+ if templ_7745c5c3_Err != nil {
273+ return templ_7745c5c3_Err
274+ }
275+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" defer></script>")
276+ if templ_7745c5c3_Err != nil {
277+ return templ_7745c5c3_Err
278+ }
279+ }
280+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</head><body data-ref=\"")
262 if templ_7745c5c3_Err != nil {281 if templ_7745c5c3_Err != nil {
263 return templ_7745c5c3_Err282 return templ_7745c5c3_Err
264 }283 }
265- var templ_7745c5c3_Var18 string284+ var templ_7745c5c3_Var19 string
266- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath)285+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath)
267 if templ_7745c5c3_Err != nil {286 if templ_7745c5c3_Err != nil {
268- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 41, Col: 23}287+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 44, Col: 23}
269 }288 }
270- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)289+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
271 if templ_7745c5c3_Err != nil {290 if templ_7745c5c3_Err != nil {
272 return templ_7745c5c3_Err291 return templ_7745c5c3_Err
273 }292 }
274- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-sha=\"")293+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" data-sha=\"")
275 if templ_7745c5c3_Err != nil {294 if templ_7745c5c3_Err != nil {
276 return templ_7745c5c3_Err295 return templ_7745c5c3_Err
277 }296 }
278- var templ_7745c5c3_Var19 string297+ var templ_7745c5c3_Var20 string
279- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA)298+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA)
280 if templ_7745c5c3_Err != nil {299 if templ_7745c5c3_Err != nil {
281- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 42, Col: 19}300+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 45, Col: 19}
282 }301 }
283- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)302+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
284 if templ_7745c5c3_Err != nil {303 if templ_7745c5c3_Err != nil {
285 return templ_7745c5c3_Err304 return templ_7745c5c3_Err
286 }305 }
287- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-sel=\"\" data-nav=\"\"")306+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" data-sel=\"\" data-nav=\"\"")
288 if templ_7745c5c3_Err != nil {307 if templ_7745c5c3_Err != nil {
289 return templ_7745c5c3_Err308 return templ_7745c5c3_Err
290 }309 }
291 if l.PathsURL != "" {310 if l.PathsURL != "" {
292- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " data-paths=\"")311+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " data-paths=\"")
293 if templ_7745c5c3_Err != nil {312 if templ_7745c5c3_Err != nil {
294 return templ_7745c5c3_Err313 return templ_7745c5c3_Err
295 }314 }
296- var templ_7745c5c3_Var20 string315+ var templ_7745c5c3_Var21 string
297- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL)316+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL)
298 if templ_7745c5c3_Err != nil {317 if templ_7745c5c3_Err != nil {
299- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 46, Col: 27}318+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 49, Col: 27}
300 }319 }
301- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)320+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
302 if templ_7745c5c3_Err != nil {321 if templ_7745c5c3_Err != nil {
303 return templ_7745c5c3_Err322 return templ_7745c5c3_Err
304 }323 }
305- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" data-blob=\"")324+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" data-blob=\"")
306 if templ_7745c5c3_Err != nil {325 if templ_7745c5c3_Err != nil {
307 return templ_7745c5c3_Err326 return templ_7745c5c3_Err
308 }327 }
309- var templ_7745c5c3_Var21 string328+ var templ_7745c5c3_Var22 string
310- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase)329+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase)
311 if templ_7745c5c3_Err != nil {330 if templ_7745c5c3_Err != nil {
312- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 47, Col: 26}331+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 50, Col: 26}
313 }332 }
314- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)333+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
315 if templ_7745c5c3_Err != nil {334 if templ_7745c5c3_Err != nil {
316 return templ_7745c5c3_Err335 return templ_7745c5c3_Err
317 }336 }
318- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\"")337+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\"")
319 if templ_7745c5c3_Err != nil {338 if templ_7745c5c3_Err != nil {
320 return templ_7745c5c3_Err339 return templ_7745c5c3_Err
321 }340 }
322 }341 }
323- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "><a class=\"skip\" href=\"#main\">skip to content</a>")342+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "><a class=\"skip\" href=\"#main\">skip to content</a>")
324 if templ_7745c5c3_Err != nil {343 if templ_7745c5c3_Err != nil {
325 return templ_7745c5c3_Err344 return templ_7745c5c3_Err
326 }345 }
@@ -328,48 +347,48 @@ func Shell(l Layout, rail templ.Component) templ.Component {
328 if templ_7745c5c3_Err != nil {347 if templ_7745c5c3_Err != nil {
329 return templ_7745c5c3_Err348 return templ_7745c5c3_Err
330 }349 }
331- var templ_7745c5c3_Var22 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)}350+ var templ_7745c5c3_Var23 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)}
332- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var22...)351+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...)
333 if templ_7745c5c3_Err != nil {352 if templ_7745c5c3_Err != nil {
334 return templ_7745c5c3_Err353 return templ_7745c5c3_Err
335 }354 }
336- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"")355+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"")
337 if templ_7745c5c3_Err != nil {356 if templ_7745c5c3_Err != nil {
338 return templ_7745c5c3_Err357 return templ_7745c5c3_Err
339 }358 }
340- var templ_7745c5c3_Var23 string359+ var templ_7745c5c3_Var24 string
341- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var22).String())360+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var23).String())
342 if templ_7745c5c3_Err != nil {361 if templ_7745c5c3_Err != nil {
343 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 1, Col: 0}362 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 1, Col: 0}
344 }363 }
345- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)364+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
346 if templ_7745c5c3_Err != nil {365 if templ_7745c5c3_Err != nil {
347 return templ_7745c5c3_Err366 return templ_7745c5c3_Err
348 }367 }
349- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")368+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">")
350 if templ_7745c5c3_Err != nil {369 if templ_7745c5c3_Err != nil {
351 return templ_7745c5c3_Err370 return templ_7745c5c3_Err
352 }371 }
353 if l.Lede != "" {372 if l.Lede != "" {
354- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">")373+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">")
355 if templ_7745c5c3_Err != nil {374 if templ_7745c5c3_Err != nil {
356 return templ_7745c5c3_Err375 return templ_7745c5c3_Err
357 }376 }
358- var templ_7745c5c3_Var24 string377+ var templ_7745c5c3_Var25 string
359- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede)378+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede)
360 if templ_7745c5c3_Err != nil {379 if templ_7745c5c3_Err != nil {
361- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 54, Col: 81}380+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 57, Col: 81}
362 }381 }
363- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))382+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
364 if templ_7745c5c3_Err != nil {383 if templ_7745c5c3_Err != nil {
365 return templ_7745c5c3_Err384 return templ_7745c5c3_Err
366 }385 }
367- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</p></section>")386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</p></section>")
368 if templ_7745c5c3_Err != nil {387 if templ_7745c5c3_Err != nil {
369 return templ_7745c5c3_Err388 return templ_7745c5c3_Err
370 }389 }
371 }390 }
372- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<main id=\"main\" tabindex=\"-1\">")391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<main id=\"main\" tabindex=\"-1\">")
373 if templ_7745c5c3_Err != nil {392 if templ_7745c5c3_Err != nil {
374 return templ_7745c5c3_Err393 return templ_7745c5c3_Err
375 }394 }
@@ -377,12 +396,12 @@ func Shell(l Layout, rail templ.Component) templ.Component {
377 if templ_7745c5c3_Err != nil {396 if templ_7745c5c3_Err != nil {
378 return templ_7745c5c3_Err397 return templ_7745c5c3_Err
379 }398 }
380- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</main>")399+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</main>")
381 if templ_7745c5c3_Err != nil {400 if templ_7745c5c3_Err != nil {
382 return templ_7745c5c3_Err401 return templ_7745c5c3_Err
383 }402 }
384 if l.HasRail && rail != nil {403 if l.HasRail && rail != nil {
385- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<aside class=\"rail\" aria-label=\"Context\">")404+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<aside class=\"rail\" aria-label=\"Context\">")
386 if templ_7745c5c3_Err != nil {405 if templ_7745c5c3_Err != nil {
387 return templ_7745c5c3_Err406 return templ_7745c5c3_Err
388 }407 }
@@ -390,12 +409,12 @@ func Shell(l Layout, rail templ.Component) templ.Component {
390 if templ_7745c5c3_Err != nil {409 if templ_7745c5c3_Err != nil {
391 return templ_7745c5c3_Err410 return templ_7745c5c3_Err
392 }411 }
393- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</aside>")412+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</aside>")
394 if templ_7745c5c3_Err != nil {413 if templ_7745c5c3_Err != nil {
395 return templ_7745c5c3_Err414 return templ_7745c5c3_Err
396 }415 }
397 }416 }
398- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div>")417+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div>")
399 if templ_7745c5c3_Err != nil {418 if templ_7745c5c3_Err != nil {
400 return templ_7745c5c3_Err419 return templ_7745c5c3_Err
401 }420 }
@@ -423,7 +442,7 @@ func Shell(l Layout, rail templ.Component) templ.Component {
423 if templ_7745c5c3_Err != nil {442 if templ_7745c5c3_Err != nil {
424 return templ_7745c5c3_Err443 return templ_7745c5c3_Err
425 }444 }
426- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</body></html>")445+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</body></html>")
427 if templ_7745c5c3_Err != nil {446 if templ_7745c5c3_Err != nil {
428 return templ_7745c5c3_Err447 return templ_7745c5c3_Err
429 }448 }
@@ -449,366 +468,366 @@ func Header(l Layout) templ.Component {
449 }()468 }()
450 }469 }
451 ctx = templ.InitializeContext(ctx)470 ctx = templ.InitializeContext(ctx)
452- templ_7745c5c3_Var25 := templ.GetChildren(ctx)471+ templ_7745c5c3_Var26 := templ.GetChildren(ctx)
453- if templ_7745c5c3_Var25 == nil {472+ if templ_7745c5c3_Var26 == nil {
454- templ_7745c5c3_Var25 = templ.NopComponent473+ templ_7745c5c3_Var26 = templ.NopComponent
455 }474 }
456 ctx = templ.ClearChildren(ctx)475 ctx = templ.ClearChildren(ctx)
457- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")476+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")
458 if templ_7745c5c3_Err != nil {477 if templ_7745c5c3_Err != nil {
459 return templ_7745c5c3_Err478 return templ_7745c5c3_Err
460 }479 }
461 if l.Inbox != nil {480 if l.Inbox != nil {
462- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a class=\"btn inbox-btn\" href=\"")481+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<a class=\"btn inbox-btn\" href=\"")
463 if templ_7745c5c3_Err != nil {482 if templ_7745c5c3_Err != nil {
464 return templ_7745c5c3_Err483 return templ_7745c5c3_Err
465 }484 }
466- var templ_7745c5c3_Var26 templ.SafeURL485+ var templ_7745c5c3_Var27 templ.SafeURL
467- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.Inbox.Href))486+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.Inbox.Href))
468 if templ_7745c5c3_Err != nil {487 if templ_7745c5c3_Err != nil {
469- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 86, Col: 39}488+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 89, Col: 39}
470 }489 }
471- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))490+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
472 if templ_7745c5c3_Err != nil {491 if templ_7745c5c3_Err != nil {
473 return templ_7745c5c3_Err492 return templ_7745c5c3_Err
474 }493 }
475- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" title=\"inbox\"><span class=\"ico i-mailbox\" aria-hidden=\"true\"></span> Inbox ")494+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" title=\"inbox\"><span class=\"ico i-mailbox\" aria-hidden=\"true\"></span> Inbox ")
476 if templ_7745c5c3_Err != nil {495 if templ_7745c5c3_Err != nil {
477 return templ_7745c5c3_Err496 return templ_7745c5c3_Err
478 }497 }
479 if l.Inbox.Count >= 0 {498 if l.Inbox.Count >= 0 {
480- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<span class=\"count\">")499+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span class=\"count\">")
481 if templ_7745c5c3_Err != nil {500 if templ_7745c5c3_Err != nil {
482 return templ_7745c5c3_Err501 return templ_7745c5c3_Err
483 }502 }
484- var templ_7745c5c3_Var27 string503+ var templ_7745c5c3_Var28 string
485- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(l.Inbox.Count))504+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(l.Inbox.Count))
486 if templ_7745c5c3_Err != nil {505 if templ_7745c5c3_Err != nil {
487- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 92, Col: 47}506+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 95, Col: 47}
488 }507 }
489- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))508+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
490 if templ_7745c5c3_Err != nil {509 if templ_7745c5c3_Err != nil {
491 return templ_7745c5c3_Err510 return templ_7745c5c3_Err
492 }511 }
493- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span>")512+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span>")
494 if templ_7745c5c3_Err != nil {513 if templ_7745c5c3_Err != nil {
495 return templ_7745c5c3_Err514 return templ_7745c5c3_Err
496 }515 }
497 }516 }
498- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</a> ")517+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</a> ")
499 if templ_7745c5c3_Err != nil {518 if templ_7745c5c3_Err != nil {
500 return templ_7745c5c3_Err519 return templ_7745c5c3_Err
501 }520 }
502 }521 }
503 if len(l.Crumbs) > 0 {522 if len(l.Crumbs) > 0 {
504- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")523+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
505 if templ_7745c5c3_Err != nil {524 if templ_7745c5c3_Err != nil {
506 return templ_7745c5c3_Err525 return templ_7745c5c3_Err
507 }526 }
508 for _, c := range l.Crumbs {527 for _, c := range l.Crumbs {
509- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<li>")528+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<li>")
510 if templ_7745c5c3_Err != nil {529 if templ_7745c5c3_Err != nil {
511 return templ_7745c5c3_Err530 return templ_7745c5c3_Err
512 }531 }
513 if c.Href != "" {532 if c.Href != "" {
514- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<a href=\"")533+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<a href=\"")
515 if templ_7745c5c3_Err != nil {534 if templ_7745c5c3_Err != nil {
516 return templ_7745c5c3_Err535 return templ_7745c5c3_Err
517 }536 }
518- var templ_7745c5c3_Var28 templ.SafeURL537+ var templ_7745c5c3_Var29 templ.SafeURL
519- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))538+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
520 if templ_7745c5c3_Err != nil {539 if templ_7745c5c3_Err != nil {
521- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 103, Col: 38}540+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 106, Col: 38}
522 }541 }
523- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))542+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
524 if templ_7745c5c3_Err != nil {543 if templ_7745c5c3_Err != nil {
525 return templ_7745c5c3_Err544 return templ_7745c5c3_Err
526 }545 }
527- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"")546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\"")
528 if templ_7745c5c3_Err != nil {547 if templ_7745c5c3_Err != nil {
529 return templ_7745c5c3_Err548 return templ_7745c5c3_Err
530 }549 }
531 if c.Current {550 if c.Current {
532- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " aria-current=\"page\"")551+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " aria-current=\"page\"")
533 if templ_7745c5c3_Err != nil {552 if templ_7745c5c3_Err != nil {
534 return templ_7745c5c3_Err553 return templ_7745c5c3_Err
535 }554 }
536 }555 }
537- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, ">")556+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, ">")
538 if templ_7745c5c3_Err != nil {557 if templ_7745c5c3_Err != nil {
539 return templ_7745c5c3_Err558 return templ_7745c5c3_Err
540 }559 }
541- var templ_7745c5c3_Var29 string560+ var templ_7745c5c3_Var30 string
542- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)561+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
543 if templ_7745c5c3_Err != nil {562 if templ_7745c5c3_Err != nil {
544- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 107, Col: 19}563+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 110, Col: 19}
545 }564 }
546- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))565+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
547 if templ_7745c5c3_Err != nil {566 if templ_7745c5c3_Err != nil {
548 return templ_7745c5c3_Err567 return templ_7745c5c3_Err
549 }568 }
550- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a>")569+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</a>")
551 if templ_7745c5c3_Err != nil {570 if templ_7745c5c3_Err != nil {
552 return templ_7745c5c3_Err571 return templ_7745c5c3_Err
553 }572 }
554 } else {573 } else {
555- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span")574+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span")
556 if templ_7745c5c3_Err != nil {575 if templ_7745c5c3_Err != nil {
557 return templ_7745c5c3_Err576 return templ_7745c5c3_Err
558 }577 }
559 if c.Current {578 if c.Current {
560- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " aria-current=\"page\"")579+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " aria-current=\"page\"")
561 if templ_7745c5c3_Err != nil {580 if templ_7745c5c3_Err != nil {
562 return templ_7745c5c3_Err581 return templ_7745c5c3_Err
563 }582 }
564 }583 }
565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">")584+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">")
566 if templ_7745c5c3_Err != nil {585 if templ_7745c5c3_Err != nil {
567 return templ_7745c5c3_Err586 return templ_7745c5c3_Err
568 }587 }
569- var templ_7745c5c3_Var30 string588+ var templ_7745c5c3_Var31 string
570- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)589+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
571 if templ_7745c5c3_Err != nil {590 if templ_7745c5c3_Err != nil {
572- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 113, Col: 19}591+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 116, Col: 19}
573 }592 }
574- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))593+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
575 if templ_7745c5c3_Err != nil {594 if templ_7745c5c3_Err != nil {
576 return templ_7745c5c3_Err595 return templ_7745c5c3_Err
577 }596 }
578- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span>")597+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</span>")
579 if templ_7745c5c3_Err != nil {598 if templ_7745c5c3_Err != nil {
580 return templ_7745c5c3_Err599 return templ_7745c5c3_Err
581 }600 }
582 }601 }
583- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</li>")602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</li>")
584 if templ_7745c5c3_Err != nil {603 if templ_7745c5c3_Err != nil {
585 return templ_7745c5c3_Err604 return templ_7745c5c3_Err
586 }605 }
587 }606 }
588- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</ol></nav>")607+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</ol></nav>")
589 if templ_7745c5c3_Err != nil {608 if templ_7745c5c3_Err != nil {
590 return templ_7745c5c3_Err609 return templ_7745c5c3_Err
591 }610 }
592 }611 }
593- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"hdr-acts\">")612+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div class=\"hdr-acts\">")
594 if templ_7745c5c3_Err != nil {613 if templ_7745c5c3_Err != nil {
595 return templ_7745c5c3_Err614 return templ_7745c5c3_Err
596 }615 }
597 if l.Ref != "" {616 if l.Ref != "" {
598- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<a class=\"btn ref\" href=\"")617+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<a class=\"btn ref\" href=\"")
599 if templ_7745c5c3_Err != nil {618 if templ_7745c5c3_Err != nil {
600 return templ_7745c5c3_Err619 return templ_7745c5c3_Err
601 }620 }
602- var templ_7745c5c3_Var31 templ.SafeURL621+ var templ_7745c5c3_Var32 templ.SafeURL
603- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))622+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
604 if templ_7745c5c3_Err != nil {623 if templ_7745c5c3_Err != nil {
605- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 124, Col: 52}624+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 127, Col: 52}
606 }625 }
607- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))626+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
608 if templ_7745c5c3_Err != nil {627 if templ_7745c5c3_Err != nil {
609 return templ_7745c5c3_Err628 return templ_7745c5c3_Err
610 }629 }
611- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")630+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
612 if templ_7745c5c3_Err != nil {631 if templ_7745c5c3_Err != nil {
613 return templ_7745c5c3_Err632 return templ_7745c5c3_Err
614 }633 }
615- var templ_7745c5c3_Var32 string634+ var templ_7745c5c3_Var33 string
616- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)635+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
617 if templ_7745c5c3_Err != nil {636 if templ_7745c5c3_Err != nil {
618- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 130, Col: 36}637+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 133, Col: 36}
619 }638 }
620- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))639+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
621 if templ_7745c5c3_Err != nil {640 if templ_7745c5c3_Err != nil {
622 return templ_7745c5c3_Err641 return templ_7745c5c3_Err
623 }642 }
624- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span> <span class=\"sha\">")643+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span> <span class=\"sha\">")
625 if templ_7745c5c3_Err != nil {644 if templ_7745c5c3_Err != nil {
626 return templ_7745c5c3_Err645 return templ_7745c5c3_Err
627 }646 }
628- var templ_7745c5c3_Var33 string647+ var templ_7745c5c3_Var34 string
629- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)648+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
630 if templ_7745c5c3_Err != nil {649 if templ_7745c5c3_Err != nil {
631- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 131, Col: 33}650+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 134, Col: 33}
632 }651 }
633- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))652+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
634 if templ_7745c5c3_Err != nil {653 if templ_7745c5c3_Err != nil {
635 return templ_7745c5c3_Err654 return templ_7745c5c3_Err
636 }655 }
637- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</span></a> ")656+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span></a> ")
638 if templ_7745c5c3_Err != nil {657 if templ_7745c5c3_Err != nil {
639 return templ_7745c5c3_Err658 return templ_7745c5c3_Err
640 }659 }
641 }660 }
642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")661+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
643 if templ_7745c5c3_Err != nil {662 if templ_7745c5c3_Err != nil {
644 return templ_7745c5c3_Err663 return templ_7745c5c3_Err
645 }664 }
646 if SignedIn(ctx) {665 if SignedIn(ctx) {
647- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")666+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")
648 if templ_7745c5c3_Err != nil {667 if templ_7745c5c3_Err != nil {
649 return templ_7745c5c3_Err668 return templ_7745c5c3_Err
650 }669 }
651- var templ_7745c5c3_Var34 string670+ var templ_7745c5c3_Var35 string
652- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)671+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
653 if templ_7745c5c3_Err != nil {672 if templ_7745c5c3_Err != nil {
654- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 150, Col: 123}673+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 153, Col: 123}
655 }674 }
656- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))675+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
657 if templ_7745c5c3_Err != nil {676 if templ_7745c5c3_Err != nil {
658 return templ_7745c5c3_Err677 return templ_7745c5c3_Err
659 }678 }
660- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</span> ")679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</span> ")
661 if templ_7745c5c3_Err != nil {680 if templ_7745c5c3_Err != nil {
662 return templ_7745c5c3_Err681 return templ_7745c5c3_Err
663 }682 }
664 if l.Passkeys {683 if l.Passkeys {
665- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")684+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")
666 if templ_7745c5c3_Err != nil {685 if templ_7745c5c3_Err != nil {
667 return templ_7745c5c3_Err686 return templ_7745c5c3_Err
668 }687 }
669 }688 }
670- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")689+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")
671 if templ_7745c5c3_Err != nil {690 if templ_7745c5c3_Err != nil {
672 return templ_7745c5c3_Err691 return templ_7745c5c3_Err
673 }692 }
674 } else if l.Passkeys {693 } else if l.Passkeys {
675- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")694+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")
676 if templ_7745c5c3_Err != nil {695 if templ_7745c5c3_Err != nil {
677 return templ_7745c5c3_Err696 return templ_7745c5c3_Err
678 }697 }
679 }698 }
680- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div></div>")699+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div></div>")
681 if templ_7745c5c3_Err != nil {700 if templ_7745c5c3_Err != nil {
682 return templ_7745c5c3_Err701 return templ_7745c5c3_Err
683 }702 }
684 if len(l.Sections) > 0 {703 if len(l.Sections) > 0 {
685- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")704+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
686 if templ_7745c5c3_Err != nil {705 if templ_7745c5c3_Err != nil {
687 return templ_7745c5c3_Err706 return templ_7745c5c3_Err
688 }707 }
689 for _, s := range l.Sections {708 for _, s := range l.Sections {
690- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<li><a href=\"")709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<li><a href=\"")
691 if templ_7745c5c3_Err != nil {710 if templ_7745c5c3_Err != nil {
692 return templ_7745c5c3_Err711 return templ_7745c5c3_Err
693 }712 }
694- var templ_7745c5c3_Var35 templ.SafeURL713+ var templ_7745c5c3_Var36 templ.SafeURL
695- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))714+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
696 if templ_7745c5c3_Err != nil {715 if templ_7745c5c3_Err != nil {
697- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 167, Col: 36}716+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 170, Col: 36}
698 }717 }
699- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))718+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
700 if templ_7745c5c3_Err != nil {719 if templ_7745c5c3_Err != nil {
701 return templ_7745c5c3_Err720 return templ_7745c5c3_Err
702 }721 }
703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" data-key=\"")722+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" data-key=\"")
704 if templ_7745c5c3_Err != nil {723 if templ_7745c5c3_Err != nil {
705 return templ_7745c5c3_Err724 return templ_7745c5c3_Err
706 }725 }
707- var templ_7745c5c3_Var36 string726+ var templ_7745c5c3_Var37 string
708- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)727+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
709 if templ_7745c5c3_Err != nil {728 if templ_7745c5c3_Err != nil {
710- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 168, Col: 24}729+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 171, Col: 24}
711 }730 }
712- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)731+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
713 if templ_7745c5c3_Err != nil {732 if templ_7745c5c3_Err != nil {
714 return templ_7745c5c3_Err733 return templ_7745c5c3_Err
715 }734 }
716- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" aria-keyshortcuts=\"")735+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" aria-keyshortcuts=\"")
717 if templ_7745c5c3_Err != nil {736 if templ_7745c5c3_Err != nil {
718 return templ_7745c5c3_Err737 return templ_7745c5c3_Err
719 }738 }
720- var templ_7745c5c3_Var37 string739+ var templ_7745c5c3_Var38 string
721- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)740+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
722 if templ_7745c5c3_Err != nil {741 if templ_7745c5c3_Err != nil {
723- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 169, Col: 33}742+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 172, Col: 33}
724 }743 }
725- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)744+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
726 if templ_7745c5c3_Err != nil {745 if templ_7745c5c3_Err != nil {
727 return templ_7745c5c3_Err746 return templ_7745c5c3_Err
728 }747 }
729- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\"")748+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\"")
730 if templ_7745c5c3_Err != nil {749 if templ_7745c5c3_Err != nil {
731 return templ_7745c5c3_Err750 return templ_7745c5c3_Err
732 }751 }
733 if s.Current {752 if s.Current {
734- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " aria-current=\"page\"")753+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " aria-current=\"page\"")
735 if templ_7745c5c3_Err != nil {754 if templ_7745c5c3_Err != nil {
736 return templ_7745c5c3_Err755 return templ_7745c5c3_Err
737 }756 }
738 }757 }
739- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, ">")758+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, ">")
740 if templ_7745c5c3_Err != nil {759 if templ_7745c5c3_Err != nil {
741 return templ_7745c5c3_Err760 return templ_7745c5c3_Err
742 }761 }
743- var templ_7745c5c3_Var38 string762+ var templ_7745c5c3_Var39 string
744- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)763+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
745 if templ_7745c5c3_Err != nil {764 if templ_7745c5c3_Err != nil {
746- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 174, Col: 17}765+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 177, Col: 17}
747 }766 }
748- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))767+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
749 if templ_7745c5c3_Err != nil {768 if templ_7745c5c3_Err != nil {
750 return templ_7745c5c3_Err769 return templ_7745c5c3_Err
751 }770 }
752- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " ")771+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " ")
753 if templ_7745c5c3_Err != nil {772 if templ_7745c5c3_Err != nil {
754 return templ_7745c5c3_Err773 return templ_7745c5c3_Err
755 }774 }
756 if s.Count != "" {775 if s.Count != "" {
757- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span class=\"count\">")776+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span class=\"count\">")
758 if templ_7745c5c3_Err != nil {777 if templ_7745c5c3_Err != nil {
759 return templ_7745c5c3_Err778 return templ_7745c5c3_Err
760 }779 }
761- var templ_7745c5c3_Var39 string780+ var templ_7745c5c3_Var40 string
762- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)781+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
763 if templ_7745c5c3_Err != nil {782 if templ_7745c5c3_Err != nil {
764- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 38}783+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 179, Col: 38}
765 }784 }
766- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))785+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
767 if templ_7745c5c3_Err != nil {786 if templ_7745c5c3_Err != nil {
768 return templ_7745c5c3_Err787 return templ_7745c5c3_Err
769 }788 }
770- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"sr\">")789+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"sr\">")
771 if templ_7745c5c3_Err != nil {790 if templ_7745c5c3_Err != nil {
772 return templ_7745c5c3_Err791 return templ_7745c5c3_Err
773 }792 }
774- var templ_7745c5c3_Var40 string793+ var templ_7745c5c3_Var41 string
775- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(" open")794+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
776 if templ_7745c5c3_Err != nil {795 if templ_7745c5c3_Err != nil {
777- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 66}796+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 179, Col: 66}
778 }797 }
779- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))798+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
780 if templ_7745c5c3_Err != nil {799 if templ_7745c5c3_Err != nil {
781 return templ_7745c5c3_Err800 return templ_7745c5c3_Err
782 }801 }
783- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span></span> ")802+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span></span> ")
784 if templ_7745c5c3_Err != nil {803 if templ_7745c5c3_Err != nil {
785 return templ_7745c5c3_Err804 return templ_7745c5c3_Err
786 }805 }
787 }806 }
788- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<kbd aria-hidden=\"true\">")807+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<kbd aria-hidden=\"true\">")
789 if templ_7745c5c3_Err != nil {808 if templ_7745c5c3_Err != nil {
790 return templ_7745c5c3_Err809 return templ_7745c5c3_Err
791 }810 }
792- var templ_7745c5c3_Var41 string811+ var templ_7745c5c3_Var42 string
793- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)812+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
794 if templ_7745c5c3_Err != nil {813 if templ_7745c5c3_Err != nil {
795- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 178, Col: 39}814+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 181, Col: 39}
796 }815 }
797- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))816+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
798 if templ_7745c5c3_Err != nil {817 if templ_7745c5c3_Err != nil {
799 return templ_7745c5c3_Err818 return templ_7745c5c3_Err
800 }819 }
801- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</kbd></a></li>")820+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</kbd></a></li>")
802 if templ_7745c5c3_Err != nil {821 if templ_7745c5c3_Err != nil {
803 return templ_7745c5c3_Err822 return templ_7745c5c3_Err
804 }823 }
805 }824 }
806- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</ul></nav>")825+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</ul></nav>")
807 if templ_7745c5c3_Err != nil {826 if templ_7745c5c3_Err != nil {
808 return templ_7745c5c3_Err827 return templ_7745c5c3_Err
809 }828 }
810 }829 }
811- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</header>")830+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</header>")
812 if templ_7745c5c3_Err != nil {831 if templ_7745c5c3_Err != nil {
813 return templ_7745c5c3_Err832 return templ_7745c5c3_Err
814 }833 }
@@ -834,38 +853,38 @@ func Footer(l Layout) templ.Component {
834 }()853 }()
835 }854 }
836 ctx = templ.InitializeContext(ctx)855 ctx = templ.InitializeContext(ctx)
837- templ_7745c5c3_Var42 := templ.GetChildren(ctx)856+ templ_7745c5c3_Var43 := templ.GetChildren(ctx)
838- if templ_7745c5c3_Var42 == nil {857+ if templ_7745c5c3_Var43 == nil {
839- templ_7745c5c3_Var42 = templ.NopComponent858+ templ_7745c5c3_Var43 = templ.NopComponent
840 }859 }
841 ctx = templ.ClearChildren(ctx)860 ctx = templ.ClearChildren(ctx)
842- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<footer class=\"foot\"><span>ermite ")861+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<footer class=\"foot\"><span>ermite ")
843 if templ_7745c5c3_Err != nil {862 if templ_7745c5c3_Err != nil {
844 return templ_7745c5c3_Err863 return templ_7745c5c3_Err
845 }864 }
846- var templ_7745c5c3_Var43 string865+ var templ_7745c5c3_Var44 string
847- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)866+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
848 if templ_7745c5c3_Err != nil {867 if templ_7745c5c3_Err != nil {
849- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 192, Col: 26}868+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 195, Col: 26}
850 }869 }
851- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))870+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
852 if templ_7745c5c3_Err != nil {871 if templ_7745c5c3_Err != nil {
853 return templ_7745c5c3_Err872 return templ_7745c5c3_Err
854 }873 }
855- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</span> <a href=\"")874+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span> <a href=\"")
856 if templ_7745c5c3_Err != nil {875 if templ_7745c5c3_Err != nil {
857 return templ_7745c5c3_Err876 return templ_7745c5c3_Err
858 }877 }
859- var templ_7745c5c3_Var44 templ.SafeURL878+ var templ_7745c5c3_Var45 templ.SafeURL
860- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))879+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
861 if templ_7745c5c3_Err != nil {880 if templ_7745c5c3_Err != nil {
862- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 193, Col: 38}881+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 196, Col: 38}
863 }882 }
864- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))883+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
865 if templ_7745c5c3_Err != nil {884 if templ_7745c5c3_Err != nil {
866 return templ_7745c5c3_Err885 return templ_7745c5c3_Err
867 }886 }
868- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>")887+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>")
869 if templ_7745c5c3_Err != nil {888 if templ_7745c5c3_Err != nil {
870 return templ_7745c5c3_Err889 return templ_7745c5c3_Err
871 }890 }
@@ -893,12 +912,12 @@ func Hint(l Layout) templ.Component {
893 }()912 }()
894 }913 }
895 ctx = templ.InitializeContext(ctx)914 ctx = templ.InitializeContext(ctx)
896- templ_7745c5c3_Var45 := templ.GetChildren(ctx)915+ templ_7745c5c3_Var46 := templ.GetChildren(ctx)
897- if templ_7745c5c3_Var45 == nil {916+ if templ_7745c5c3_Var46 == nil {
898- templ_7745c5c3_Var45 = templ.NopComponent917+ templ_7745c5c3_Var46 = templ.NopComponent
899 }918 }
900 ctx = templ.ClearChildren(ctx)919 ctx = templ.ClearChildren(ctx)
901- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")920+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
902 if templ_7745c5c3_Err != nil {921 if templ_7745c5c3_Err != nil {
903 return templ_7745c5c3_Err922 return templ_7745c5c3_Err
904 }923 }
@@ -906,7 +925,7 @@ func Hint(l Layout) templ.Component {
906 if templ_7745c5c3_Err != nil {925 if templ_7745c5c3_Err != nil {
907 return templ_7745c5c3_Err926 return templ_7745c5c3_Err
908 }927 }
909- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</div>")928+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div>")
910 if templ_7745c5c3_Err != nil {929 if templ_7745c5c3_Err != nil {
911 return templ_7745c5c3_Err930 return templ_7745c5c3_Err
912 }931 }
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