detached 3c3845e

feat(web): merge request layout, merge bar and lazy-loaded files (#51)

commit
3c3845e5d8be66bcbd3b3c37e910ff00f2dda13c
author
brice · · 2026-10-17 22:27 UTC
committer
bnema · · 2026-10-17 22:27 UTC
parent
e08f000
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
72 files · +3495 -1450
M internal/adapters/gitcli/diff.go +10 -3
@@ -107,16 +107,22 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
107107 stats.Deletions += entries[i].del
108108 }
109109 var diff domain.Diff
110- if len(entries) > limits.MaxFiles {
110+ if limits.MaxFiles > 0 && len(entries) > limits.MaxFiles {
111111 entries = entries[:limits.MaxFiles]
112112 diff.Truncated = true
113113 }
114+ // Only the files of the window [from, to) get their hunks; the others are
115+ // listed as they are, neither truncated nor counted in the budgets.
116+ from, to := max(limits.From, 0), len(entries)
117+ if limits.To > 0 {
118+ to = min(limits.To, len(entries))
119+ }
114120
115121 // The patch holds one section per file, two for a type change, in the
116122 // order of the entries. Entries without lines to show (binary, pure
117123 // rename, mode change) have a section without hunks.
118124 lastRead := -1
119- for i := range entries {
125+ for i := from; i < to; i++ {
120126 if wantsHunks(&entries[i], limits, limits.MaxTotalLines, limits.MaxTotalBytes) {
121127 lastRead = i
122128 }
@@ -136,8 +142,9 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
136142 if e.typeChange {
137143 sections = 2
138144 }
145+ inWindow := i >= from && i < to
139146 switch {
140- case !changedLines(e):
147+ case !inWindow, !changedLines(e):
141148 // Nothing to show.
142149 case !wantsHunks(e, limits, budget, pr.bytesLeft):
143150 e.file.Truncated = true
M internal/adapters/gitcli/diff_test.go +176 -0
@@ -484,3 +484,179 @@ func TestGetCommitStopsGitEarly(t *testing.T) {
484484 assert.True(t, d.Diff.Files[1].Truncated)
485485 assert.Equal(t, 100005, d.Stats.Additions)
486486 }
487+
488+// windowFixture commits a diff that mixes what the patch reader must step over
489+// in a window: text, binary, a rename, a type change (two sections), a mode
490+// change (no hunks) and more text. It returns the id of the second commit.
491+func windowFixture(t *testing.T, data, name string) (*readertest.Fixture, string) {
492+ t.Helper()
493+ f := readertest.NewFixture(t, data, name)
494+ f.Lines("a-text", "a", 20).WriteBytes("b-bin", []byte{0, 1, 2}).Lines("c-old", "c", 30).
495+ Lines("d-type", "d", 5).Lines("e-mode", "e", 3).Lines("f-text", "f", 20).Lines("g-text", "g", 20)
496+ f.Commit("v1")
497+ f.Lines("a-text", "A", 21).WriteBytes("b-bin", []byte{0, 3}).Mv("c-old", "c-new").
498+ Remove("d-type").Symlink("a-text", "d-type").Chmod("e-mode", 0o755).Lines("f-text", "F", 20).Lines("g-text", "G", 2)
499+ f.Write("c-new", readLines("c", 30)+"extra\n") // a rename with one line added
500+ id := f.Commit("v2")
501+ return f, id
502+}
503+
504+func readLines(prefix string, n int) string {
505+ var b strings.Builder
506+ for i := range n {
507+ fmt.Fprintf(&b, "%s %d\n", prefix, i)
508+ }
509+ return b.String()
510+}
511+
512+func TestDiffLimitsWindow(t *testing.T) {
513+ ctx := context.Background()
514+ g, data := newReader(t, nil)
515+
516+ t.Run("a window reads the hunks of its files only, whatever the files around it", func(t *testing.T) {
517+ f, id := windowFixture(t, data, "win-mixed")
518+ full, err := g.GetCommit(ctx, f.Name, readertest.Rev(t, id), domain.DefaultDiffLimits())
519+ require.NoError(t, err)
520+ assertMatchesGitShow(t, f, id, full)
521+ n := len(full.Diff.Files)
522+ require.GreaterOrEqual(t, n, 7)
523+ status := map[domain.FileStatus]bool{}
524+ for _, file := range full.Diff.Files {
525+ status[file.Status] = true
526+ }
527+ assert.True(t, status[domain.FileTypeChanged], "the fixture has a type change")
528+ assert.True(t, status[domain.FileRenamed], "the fixture has a rename")
529+ assert.False(t, full.Diff.Truncated)
530+
531+ tests := []struct {
532+ name string
533+ from, to int
534+ }{
535+ {"first file", 0, 1},
536+ {"middle", 2, 5},
537+ {"type change alone", 3, 4},
538+ {"binary alone", 1, 2},
539+ {"mode change alone", 4, 5},
540+ {"rename alone", 2, 3},
541+ {"through the end, to 0", 3, 0},
542+ {"to past the end", 5, 100},
543+ {"last file", n - 1, n},
544+ {"from past the end", n, n + 5},
545+ {"far past the end", 1000, 0},
546+ {"empty window", 3, 3},
547+ {"whole diff", 0, 0},
548+ }
549+ for _, tc := range tests {
550+ t.Run(tc.name, func(t *testing.T) {
551+ limits := domain.DefaultDiffLimits()
552+ limits.From, limits.To = tc.from, tc.to
553+ d, err := g.GetCommit(ctx, f.Name, readertest.Rev(t, id), limits)
554+ require.NoError(t, err)
555+ require.Len(t, d.Diff.Files, n)
556+ assert.Equal(t, full.Stats, d.Stats)
557+ assert.False(t, d.Diff.Truncated, "files outside the window do not truncate the diff")
558+ to := tc.to
559+ if to == 0 {
560+ to = n
561+ }
562+ for i, got := range d.Diff.Files {
563+ want := full.Diff.Files[i]
564+ if i < tc.from || i >= to {
565+ want.Hunks = nil
566+ }
567+ assert.Equal(t, want, got, "file %d (%s)", i, want.NewPath)
568+ }
569+ })
570+ }
571+ })
572+
573+ t.Run("files outside the window use none of the total budget", func(t *testing.T) {
574+ f := readertest.NewFixture(t, data, "win-budget")
575+ commitFiles(f, "five", map[string]int{"a": 10, "b": 10, "c": 10, "d": 10, "e": 10})
576+ limits := domain.DiffLimits{MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20, From: 3, To: 5}
577+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
578+ require.NoError(t, err)
579+ require.Len(t, d.Diff.Files, 5)
580+ for i, file := range d.Diff.Files {
581+ switch i {
582+ case 3:
583+ assert.False(t, file.Truncated)
584+ assert.Len(t, file.Hunks, 1, "10 lines fit in 15")
585+ case 4:
586+ assert.True(t, file.Truncated, "the second file of the window does not fit in the 5 lines left")
587+ default:
588+ assert.False(t, file.Truncated)
589+ assert.Empty(t, file.Hunks)
590+ assert.Equal(t, 10, file.Additions, "counts stay accurate")
591+ }
592+ }
593+ assert.True(t, d.Diff.Truncated, "a file of the window is truncated")
594+
595+ limits.From, limits.To = 0, 0
596+ limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file
597+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
598+ require.NoError(t, err)
599+ assert.True(t, d.Diff.Files[0].Truncated)
600+ })
601+
602+ t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) {
603+ f := readertest.NewFixture(t, data, "win-huge")
604+ f.Lines("a", "x", 5).Lines("z-huge", "y", 5000).Commit("c")
605+ limits := domain.DefaultDiffLimits()
606+ limits.From, limits.To = 0, 1
607+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
608+ require.NoError(t, err)
609+ require.Len(t, d.Diff.Files, 2)
610+ assert.Len(t, d.Diff.Files[0].Hunks, 1)
611+ assert.False(t, d.Diff.Files[1].Truncated)
612+ assert.Empty(t, d.Diff.Files[1].Hunks)
613+ assert.False(t, d.Diff.Truncated)
614+ limits.From, limits.To = 1, 2
615+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
616+ require.NoError(t, err)
617+ assert.True(t, d.Diff.Files[1].Truncated, "in the window, over the per-file limit")
618+ assert.True(t, d.Diff.Truncated)
619+ })
620+
621+ t.Run("MaxFiles 0 lists every file", func(t *testing.T) {
622+ f := readertest.NewFixture(t, data, "win-many")
623+ const total = 350
624+ for i := range total {
625+ f.Write(fmt.Sprintf("many/f%03d", i), fmt.Sprintf("x%d\n", i))
626+ }
627+ f.Commit("many")
628+ limits := domain.DefaultDiffLimits()
629+ limits.MaxFiles = 0
630+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
631+ require.NoError(t, err)
632+ require.Len(t, d.Diff.Files, total)
633+ assert.False(t, d.Diff.Truncated)
634+ assert.Equal(t, domain.DiffStats{FilesChanged: total, Additions: total}, d.Stats)
635+ for i, file := range d.Diff.Files {
636+ assert.Equal(t, fmt.Sprintf("many/f%03d", i), file.NewPath)
637+ require.Len(t, file.Hunks, 1, file.NewPath)
638+ }
639+
640+ // A window past the 300th file reads its own hunks.
641+ limits.From, limits.To = 320, 330
642+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
643+ require.NoError(t, err)
644+ require.Len(t, d.Diff.Files, total)
645+ assert.False(t, d.Diff.Truncated)
646+ for i, file := range d.Diff.Files {
647+ if i >= 320 && i < 330 {
648+ require.Len(t, file.Hunks, 1)
649+ assert.Equal(t, fmt.Sprintf("x%d", i), file.Hunks[0].Lines[0].Text)
650+ } else {
651+ assert.Empty(t, file.Hunks)
652+ }
653+ assert.False(t, file.Truncated)
654+ }
655+
656+ // The default limits still cut at 300.
657+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
658+ require.NoError(t, err)
659+ assert.Len(t, d.Diff.Files, 300)
660+ assert.True(t, d.Diff.Truncated)
661+ })
662+}
M internal/adapters/rpc/mergerequest.go +1 -1
@@ -166,7 +166,7 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
166166 if err != nil {
167167 return nil, connectError(ctx, err)
168168 }
169- d, err := s.mrs.Get(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetSinceLastRead())
169+ d, err := s.mrs.Get(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.GetMergeRequestOptions{SinceLastRead: req.Msg.GetSinceLastRead()})
170170 if err != nil {
171171 return nil, connectError(ctx, err)
172172 }
M internal/adapters/rpc/mergerequest_test.go +1 -1
@@ -212,7 +212,7 @@ func TestMergeRequestServiceGet(t *testing.T) {
212212 t.Run(tt.name, func(t *testing.T) {
213213 t.Parallel()
214214 mrs := mocks.NewMergeRequestService(t)
215- mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), true).
215+ mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}).
216216 Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once()
217217 resp, err := (&mergeRequestService{mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))
218218 if tt.wantCode != 0 {
M internal/adapters/web/actions.templ +14 -0
@@ -423,6 +423,20 @@ templ CommitChecks(c ChecksSummary) {
423423 }
424424 }
425425
426+// MRChecksSum is the state of the checks of a merge request in the bar above
427+// the diff, a link to the checks of the rail. The event stream replaces it
428+// with the checks section (same event, fragments.go).
429+templ MRChecksSum(c ChecksSummary) {
430+ <a class="ck-sum mr-ck" id="mr-ck" href="#mr-checks" title={ c.Title }>
431+ if c.Total > 0 {
432+ @CheckIcon(c.Kind, c.State)
433+ <span>{ c.Label }</span>
434+ } else {
435+ <span class="mute">no checks</span>
436+ }
437+ </a>
438+}
439+
426440 // MRChecks is the checks section of the rail of a merge request.
427441 templ MRChecks(c ChecksSection) {
428442 <section aria-labelledby="r-checks" id="mr-checks">
M internal/adapters/web/actions_templ.go +120 -43 generated file: collapsed
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, App, Files, Passkey, Log, Icon string
27+ CSS, HTMX, Live, SSE, App, Files, Passkey, Log, MR, 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
@@ -62,6 +62,7 @@ var scripts = []struct {
6262 {"files.js", func(a *Assets, u string) { a.Files = u }},
6363 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
6464 {"log.js", func(a *Assets, u string) { a.Log = u }},
65+ {"mr.js", func(a *Assets, u string) { a.MR = u }},
6566 }
6667
6768 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):
M internal/adapters/web/diff.go +61 -34
@@ -80,10 +80,6 @@ type DiffHunk struct {
8080 Split []SplitRow
8181 }
8282
83-// Bar is the change bar of the file list, "+++--·····", in three runs so each
84-// can carry its colour.
85-type Bar struct{ Add, Del, Rest string }
86-
8783 // DiffFile is one changed file.
8884 type DiffFile struct {
8985 ID string
@@ -93,12 +89,48 @@ type DiffFile struct {
9389 Key string
9490 Path string // display path, "old → new" for a rename
9591 Add, Del int
96- Bar Bar
92+ // Bin is true for a binary file: no lines to count.
93+ Bin bool
9794 // Note is why there is no text to show, or what else changed: a binary
9895 // file, a mode change, a truncation, a similarity.
9996 Note string
10097 Open bool
10198 Hunks []DiffHunk
99+ // Digits is the width of the largest line number of the file, at least 2
100+ // and at most 7: the gutter is as wide as the file needs (class lnw-N).
101+ Digits int
102+ // Name and Dir are the last element of the path and the directory before
103+ // it ("" at the root), for the compact file list.
104+ Name, Dir string
105+ // Notes is the number of comments on lines of the file, Open those not
106+ // resolved; the file list shows them.
107+ Notes, OpenNotes int
108+ // Lazy is true when the hunks of the file were not read: the page shows a
109+ // placeholder that loads them (Src, the window of files it belongs to).
110+ Lazy bool
111+ Src string
112+ // From is the index of the first file of the window of a lazy file: the
113+ // page without scripts links to ?from=From.
114+ From int
115+ // Height is the size class of a lazy file (1 to 4, by its changed lines),
116+ // so that the placeholder takes about the room of the diff.
117+ Height int
118+ // Comments are the ids of the comments on the file ("c12"): a link to a
119+ // comment of a lazy file loads that file first.
120+ Comments string
121+ // Loose are the comments of a file loaded with its window that are on a
122+ // line its diff does not show: listed at the end of the file.
123+ Loose []CommentView
124+}
125+
126+// gutterDigits is the number of digits of the largest line number of the
127+// hunks, clamped to the widths the style sheet has a class for.
128+func gutterDigits(hunks []domain.Hunk) int {
129+ most := 0
130+ for _, h := range hunks {
131+ most = max(most, h.OldStart+h.OldLines, h.NewStart+h.NewLines)
132+ }
133+ return min(max(len(itoa(most)), 2), 7)
102134 }
103135
104136 func lineNumber(n int) string {
@@ -176,26 +208,6 @@ func pairLines(ls []DiffLine) []SplitRow {
176208 return out
177209 }
178210
179-// changeBar draws the change bar of a file, scaled to the largest file.
180-func changeBar(add, del, most int) Bar {
181- const cells = 10
182- if most == 0 {
183- return Bar{Rest: strings.Repeat("·", cells)}
184- }
185- scale := func(n int) int {
186- c := (n*cells + most - 1) / most
187- if n > 0 && c == 0 {
188- c = 1
189- }
190- return c
191- }
192- a, d := scale(add), scale(del)
193- if a+d > cells {
194- d = cells - a
195- }
196- return Bar{strings.Repeat("+", a), strings.Repeat("-", d), strings.Repeat("·", cells-a-d)}
197-}
198-
199211 // statusLetter is the one-letter status of a file.
200212 func statusLetter(s domain.FileStatus) string {
201213 switch s {
@@ -213,6 +225,27 @@ func statusLetter(s domain.FileStatus) string {
213225 return "M"
214226 }
215227
228+// fileFilterMin is the number of files above which the file list has a
229+// filter.
230+const fileFilterMin = 10
231+
232+// statusName says what a status letter means, for its tooltip.
233+func statusName(letter string) string {
234+ switch letter {
235+ case "A":
236+ return "added"
237+ case "D":
238+ return "deleted"
239+ case "R":
240+ return "renamed"
241+ case "C":
242+ return "copied"
243+ case "T":
244+ return "type changed"
245+ }
246+ return "modified"
247+}
248+
216249 // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk.
217250 func hunkHeader(h domain.Hunk) string {
218251 s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines)
@@ -239,16 +272,11 @@ func diffLine(l domain.Line) DiffLine {
239272
240273 // diffFiles converts the files of a diff for the page.
241274 func diffFiles(files []domain.FileDiff) []DiffFile {
242- most := 0
243- for _, f := range files {
244- most = max(most, f.Additions+f.Deletions)
245- }
246275 out := make([]DiffFile, 0, len(files))
247276 for i, f := range files {
248277 df := DiffFile{
249278 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),
250- Add: f.Additions, Del: f.Deletions,
251- Bar: changeBar(f.Additions, f.Deletions, most),
279+ Add: f.Additions, Del: f.Deletions, Bin: f.Binary,
252280 // A file that is shown in full opens; a file with nothing to read
253281 // (binary, truncated) stays closed under its note.
254282 Open: len(f.Hunks) > 0 && !f.Truncated,
@@ -264,9 +292,8 @@ func diffFiles(files []domain.FileDiff) []DiffFile {
264292 df.Path = visibleText(f.NewPath)
265293 }
266294 df.Note = fileNote(f)
267- if f.Binary {
268- df.Bar = Bar{Rest: "bin"}
269- }
295+ df.Digits = gutterDigits(f.Hunks)
296+ df.Name, df.Dir = splitPath(df.Key)
270297 for _, h := range f.Hunks {
271298 dh := DiffHunk{Header: hunkHeader(h)}
272299 for _, l := range h.Lines {
M internal/adapters/web/diff.templ +57 -7
@@ -141,17 +141,39 @@ templ commitRail(p CommitPage) {
141141 // FileList is the table of contents of a diff: status letter, path, +n -m
142142 // and an ASCII bar. The row of the file in view is marked.
143143 templ FileList(files []DiffFile) {
144+ if len(files) > fileFilterMin {
145+ <div class="ffilter">
146+ <label for="files-filter" class="sr">filter the changed files</label>
147+ <input
148+ id="files-filter"
149+ type="search"
150+ placeholder="Filter files…"
151+ autocomplete="off"
152+ data-pick=".files > li"
153+ hx-on:input="ermite.filter(this)"
154+ />
155+ </div>
156+ }
144157 <ol class="files" aria-label="Changed files">
145158 for _, f := range files {
146159 <li>
147160 <a
148161 href={ templ.SafeURL("#" + f.ID) }
149- hx-on:click="document.getElementById(this.hash.slice(1)).open = true"
162+ title={ f.Path }
163+ hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"
150164 >
151- <span class="st" title={ f.Status }>{ f.Status }</span>
152- <span class="p" title={ f.Path }>{ f.Path }</span>
153- <span class="n"><span class="plus">+{ itoa(f.Add) }</span> <span class="minus">-{ itoa(f.Del) }</span></span>
154- <span class="b" aria-hidden="true"><span class="plus">{ f.Bar.Add }</span><span class="minus">{ f.Bar.Del }</span>{ f.Bar.Rest }</span>
165+ <span class="st" data-st={ f.Status } title={ statusName(f.Status) }>{ f.Status }</span>
166+ <span class="nm"><span class="p">{ f.Name }</span>{ " " }<span class="d"><bdi>{ f.Dir }</bdi></span></span>
167+ if f.OpenNotes > 0 {
168+ <span class="cn" title={ plural(f.OpenNotes, "open comment", "open comments") }>{ itoa(f.OpenNotes) }</span>
169+ } else if f.Notes > 0 {
170+ <span class="cn done" title={ plural(f.Notes, "resolved comment", "resolved comments") }>{ itoa(f.Notes) }</span>
171+ }
172+ if f.Bin {
173+ <span class="n mute">bin</span>
174+ } else {
175+ <span class="n"><span class="plus">+{ itoa(f.Add) }</span> <span class="minus">-{ itoa(f.Del) }</span></span>
176+ }
155177 </a>
156178 </li>
157179 }
@@ -164,7 +186,7 @@ templ FileList(files []DiffFile) {
164186 // colour alone.
165187 templ DiffFileView(f DiffFile, p DiffView) {
166188 <details
167- class="dfile"
189+ class={ "dfile", "lnw-" + itoa(f.Digits) }
168190 id={ f.ID }
169191 data-file
170192 if f.Open {
@@ -177,7 +199,14 @@ templ DiffFileView(f DiffFile, p DiffView) {
177199 { " " }<span class="note">{ f.Note }</span>
178200 }
179201 </summary>
180- if len(f.Hunks) > 0 {
202+ if f.Lazy {
203+ // A placeholder: mr.js fetches the window of the file when it comes
204+ // near the screen and swaps the files of the window in. Without
205+ // scripts the link opens the page of that window.
206+ <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
207+ <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
208+ </div>
209+ } else if len(f.Hunks) > 0 {
181210 <div class="dscroll">
182211 if p.ShowUnified {
183212 <table class="dt uni" aria-label={ f.Path + ", unified" }>
@@ -233,9 +262,30 @@ templ DiffFileView(f DiffFile, p DiffView) {
233262 }
234263 </p>
235264 }
265+ if len(f.Loose) > 0 {
266+ <div class="dloose">
267+ for _, c := range f.Loose {
268+ @commentView(c, "")
269+ }
270+ </div>
271+ }
236272 </details>
237273 }
238274
275+// MRFiles is a window of files of a merge request, for the placeholders of
276+// the page: the files only, which replace the placeholders of the same ids.
277+templ MRFiles(p MRFilesPage) {
278+ for _, f := range p.Files {
279+ @DiffFileView(f, p.DiffView)
280+ }
281+}
282+
283+// MRFilesMoved answers a window of a diff that is not the one of the page any
284+// more: the placeholder shows it instead of a file of another diff.
285+templ MRFilesMoved() {
286+ <p class="notice" data-moved>this merge request changed since the page was loaded — <a href="">reload</a></p>
287+}
288+
239289 // splitSide is one half of a row of the split view; a side without a line is
240290 // an empty cell.
241291 templ splitSide(ln *DiffLine, old bool) {
M internal/adapters/web/diff_templ.go +463 -215 generated file: collapsed
M internal/adapters/web/diff_test.go +0 -9
@@ -85,15 +85,6 @@ func TestPairLines(t *testing.T) {
8585 assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}, newN: 2}, ls[4])
8686 }
8787
88-func TestChangeBar(t *testing.T) {
89- t.Parallel()
90- assert.Equal(t, Bar{Rest: "··········"}, changeBar(0, 0, 0))
91- assert.Equal(t, Bar{Add: "++++++++++"}, changeBar(100, 0, 100))
92- assert.Equal(t, Bar{Add: "+++++", Del: "-----", Rest: ""}, changeBar(50, 50, 100))
93- assert.Equal(t, Bar{Add: "+", Rest: "·········"}, changeBar(1, 0, 1000), "a change is never rounded away")
94- assert.Equal(t, 10, len(changeBar(7, 7, 8).Add+changeBar(7, 7, 8).Del+changeBar(7, 7, 8).Rest), "never wider than the bar")
95-}
96-
9788 func TestFileNotes(t *testing.T) {
9889 t.Parallel()
9990 tests := []struct {
M internal/adapters/web/fragments.go +7 -2
@@ -496,8 +496,13 @@ func (l *liveStream) mrChecks(ctx context.Context) ([]fragment, error) {
496496 case err != nil:
497497 return nil, err
498498 }
499- f, err := renderFragment(ctx, "ci-run", MRChecks(checksSection(repo, l.s.checksFor(ctx, name, l.head))))
500- return []fragment{f}, err
499+ checks := l.s.checksFor(ctx, name, l.head)
500+ f, err := renderFragment(ctx, "ci-run", MRChecks(checksSection(repo, checks)))
501+ if err != nil {
502+ return nil, err
503+ }
504+ sum, err := renderFragment(ctx, "ci-run", MRChecksSum(checksSummary(name, l.head, checks)))
505+ return []fragment{f, sum}, err
501506 }
502507
503508 // recentActions is the "Recent actions" block of the summary rail, read again
A internal/adapters/web/jstest/mr.test.mjs +38 -0
@@ -0,0 +1,38 @@
1+// Tests of the pure core of the merge request page (static/js/mr.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+
6+globalThis.window = globalThis;
7+globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };
8+await import('../static/js/mr.js');
9+const C = window.ermiteMR.core;
10+
11+test('windowOf: the start of the window of a file', () => {
12+ assert.equal(C.windowOf('f1', 25), 0);
13+ assert.equal(C.windowOf('f25', 25), 0);
14+ assert.equal(C.windowOf('f26', 25), 25);
15+ assert.equal(C.windowOf('f84', 25), 75);
16+ assert.equal(C.windowOf('c3', 25), -1, 'not a file');
17+ assert.equal(C.windowOf('f0', 25), -1);
18+});
19+
20+test('menuKey: modes by number, D toggles the deletion, nothing else', () => {
21+ assert.deepEqual(C.menuKey('1', 2), { mode: 0 });
22+ assert.deepEqual(C.menuKey('2', 2), { mode: 1 });
23+ assert.equal(C.menuKey('3', 2), null, 'no third mode');
24+ assert.equal(C.menuKey('0', 2), null);
25+ assert.deepEqual(C.menuKey('D', 2), { toggle: true });
26+ assert.equal(C.menuKey('d', 2), null, 'd is the split layout');
27+ assert.equal(C.menuKey('Enter', 2), null, 'the browser submits: the focus is on the button');
28+ assert.equal(C.menuKey('M', 2), null);
29+});
30+
31+test('step: stays in the list', () => {
32+ assert.equal(C.step(0, 1, 3), 1);
33+ assert.equal(C.step(2, 1, 3), 2);
34+ assert.equal(C.step(0, -1, 3), 0);
35+ assert.equal(C.step(-1, 1, 3), 0);
36+ assert.equal(C.step(-1, -1, 3), 2);
37+ assert.equal(C.step(0, 1, 0), -1);
38+});
M internal/adapters/web/keys.templ +4 -1
@@ -29,6 +29,9 @@ templ Keys() {
2929 k-split from:body -> ermite.layout('split');
3030 k-unified from:body -> ermite.layout('unified');
3131 k-collapse from:body -> ermite.collapse();
32- k-permalink from:body -> ermite.permalink()"
32+ k-permalink from:body -> ermite.permalink();
33+ k-merge from:body -> window.ermiteMR?.openMerge();
34+ k-read from:body -> window.ermiteMR?.markRead();
35+ k-gf from:body -> window.ermiteMR?.focusFiles()"
3336 ></div>
3437 }
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/mr.go +70 -13
@@ -28,6 +28,13 @@ import (
2828 // GET /<repo>/merge-requests/<n> the request with its diff
2929 // GET /<repo>/merge-requests/<n>/since the diff since the last read
3030 // GET /<repo>/compare/<branch> a branch against the default branch
31+// GET /<repo>/merge-requests/<n>/files a window of files of the diff
32+//
33+// The page lists every changed file and reads the hunks of the first window
34+// of files only (mrWindow); the files after it are placeholders that the
35+// browser fills in, a window per request, as they come near the screen
36+// (/files?from=&base=&head=). Without scripts each placeholder links to the
37+// page of its window (?from=), which shows the hunks of that window.
3138 //
3239 // Reading marks nothing as read. MarkRead stores state, and a GET must stay
3340 // safe: a prefetcher, a link preview or a reload of the owner's tab would move
@@ -43,6 +50,9 @@ import (
4350 // forms and the CLI lines exist only in the owner's copy.
4451
4552 const (
53+ // mrWindow is how many files of a merge request one read gets the hunks
54+ // of: the page shows the first window and loads the others.
55+ mrWindow = 25
4656 // mrPerPage is how many decided merge requests a page of the list shows,
4757 // at least; pages of the use case are read whole.
4858 mrPerPage = 50
@@ -114,6 +124,8 @@ type ResolveForm struct {
114124 // Value is the state the form sets.
115125 Value bool
116126 Label string
127+ // From is the window of the page the comment is on (CommentForm.From).
128+ From int
117129 }
118130
119131 // CommentForm is the POST that adds a comment.
@@ -125,6 +137,9 @@ type CommentForm struct {
125137 Path, Line, Side string
126138 Cancel string
127139 Focus bool
140+ // From is the window of the page the form is on (see mrWindow), sent
141+ // back so that the answer returns to it; 0 for the first.
142+ From int
128143 }
129144
130145 // MRCommit is a commit of the request.
@@ -198,8 +213,11 @@ type MRPage struct {
198213 Invisible bool
199214
200215 // General are the comments that are not on a line, Loose those on a line
201- // that the diff shown does not have.
216+ // that the diff shown does not have. With LooseInFiles (a window of files
217+ // loaded later) the loose comments of a listed file go under the file
218+ // instead (DiffFile.Loose).
202219 General, Loose []CommentView
220+ LooseInFiles bool
203221 NComments int
204222 NUnresolved int
205223 GeneralForm *CommentForm
@@ -208,12 +226,16 @@ type MRPage struct {
208226 CommitsNote string
209227 Modes []string
210228 ChecksBlock bool
211- // Checks are the CI checks of the head of the request.
212- Checks ChecksSection
229+ // Checks are the CI checks of the head of the request, ChecksSum their
230+ // state in the bar above the diff.
231+ Checks ChecksSection
232+ ChecksSum ChecksSummary
213233
214234 Box *MergeBox
215235 // Live is true when the page announces changes.
216236 Live bool
237+ // From is the first file of the window the page read the hunks of (?from=).
238+ From int
217239 }
218240
219241 // CompareCommit is a commit of a branch that the target lacks.
@@ -419,10 +441,19 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
419441 if err != nil {
420442 return nil, 0, err
421443 }
422- res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, since)
444+ from, err := parseFrom(r)
423445 if err != nil {
424446 return nil, 0, err
425447 }
448+ res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
449+ SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow},
450+ })
451+ if err != nil {
452+ return nil, 0, err
453+ }
454+ if from > 0 && from >= len(res.Diff.Files) {
455+ return nil, 0, errNotFound
456+ }
426457 m := res.MergeRequest
427458 now := s.cfg.Now()
428459 signed := SignedIn(ctx)
@@ -470,13 +501,20 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
470501 }
471502 }
472503 p.ChecksBlock = d.repo.Settings.FailingChecksBlock
473- if open && head != "" {
474- p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head))
475- } else if head != "" {
476- p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head))
477- p.Checks.Note = ""
504+ if head != "" {
505+ checks := s.checksFor(ctx, d.repo.Name, head)
506+ p.Checks = checksSection(d.repo.Repo, checks)
507+ p.ChecksSum = checksSummary(d.repo.Name, head, checks)
508+ if !open {
509+ p.Checks.Note = ""
510+ }
478511 }
479512 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
513+ countNotes(p.Files, res.Comments)
514+ if !res.DiffBaseID.IsZero() {
515+ lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
516+ }
517+ p.From = from
480518
481519 // The commits of the request, newest first.
482520 for _, c := range res.Commits {
@@ -538,7 +576,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
538576 s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
539577 lines, invisible := diffLines(p.Files)
540578 p.Invisible = invisible
541- p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
579+ p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
542580 if signed && open {
543581 p.Box = s.mergeBox(d, n, m.Source, head, lastRead)
544582 if m.IsDraft() {
@@ -570,6 +608,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
570608 p.Live = true
571609 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))
572610 l.HasRail = true
611+ l.MR = true
573612 return MergeRequest(l, p), http.StatusOK, nil
574613 }
575614
@@ -666,23 +705,30 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
666705 }
667706 index := map[lineKey]*DiffLine{}
668707 fileIDs := map[string]string{}
708+ fileAt := map[string]int{}
669709 for fi := range p.Files {
670710 f := &p.Files[fi]
671711 fileIDs[f.Key] = f.ID
712+ fileAt[f.Key] = fi
672713 linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
714+ // The form of a line shows on the page of the window of its file.
715+ on := self + "?on="
716+ if w := windowStart(fi); w > 0 {
717+ on = self + "?from=" + itoa(w) + "&on="
718+ }
673719 for hi := range f.Hunks {
674720 for li := range f.Hunks[hi].Lines {
675721 ln := &f.Hunks[hi].Lines[li]
676722 if ln.oldN > 0 && ln.Kind != "add" {
677723 index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln
678724 if linkable {
679- ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
725+ ln.OldHref = on + "old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
680726 }
681727 }
682728 if ln.newN > 0 && ln.Kind != "del" {
683729 index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln
684730 if linkable {
685- ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
731+ ln.NewHref = on + "new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
686732 }
687733 }
688734 }
@@ -711,6 +757,11 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
711757 p.NUnresolved++
712758 }
713759 general := c.Path == ""
760+ fi, listed := fileAt[c.Path]
761+ if !general && listed && p.Files[fi].Lazy {
762+ // Its file comes with its window, and the comment with it.
763+ continue
764+ }
714765 var ln *DiffLine
715766 if !general {
716767 ln = find(c.Path, c.Side, c.Line)
@@ -724,6 +775,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
724775 }
725776 if canWrite {
726777 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
778+ if !general && listed {
779+ v.Resolve.From = windowStart(fi)
780+ }
727781 if c.Resolved {
728782 v.Resolve.Label = "Unresolve"
729783 }
@@ -733,6 +787,8 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
733787 ln.Notes = append(ln.Notes, v)
734788 case general:
735789 p.General = append(p.General, v)
790+ case p.LooseInFiles && listed:
791+ p.Files[fi].Loose = append(p.Files[fi].Loose, v)
736792 default:
737793 p.Loose = append(p.Loose, v)
738794 }
@@ -747,7 +803,8 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
747803 ln.Form = &CommentForm{
748804 ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
749805 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
750- Cancel: self + "#" + fileIDs[on.path], Focus: true,
806+ Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
807+ From: windowStart(fileAt[on.path]),
751808 }
752809 }
753810 }
M internal/adapters/web/mr.templ +113 -75
@@ -162,13 +162,7 @@ templ MergeRequest(l Layout, p MRPage) {
162162 if p.SinceNote != "" {
163163 <p class="notice">{ p.SinceNote }</p>
164164 }
165- <div class="diff-tools" role="toolbar" aria-label="Diff view">
166- <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
167- <span class="grow"></span>
168- @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
169- @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
170- @collapseButton()
171- </div>
165+ @mrBar(p)
172166 if p.Invisible {
173167 <p class="notice">
174168 this diff holds invisible characters (direction controls, zero-width characters). They are shown as
@@ -180,6 +174,7 @@ templ MergeRequest(l Layout, p MRPage) {
180174 }
181175 <div
182176 id="diffs"
177+ data-mr
183178 if p.Both {
184179 data-diff={ p.View }
185180 } else {
@@ -256,6 +251,9 @@ templ commentView(c CommentView, suffix string) {
256251 if c.Resolve != nil {
257252 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
258253 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
254+ if c.Resolve.From > 0 {
255+ <input type="hidden" name="from" value={ itoa(c.Resolve.From) }/>
256+ }
259257 <button type="submit" class="btn">{ c.Resolve.Label }</button>
260258 </form>
261259 }
@@ -289,6 +287,9 @@ templ commentForm(f CommentForm, suffix string) {
289287 <input type="hidden" name="line" value={ f.Line }/>
290288 <input type="hidden" name="side" value={ f.Side }/>
291289 }
290+ if f.From > 0 {
291+ <input type="hidden" name="from" value={ itoa(f.From) }/>
292+ }
292293 <div class="f-btns">
293294 <button type="submit" class="btn primary">Comment</button>
294295 if f.Cancel != "" {
@@ -299,25 +300,11 @@ templ commentForm(f CommentForm, suffix string) {
299300 }
300301
301302 templ mrRail(p MRPage) {
302- if p.Box != nil {
303- @mergeBox(p)
304- }
305- @MRChecks(p.Checks)
306- <section aria-labelledby="r-settings">
307- <h2 id="r-settings">Settings</h2>
308- <dl class="kv">
309- <dt>modes</dt>
310- <dd>{ strings.Join(p.Modes, ", ") }</dd>
311- <dt>checks</dt>
312- <dd>
313- if p.ChecksBlock {
314- failing ones block the merge
315- } else {
316- do not block the merge
317- }
318- </dd>
319- </dl>
303+ <section aria-labelledby="r-files" class="r-files">
304+ <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd></h2>
305+ @FileList(p.Files)
320306 </section>
307+ @MRChecks(p.Checks)
321308 <section aria-labelledby="r-commits">
322309 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
323310 <ul class="pcommits">
@@ -344,74 +331,125 @@ templ mrRail(p MRPage) {
344331 <p class="note">{ p.CommitsNote }</p>
345332 }
346333 </section>
347- <section aria-labelledby="r-files">
348- <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
349- @FileList(p.Files)
350- </section>
351-}
352-
353-// mergeBox is the first block of the rail of an open request, for the owner:
354-// the merge form (it carries the head that was read), the terminal lines, and
355-// the other things one does with the request.
356-templ mergeBox(p MRPage) {
357- <section class="decide" aria-labelledby="r-merge" data-state="open">
358- <h2 id="r-merge">Merge</h2>
334+ <section aria-labelledby="r-settings">
335+ <h2 id="r-settings">Settings</h2>
359336 <dl class="kv">
360- <dt>head</dt>
337+ <dt>modes</dt>
338+ <dd>{ strings.Join(p.Modes, ", ") }</dd>
339+ <dt>checks</dt>
361340 <dd>
362- if p.Box.HeadGone {
363- gone: the source branch was deleted
341+ if p.ChecksBlock {
342+ failing ones block the merge
364343 } else {
365- { p.HeadShort }
344+ do not block the merge
366345 }
367346 </dd>
368- <dt>into</dt>
369- <dd>{ p.Target }</dd>
370347 </dl>
371- if p.Box.ReadyAction != "" {
372- <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
373- <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
374- <button type="submit" class="btn primary" title="remove the draft prefix from the title">Ready for review</button>
375- </form>
376- } else if !p.Box.HeadGone {
377- <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
378- <input type="hidden" name="head_id" value={ p.Box.Head }/>
379- <label class="sr" for="merge-mode">merge mode</label>
380- <select id="merge-mode" name="mode">
381- for _, m := range p.Box.Modes {
382- <option value={ m.Value }>{ m.Label }</option>
383- }
384- </select>
385- if p.Box.CanDeleteBranch {
386- <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge</label>
387- }
388- <div class="d-btns">
389- <button type="submit" class="btn primary">Merge</button>
390- </div>
391- </form>
348+ </section>
349+}
350+
351+// mrBar is the bar above the diff, which follows the review: the totals, the
352+// layout of the diff, the state of the checks and, for the owner of an open
353+// request, the merge menu and the other actions. Each menu is a <details>: it
354+// works without scripts; with them, M and the keys of the menu drive it.
355+templ mrBar(p MRPage) {
356+ <div class="diff-tools mr-bar" role="toolbar" aria-label="Diff view and merge">
357+ <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
358+ @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
359+ @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
360+ @collapseButton()
361+ <span class="grow"></span>
362+ @MRChecksSum(p.ChecksSum)
363+ if p.Box != nil {
364+ @mergeMenu(p)
365+ @moreMenu(p)
392366 }
393- <div class="d-btns">
367+ </div>
368+}
369+
370+// mergeMenu is the merge of an open request, for the owner: the form (it
371+// carries the head that was read), or "Ready for review" for a draft. The
372+// mode and the branch deletion have keys (1-3, D) while it is open.
373+templ mergeMenu(p MRPage) {
374+ <details class="menu mmenu" id="merge-menu" data-state="open">
375+ <summary class="btn primary" aria-keyshortcuts="Shift+M">
376+ if p.Box.ReadyAction != "" {
377+ Draft
378+ } else {
379+ Merge
380+ }
381+ <kbd aria-hidden="true">M</kbd>
382+ </summary>
383+ <div class="menu-pop" role="group" aria-label="Merge">
384+ <dl class="kv">
385+ <dt>head</dt>
386+ <dd>
387+ if p.Box.HeadGone {
388+ gone: the source branch was deleted
389+ } else {
390+ { p.HeadShort }
391+ }
392+ </dd>
393+ <dt>into</dt>
394+ <dd>{ p.Target }</dd>
395+ </dl>
396+ if p.Box.ReadyAction != "" {
397+ <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
398+ <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
399+ <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>
400+ </form>
401+ } else if !p.Box.HeadGone {
402+ <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
403+ <input type="hidden" name="head_id" value={ p.Box.Head }/>
404+ <fieldset class="d-modes">
405+ <legend class="sr">merge mode</legend>
406+ for i, m := range p.Box.Modes {
407+ <label>
408+ <input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/>
409+ { m.Label }
410+ if i < 9 {
411+ <kbd aria-hidden="true">{ itoa(i + 1) }</kbd>
412+ }
413+ </label>
414+ }
415+ </fieldset>
416+ if p.Box.CanDeleteBranch {
417+ <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>
418+ }
419+ <div class="d-btns">
420+ <button type="submit" class="btn primary" data-default>Merge into { p.Target }</button>
421+ </div>
422+ </form>
423+ }
424+ </div>
425+ </details>
426+}
427+
428+// moreMenu is what else the owner does with an open request: mark the head
429+// read, close it, merge it from a terminal.
430+templ moreMenu(p MRPage) {
431+ <details class="menu omenu">
432+ <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>
433+ <div class="menu-pop" role="group" aria-label="More actions">
394434 if p.Box.CanMarkRead {
395435 <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
396436 <input type="hidden" name="head_id" value={ p.Box.Head }/>
397- <button type="submit" title="the next visit can show only what changed after this head" class="btn">Mark read</button>
437+ <button type="submit" title="the next visit can show only what changed after this head" class="btn" aria-keyshortcuts="Shift+R" data-read>Mark read <kbd aria-hidden="true">R</kbd></button>
398438 </form>
399439 }
400440 <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
401441 <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
402442 </form>
443+ if !p.Box.HeadGone && p.Box.ReadyAction == "" {
444+ <details class="d-or">
445+ <summary class="mute">or from a terminal</summary>
446+ @CloneBlock(p.Box.CLI)
447+ </details>
448+ }
403449 </div>
404- if !p.Box.HeadGone && p.Box.ReadyAction == "" {
405- <details class="d-or">
406- <summary class="mute">or from a terminal</summary>
407- @CloneBlock(p.Box.CLI)
408- </details>
409- }
410- </section>
450+ </details>
411451 }
412452
413-// Compare is a branch against the default branch: the commits it is ahead by,
414-// its diff when the API can give one, and the form that opens a merge request.
415453 templ Compare(l Layout, p ComparePage) {
416454 @Shell(l, compareRail(p)) {
417455 <header class="show mr-h">
A internal/adapters/web/mr_files.go +219 -0
@@ -0,0 +1,219 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "net/url"
10+ "strconv"
11+ "strings"
12+
13+ "github.com/a-h/templ"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// The diff of a merge request is read a window of files at a time. The page
20+// reads the files of one window (the first, or the one ?from= names) with
21+// their hunks and lists the others as placeholders; a placeholder holds the
22+// address of its window, which the browser fetches when it comes near the
23+// screen and swaps in (mr.js). The window carries the base and the head the
24+// page shows: when they moved, the use case refuses (ErrConflict) and the
25+// placeholder offers to reload instead of mixing two diffs in one page.
26+
27+// MRFilesPage is a window of files of a merge request, for /files: the files
28+// of the window, rendered like in the page, and nothing else.
29+type MRFilesPage struct {
30+ Files []DiffFile
31+ DiffView
32+}
33+
34+// windowStart is the first file of the window that holds file i.
35+func windowStart(i int) int { return i - i%mrWindow }
36+
37+// withFrom adds the window of a page to its address: the page of the files
38+// [from, from+mrWindow) when from is not the first window.
39+func withFrom(href string, from int) string {
40+ if from <= 0 {
41+ return href
42+ }
43+ sep := "?"
44+ if strings.Contains(href, "?") {
45+ sep = "&"
46+ }
47+ return href + sep + "from=" + itoa(from)
48+}
49+
50+// formFrom reads the window a form was posted from (a hidden field "from"):
51+// 0 when absent or not a window start, so that a redirect never carries
52+// anything else.
53+func formFrom(r *http.Request) int {
54+ s := r.PostFormValue("from")
55+ n, err := strconv.Atoi(s)
56+ if err != nil || n <= 0 || n%mrWindow != 0 || strconv.Itoa(n) != s {
57+ return 0
58+ }
59+ return n
60+}
61+
62+// parseFrom reads ?from=: the start of a window, 0 when absent. Anything else
63+// (not a number, negative, not a window start) is not a page.
64+func parseFrom(r *http.Request) (int, error) {
65+ s := r.URL.Query().Get("from")
66+ if s == "" {
67+ return 0, nil
68+ }
69+ n, err := strconv.Atoi(s)
70+ if err != nil || n < 0 || n%mrWindow != 0 || strconv.Itoa(n) != s {
71+ return 0, errNotFound
72+ }
73+ return n, nil
74+}
75+
76+// 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 the
78+// base and the head of the diff; self the page, for the links of a page
79+// without scripts.
80+func lazyFiles(files []DiffFile, from int, src string) {
81+ for i := range files {
82+ f := &files[i]
83+ if i >= from && i < from+mrWindow {
84+ continue
85+ }
86+ f.Lazy = true
87+ f.From = windowStart(i)
88+ f.Src = src + "&from=" + itoa(f.From)
89+ f.Height = lazyHeight(f.Add + f.Del)
90+ f.Open = !f.Bin && f.Add+f.Del > 0
91+ }
92+}
93+
94+// lazyHeight is the size class of the placeholder of a file with n changed
95+// lines: about the room its diff takes, so that the page does not jump much
96+// when it comes in.
97+func lazyHeight(n int) int {
98+ switch {
99+ case n <= 10:
100+ return 1
101+ case n <= 50:
102+ return 2
103+ case n <= 200:
104+ return 3
105+ default:
106+ return 4
107+ }
108+}
109+
110+// filesSrc is the address of the windows of a diff.
111+func filesSrc(repo string, n int64, since bool, base, head string) string {
112+ q := url.Values{}
113+ if since {
114+ q.Set("since", "1")
115+ }
116+ q.Set("base", base)
117+ q.Set("head", head)
118+ return mrHref(repo, n, "files") + "?" + q.Encode()
119+}
120+
121+// countNotes sets, on each file, the number of comments on its lines and the
122+// ids of those comments.
123+func countNotes(files []DiffFile, comments []domain.Comment) {
124+ at := make(map[string]int, len(files))
125+ for i, f := range files {
126+ at[f.Key] = i
127+ }
128+ ids := make([][]string, len(files))
129+ for _, c := range comments {
130+ i, ok := at[c.Path]
131+ if c.Path == "" || !ok {
132+ continue
133+ }
134+ files[i].Notes++
135+ if !c.Resolved {
136+ files[i].OpenNotes++
137+ }
138+ ids[i] = append(ids[i], "c"+strconv.FormatInt(c.ID, 10))
139+ }
140+ for i := range files {
141+ files[i].Comments = strings.Join(ids[i], " ")
142+ }
143+}
144+
145+// splitPath is the last element of a path and the directory before it.
146+func splitPath(p string) (name, dir string) {
147+ i := strings.LastIndexByte(p, '/')
148+ if i < 0 {
149+ return visibleText(p), ""
150+ }
151+ return visibleText(p[i+1:]), visibleText(p[:i])
152+}
153+
154+// mrFiles is GET /<repo>/merge-requests/<n>/files?from=&base=&head=[&since=1]:
155+// the files of a window with their hunks and their comments, for a
156+// placeholder of the page. A base or a head that is not the one of the diff
157+// any more answers 409: the page says to reload.
158+//
159+//hexcheck:ignore suspicious-business-logic-in-adapter builds a window of the merge request page from the use case results; no rule is decided here
160+func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
161+ n, err := mrNumber(r)
162+ if err != nil {
163+ return nil, 0, err
164+ }
165+ q := r.URL.Query()
166+ since := q.Get("since") == "1"
167+ if since && !SignedIn(ctx) {
168+ return nil, 0, errNotFound
169+ }
170+ from, err := parseFrom(r)
171+ if err != nil {
172+ return nil, 0, err
173+ }
174+ base, errB := domain.ParseObjectID(q.Get("base"))
175+ head, errH := domain.ParseObjectID(q.Get("head"))
176+ if errB != nil || errH != nil {
177+ return nil, 0, errNotFound
178+ }
179+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
180+ if err != nil {
181+ return nil, 0, err
182+ }
183+ 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},
186+ })
187+ if errors.Is(err, domain.ErrConflict) {
188+ return MRFilesMoved(), http.StatusConflict, nil
189+ }
190+ if err != nil {
191+ return nil, 0, err
192+ }
193+ if from >= len(res.Diff.Files) {
194+ return nil, 0, errNotFound
195+ }
196+ m := res.MergeRequest
197+ signed := SignedIn(ctx)
198+ canWrite := signed && m.State == domain.MRStateOpen && !since
199+ p := MRPage{Files: diffFiles(res.Diff.Files), LooseInFiles: true}
200+ s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
201+ window := p.Files[from:min(from+mrWindow, len(p.Files))]
202+ // The layouts follow the page: it sends ?view= (the layout shown) and
203+ // ?layouts=both when it holds both layouts and switches them itself, so
204+ // that every window of the page renders the same way.
205+ self := mrHref(d.name(), n)
206+ if since {
207+ self = mrHref(d.name(), n, "since")
208+ }
209+ lines, _ := diffLines(window)
210+ v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
211+ switch r.URL.Query().Get("layouts") {
212+ case "both":
213+ v.Both = true
214+ case "one":
215+ v.Both = false
216+ }
217+ v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
218+ return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil
219+}
M internal/adapters/web/mr_post.go +2 -0
@@ -181,6 +181,7 @@ func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string
181181 if err != nil {
182182 return "", back, err
183183 }
184+ back = withFrom(back, formFrom(r))
184185 c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params)
185186 if err != nil {
186187 return "", back, err
@@ -209,6 +210,7 @@ func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string
209210 // An id this server never issued is 0: the use case answers not found,
210211 // after the repository and the scope were checked, as for any unknown
211212 // comment.
213+ back = withFrom(back, formFrom(r))
212214 c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved)
213215 if err != nil {
214216 return "", back, err
M internal/adapters/web/mr_templ.go +767 -574
@@ -931,142 +931,95 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
931931 return templ_7745c5c3_Err
932932 }
933933 }
934- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, " <div class=\"diff-tools\" role=\"toolbar\" aria-label=\"Diff view\"><span class=\"dim\">")
934+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, " ")
935935 if templ_7745c5c3_Err != nil {
936936 return templ_7745c5c3_Err
937937 }
938- var templ_7745c5c3_Var48 string
939- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
940- if templ_7745c5c3_Err != nil {
941- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 166, Col: 56}
942- }
943- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
944- if templ_7745c5c3_Err != nil {
945- return templ_7745c5c3_Err
946- }
947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " · <span class=\"plus\">+")
948- if templ_7745c5c3_Err != nil {
949- return templ_7745c5c3_Err
950- }
951- var templ_7745c5c3_Var49 string
952- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
953- if templ_7745c5c3_Err != nil {
954- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 166, Col: 96}
955- }
956- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
957- if templ_7745c5c3_Err != nil {
958- return templ_7745c5c3_Err
959- }
960- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span> <span class=\"minus\">-")
961- if templ_7745c5c3_Err != nil {
962- return templ_7745c5c3_Err
963- }
964- var templ_7745c5c3_Var50 string
965- templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
966- if templ_7745c5c3_Err != nil {
967- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 166, Col: 141}
968- }
969- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
970- if templ_7745c5c3_Err != nil {
971- return templ_7745c5c3_Err
972- }
973- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</span></span> <span class=\"grow\"></span>")
938+ templ_7745c5c3_Err = mrBar(p).Render(ctx, templ_7745c5c3_Buffer)
974939 if templ_7745c5c3_Err != nil {
975940 return templ_7745c5c3_Err
976941 }
977- templ_7745c5c3_Err = viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref).Render(ctx, templ_7745c5c3_Buffer)
978- if templ_7745c5c3_Err != nil {
979- return templ_7745c5c3_Err
980- }
981- templ_7745c5c3_Err = viewLink(p.DiffView, "split", "Split", "d", p.SplitHref).Render(ctx, templ_7745c5c3_Buffer)
982- if templ_7745c5c3_Err != nil {
983- return templ_7745c5c3_Err
984- }
985- templ_7745c5c3_Err = collapseButton().Render(ctx, templ_7745c5c3_Buffer)
986- if templ_7745c5c3_Err != nil {
987- return templ_7745c5c3_Err
988- }
989- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</div>")
942+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " ")
990943 if templ_7745c5c3_Err != nil {
991944 return templ_7745c5c3_Err
992945 }
993946 if p.Invisible {
994- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
947+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
995948 if templ_7745c5c3_Err != nil {
996949 return templ_7745c5c3_Err
997950 }
998951 }
999- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " ")
952+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " ")
1000953 if templ_7745c5c3_Err != nil {
1001954 return templ_7745c5c3_Err
1002955 }
1003956 if p.Note != "" {
1004- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<p class=\"notice\">")
957+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<p class=\"notice\">")
1005958 if templ_7745c5c3_Err != nil {
1006959 return templ_7745c5c3_Err
1007960 }
1008- var templ_7745c5c3_Var51 string
1009- templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
961+ var templ_7745c5c3_Var48 string
962+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
1010963 if templ_7745c5c3_Err != nil {
1011- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 179, Col: 29}
964+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 173, Col: 29}
1012965 }
1013- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
966+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
1014967 if templ_7745c5c3_Err != nil {
1015968 return templ_7745c5c3_Err
1016969 }
1017- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</p>")
970+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</p>")
1018971 if templ_7745c5c3_Err != nil {
1019972 return templ_7745c5c3_Err
1020973 }
1021974 }
1022- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, " <div id=\"diffs\"")
975+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " <div id=\"diffs\" data-mr")
1023976 if templ_7745c5c3_Err != nil {
1024977 return templ_7745c5c3_Err
1025978 }
1026979 if p.Both {
1027- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, " data-diff=\"")
980+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " data-diff=\"")
1028981 if templ_7745c5c3_Err != nil {
1029982 return templ_7745c5c3_Err
1030983 }
1031- var templ_7745c5c3_Var52 string
1032- templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
984+ var templ_7745c5c3_Var49 string
985+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
1033986 if templ_7745c5c3_Err != nil {
1034- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 184, Col: 22}
987+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 179, Col: 22}
1035988 }
1036- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
989+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
1037990 if templ_7745c5c3_Err != nil {
1038991 return templ_7745c5c3_Err
1039992 }
1040- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\"")
993+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\"")
1041994 if templ_7745c5c3_Err != nil {
1042995 return templ_7745c5c3_Err
1043996 }
1044997 } else {
1045- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, " data-view=\"")
998+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, " data-view=\"")
1046999 if templ_7745c5c3_Err != nil {
10471000 return templ_7745c5c3_Err
10481001 }
1049- var templ_7745c5c3_Var53 string
1050- templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
1002+ var templ_7745c5c3_Var50 string
1003+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
10511004 if templ_7745c5c3_Err != nil {
1052- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 186, Col: 22}
1005+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 181, Col: 22}
10531006 }
1054- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
1007+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
10551008 if templ_7745c5c3_Err != nil {
10561009 return templ_7745c5c3_Err
10571010 }
1058- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\"")
1011+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\"")
10591012 if templ_7745c5c3_Err != nil {
10601013 return templ_7745c5c3_Err
10611014 }
10621015 }
10631016 if p.Both && !p.Pinned {
1064- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
1017+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
10651018 if templ_7745c5c3_Err != nil {
10661019 return templ_7745c5c3_Err
10671020 }
10681021 }
1069- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, ">")
1022+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, ">")
10701023 if templ_7745c5c3_Err != nil {
10711024 return templ_7745c5c3_Err
10721025 }
@@ -1077,38 +1030,38 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
10771030 }
10781031 }
10791032 if len(p.Files) == 0 {
1080- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<p class=\"note\">no changed files.</p>")
1033+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<p class=\"note\">no changed files.</p>")
10811034 if templ_7745c5c3_Err != nil {
10821035 return templ_7745c5c3_Err
10831036 }
10841037 }
1085- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</div><h2 class=\"rule-h\" id=\"comments\"><span>Comments</span><span class=\"count\">")
1038+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</div><h2 class=\"rule-h\" id=\"comments\"><span>Comments</span><span class=\"count\">")
10861039 if templ_7745c5c3_Err != nil {
10871040 return templ_7745c5c3_Err
10881041 }
1089- var templ_7745c5c3_Var54 string
1090- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.NUnresolved))
1042+ var templ_7745c5c3_Var51 string
1043+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.NUnresolved))
10911044 if templ_7745c5c3_Err != nil {
1092- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 199, Col: 97}
1045+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 194, Col: 97}
10931046 }
1094- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
1047+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
10951048 if templ_7745c5c3_Err != nil {
10961049 return templ_7745c5c3_Err
10971050 }
1098- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, " open / ")
1051+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, " open / ")
10991052 if templ_7745c5c3_Err != nil {
11001053 return templ_7745c5c3_Err
11011054 }
1102- var templ_7745c5c3_Var55 string
1103- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.NComments))
1055+ var templ_7745c5c3_Var52 string
1056+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.NComments))
11041057 if templ_7745c5c3_Err != nil {
1105- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 199, Col: 126}
1058+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 194, Col: 126}
11061059 }
1107- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1060+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
11081061 if templ_7745c5c3_Err != nil {
11091062 return templ_7745c5c3_Err
11101063 }
1111- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</span></h2>")
1064+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</span></h2>")
11121065 if templ_7745c5c3_Err != nil {
11131066 return templ_7745c5c3_Err
11141067 }
@@ -1124,17 +1077,17 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
11241077 return templ_7745c5c3_Err
11251078 }
11261079 }
1127- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, " ")
1080+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " ")
11281081 if templ_7745c5c3_Err != nil {
11291082 return templ_7745c5c3_Err
11301083 }
11311084 if p.NComments == 0 {
1132- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<p class=\"dim\">no comments yet.</p>")
1085+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<p class=\"dim\">no comments yet.</p>")
11331086 if templ_7745c5c3_Err != nil {
11341087 return templ_7745c5c3_Err
11351088 }
11361089 }
1137- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, " ")
1090+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, " ")
11381091 if templ_7745c5c3_Err != nil {
11391092 return templ_7745c5c3_Err
11401093 }
@@ -1170,49 +1123,49 @@ func branchLink(name, href string) templ.Component {
11701123 }()
11711124 }
11721125 ctx = templ.InitializeContext(ctx)
1173- templ_7745c5c3_Var56 := templ.GetChildren(ctx)
1174- if templ_7745c5c3_Var56 == nil {
1175- templ_7745c5c3_Var56 = templ.NopComponent
1126+ templ_7745c5c3_Var53 := templ.GetChildren(ctx)
1127+ if templ_7745c5c3_Var53 == nil {
1128+ templ_7745c5c3_Var53 = templ.NopComponent
11761129 }
11771130 ctx = templ.ClearChildren(ctx)
11781131 if href != "" {
1179- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<a href=\"")
1132+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<a href=\"")
11801133 if templ_7745c5c3_Err != nil {
11811134 return templ_7745c5c3_Err
11821135 }
1183- var templ_7745c5c3_Var57 templ.SafeURL
1184- templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
1136+ var templ_7745c5c3_Var54 templ.SafeURL
1137+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
11851138 if templ_7745c5c3_Err != nil {
1186- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 217, Col: 31}
1139+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 212, Col: 31}
11871140 }
1188- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
1141+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
11891142 if templ_7745c5c3_Err != nil {
11901143 return templ_7745c5c3_Err
11911144 }
1192- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\">")
1145+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\">")
11931146 if templ_7745c5c3_Err != nil {
11941147 return templ_7745c5c3_Err
11951148 }
1196- var templ_7745c5c3_Var58 string
1197- templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(name)
1149+ var templ_7745c5c3_Var55 string
1150+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(name)
11981151 if templ_7745c5c3_Err != nil {
1199- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 217, Col: 40}
1152+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 212, Col: 40}
12001153 }
1201- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
1154+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
12021155 if templ_7745c5c3_Err != nil {
12031156 return templ_7745c5c3_Err
12041157 }
1205- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</a>")
1158+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</a>")
12061159 if templ_7745c5c3_Err != nil {
12071160 return templ_7745c5c3_Err
12081161 }
12091162 } else {
1210- var templ_7745c5c3_Var59 string
1211- templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(name)
1163+ var templ_7745c5c3_Var56 string
1164+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(name)
12121165 if templ_7745c5c3_Err != nil {
1213- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 219, Col: 8}
1166+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 214, Col: 8}
12141167 }
1215- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
1168+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
12161169 if templ_7745c5c3_Err != nil {
12171170 return templ_7745c5c3_Err
12181171 }
@@ -1240,9 +1193,9 @@ func lineNotes(notes []CommentView, form *CommentForm, suffix string) templ.Comp
12401193 }()
12411194 }
12421195 ctx = templ.InitializeContext(ctx)
1243- templ_7745c5c3_Var60 := templ.GetChildren(ctx)
1244- if templ_7745c5c3_Var60 == nil {
1245- templ_7745c5c3_Var60 = templ.NopComponent
1196+ templ_7745c5c3_Var57 := templ.GetChildren(ctx)
1197+ if templ_7745c5c3_Var57 == nil {
1198+ templ_7745c5c3_Var57 = templ.NopComponent
12461199 }
12471200 ctx = templ.ClearChildren(ctx)
12481201 for _, c := range notes {
@@ -1280,167 +1233,167 @@ func commentView(c CommentView, suffix string) templ.Component {
12801233 }()
12811234 }
12821235 ctx = templ.InitializeContext(ctx)
1283- templ_7745c5c3_Var61 := templ.GetChildren(ctx)
1284- if templ_7745c5c3_Var61 == nil {
1285- templ_7745c5c3_Var61 = templ.NopComponent
1236+ templ_7745c5c3_Var58 := templ.GetChildren(ctx)
1237+ if templ_7745c5c3_Var58 == nil {
1238+ templ_7745c5c3_Var58 = templ.NopComponent
12861239 }
12871240 ctx = templ.ClearChildren(ctx)
1288- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "<div class=\"cmt\" id=\"")
1241+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<div class=\"cmt\" id=\"")
12891242 if templ_7745c5c3_Err != nil {
12901243 return templ_7745c5c3_Err
12911244 }
1292- var templ_7745c5c3_Var62 string
1293- templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue("c" + c.ID + suffix)
1245+ var templ_7745c5c3_Var59 string
1246+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue("c" + c.ID + suffix)
12941247 if templ_7745c5c3_Err != nil {
1295- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 239, Col: 42}
1248+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 234, Col: 42}
12961249 }
1297- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
1250+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
12981251 if templ_7745c5c3_Err != nil {
12991252 return templ_7745c5c3_Err
13001253 }
1301- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\" data-resolved=\"")
1254+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\" data-resolved=\"")
13021255 if templ_7745c5c3_Err != nil {
13031256 return templ_7745c5c3_Err
13041257 }
1305- var templ_7745c5c3_Var63 string
1306- templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolved))
1258+ var templ_7745c5c3_Var60 string
1259+ templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolved))
13071260 if templ_7745c5c3_Err != nil {
1308- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 239, Col: 81}
1261+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 234, Col: 81}
13091262 }
1310- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
1263+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
13111264 if templ_7745c5c3_Err != nil {
13121265 return templ_7745c5c3_Err
13131266 }
1314- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\"><p class=\"c-head\"><span class=\"c-who\">")
1267+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\"><p class=\"c-head\"><span class=\"c-who\">")
13151268 if templ_7745c5c3_Err != nil {
13161269 return templ_7745c5c3_Err
13171270 }
1318- var templ_7745c5c3_Var64 string
1319- templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
1271+ var templ_7745c5c3_Var61 string
1272+ templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
13201273 if templ_7745c5c3_Err != nil {
1321- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 241, Col: 33}
1274+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 236, Col: 33}
13221275 }
1323- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
1276+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
13241277 if templ_7745c5c3_Err != nil {
13251278 return templ_7745c5c3_Err
13261279 }
1327- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</span> ")
1280+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</span> ")
13281281 if templ_7745c5c3_Err != nil {
13291282 return templ_7745c5c3_Err
13301283 }
13311284 if c.WhenAt != "" {
1332- var templ_7745c5c3_Var65 string
1333- templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1285+ var templ_7745c5c3_Var62 string
1286+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
13341287 if templ_7745c5c3_Err != nil {
1335- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 243, Col: 9}
1288+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 238, Col: 9}
13361289 }
1337- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1290+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
13381291 if templ_7745c5c3_Err != nil {
13391292 return templ_7745c5c3_Err
13401293 }
1341- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "<time class=\"mute\" datetime=\"")
1294+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<time class=\"mute\" datetime=\"")
13421295 if templ_7745c5c3_Err != nil {
13431296 return templ_7745c5c3_Err
13441297 }
1345- var templ_7745c5c3_Var66 string
1346- templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
1298+ var templ_7745c5c3_Var63 string
1299+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
13471300 if templ_7745c5c3_Err != nil {
1348- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 243, Col: 49}
1301+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 238, Col: 49}
13491302 }
1350- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
1303+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
13511304 if templ_7745c5c3_Err != nil {
13521305 return templ_7745c5c3_Err
13531306 }
1354- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\" title=\"")
1307+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" title=\"")
13551308 if templ_7745c5c3_Err != nil {
13561309 return templ_7745c5c3_Err
13571310 }
1358- var templ_7745c5c3_Var67 string
1359- templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Abs)
1311+ var templ_7745c5c3_Var64 string
1312+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Abs)
13601313 if templ_7745c5c3_Err != nil {
1361- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 243, Col: 65}
1314+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 238, Col: 65}
13621315 }
1363- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
1316+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
13641317 if templ_7745c5c3_Err != nil {
13651318 return templ_7745c5c3_Err
13661319 }
1367- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\">")
1320+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\">")
13681321 if templ_7745c5c3_Err != nil {
13691322 return templ_7745c5c3_Err
13701323 }
1371- var templ_7745c5c3_Var68 string
1372- templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
1324+ var templ_7745c5c3_Var65 string
1325+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
13731326 if templ_7745c5c3_Err != nil {
1374- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 243, Col: 76}
1327+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 238, Col: 76}
13751328 }
1376- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
1329+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
13771330 if templ_7745c5c3_Err != nil {
13781331 return templ_7745c5c3_Err
13791332 }
1380- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</time> ")
1333+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</time> ")
13811334 if templ_7745c5c3_Err != nil {
13821335 return templ_7745c5c3_Err
13831336 }
13841337 }
13851338 if c.Resolved {
1386- var templ_7745c5c3_Var69 string
1387- templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1339+ var templ_7745c5c3_Var66 string
1340+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
13881341 if templ_7745c5c3_Err != nil {
1389- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 246, Col: 9}
1342+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 241, Col: 9}
13901343 }
1391- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
1344+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
13921345 if templ_7745c5c3_Err != nil {
13931346 return templ_7745c5c3_Err
13941347 }
1395- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "<span class=\"c-state\">resolved</span> ")
1348+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<span class=\"c-state\">resolved</span> ")
13961349 if templ_7745c5c3_Err != nil {
13971350 return templ_7745c5c3_Err
13981351 }
13991352 }
14001353 if c.Outdated {
1401- var templ_7745c5c3_Var70 string
1402- templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1354+ var templ_7745c5c3_Var67 string
1355+ templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
14031356 if templ_7745c5c3_Err != nil {
1404- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 249, Col: 9}
1357+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 244, Col: 9}
14051358 }
1406- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
1359+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
14071360 if templ_7745c5c3_Err != nil {
14081361 return templ_7745c5c3_Err
14091362 }
1410- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "<span class=\"c-state\" title=\"written on an earlier head; the line may have moved\">outdated</span> ")
1363+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "<span class=\"c-state\" title=\"written on an earlier head; the line may have moved\">outdated</span> ")
14111364 if templ_7745c5c3_Err != nil {
14121365 return templ_7745c5c3_Err
14131366 }
14141367 }
14151368 if c.Where != "" {
1416- var templ_7745c5c3_Var71 string
1417- templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1369+ var templ_7745c5c3_Var68 string
1370+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
14181371 if templ_7745c5c3_Err != nil {
1419- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 252, Col: 9}
1372+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 247, Col: 9}
14201373 }
1421- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
1374+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
14221375 if templ_7745c5c3_Err != nil {
14231376 return templ_7745c5c3_Err
14241377 }
1425- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "<span class=\"c-where mute\">")
1378+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<span class=\"c-where mute\">")
14261379 if templ_7745c5c3_Err != nil {
14271380 return templ_7745c5c3_Err
14281381 }
1429- var templ_7745c5c3_Var72 string
1430- templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(c.Where)
1382+ var templ_7745c5c3_Var69 string
1383+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(c.Where)
14311384 if templ_7745c5c3_Err != nil {
1432- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 252, Col: 47}
1385+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 247, Col: 47}
14331386 }
1434- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
1387+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
14351388 if templ_7745c5c3_Err != nil {
14361389 return templ_7745c5c3_Err
14371390 }
1438- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "</span>")
1391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</span>")
14391392 if templ_7745c5c3_Err != nil {
14401393 return templ_7745c5c3_Err
14411394 }
14421395 }
1443- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</p><p class=\"c-text\">")
1396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</p><p class=\"c-text\">")
14441397 if templ_7745c5c3_Err != nil {
14451398 return templ_7745c5c3_Err
14461399 }
@@ -1448,47 +1401,70 @@ func commentView(c CommentView, suffix string) templ.Component {
14481401 if templ_7745c5c3_Err != nil {
14491402 return templ_7745c5c3_Err
14501403 }
1451- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "</p>")
1404+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</p>")
14521405 if templ_7745c5c3_Err != nil {
14531406 return templ_7745c5c3_Err
14541407 }
14551408 if c.Resolve != nil {
1456- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<form class=\"c-act\" method=\"post\" action=\"")
1409+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "<form class=\"c-act\" method=\"post\" action=\"")
14571410 if templ_7745c5c3_Err != nil {
14581411 return templ_7745c5c3_Err
14591412 }
1460- var templ_7745c5c3_Var73 templ.SafeURL
1461- templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Resolve.Action))
1413+ var templ_7745c5c3_Var70 templ.SafeURL
1414+ templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Resolve.Action))
14621415 if templ_7745c5c3_Err != nil {
1463- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 257, Col: 77}
1416+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 252, Col: 77}
14641417 }
1465- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
1418+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
14661419 if templ_7745c5c3_Err != nil {
14671420 return templ_7745c5c3_Err
14681421 }
1469- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\"><input type=\"hidden\" name=\"resolved\" value=\"")
1422+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\"><input type=\"hidden\" name=\"resolved\" value=\"")
14701423 if templ_7745c5c3_Err != nil {
14711424 return templ_7745c5c3_Err
14721425 }
1473- var templ_7745c5c3_Var74 string
1474- templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolve.Value))
1426+ var templ_7745c5c3_Var71 string
1427+ templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolve.Value))
14751428 if templ_7745c5c3_Err != nil {
1476- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 258, Col: 74}
1429+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 253, Col: 74}
14771430 }
1478- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
1431+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
14791432 if templ_7745c5c3_Err != nil {
14801433 return templ_7745c5c3_Err
14811434 }
1482- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\"> <button type=\"submit\" class=\"btn\">")
1435+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\"> ")
14831436 if templ_7745c5c3_Err != nil {
14841437 return templ_7745c5c3_Err
14851438 }
1486- var templ_7745c5c3_Var75 string
1487- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(c.Resolve.Label)
1439+ if c.Resolve.From > 0 {
1440+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<input type=\"hidden\" name=\"from\" value=\"")
1441+ if templ_7745c5c3_Err != nil {
1442+ return templ_7745c5c3_Err
1443+ }
1444+ var templ_7745c5c3_Var72 string
1445+ templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Resolve.From))
1446+ if templ_7745c5c3_Err != nil {
1447+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 255, Col: 66}
1448+ }
1449+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
1450+ if templ_7745c5c3_Err != nil {
1451+ return templ_7745c5c3_Err
1452+ }
1453+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\"> ")
1454+ if templ_7745c5c3_Err != nil {
1455+ return templ_7745c5c3_Err
1456+ }
1457+ }
1458+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<button type=\"submit\" class=\"btn\">")
1459+ if templ_7745c5c3_Err != nil {
1460+ return templ_7745c5c3_Err
1461+ }
1462+ var templ_7745c5c3_Var73 string
1463+ templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(c.Resolve.Label)
14881464 if templ_7745c5c3_Err != nil {
1489- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 259, Col: 55}
1465+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 257, Col: 55}
14901466 }
1491- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
1467+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
14921468 if templ_7745c5c3_Err != nil {
14931469 return templ_7745c5c3_Err
14941470 }
@@ -1529,21 +1505,21 @@ func commentForm(f CommentForm, suffix string) templ.Component {
15291505 }()
15301506 }
15311507 ctx = templ.InitializeContext(ctx)
1532- templ_7745c5c3_Var76 := templ.GetChildren(ctx)
1533- if templ_7745c5c3_Var76 == nil {
1534- templ_7745c5c3_Var76 = templ.NopComponent
1508+ templ_7745c5c3_Var74 := templ.GetChildren(ctx)
1509+ if templ_7745c5c3_Var74 == nil {
1510+ templ_7745c5c3_Var74 = templ.NopComponent
15351511 }
15361512 ctx = templ.ClearChildren(ctx)
15371513 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<form class=\"cform\" method=\"post\" action=\"")
15381514 if templ_7745c5c3_Err != nil {
15391515 return templ_7745c5c3_Err
15401516 }
1541- var templ_7745c5c3_Var77 templ.SafeURL
1542- templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Action))
1517+ var templ_7745c5c3_Var75 templ.SafeURL
1518+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Action))
15431519 if templ_7745c5c3_Err != nil {
1544- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 274, Col: 67}
1520+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 272, Col: 67}
15451521 }
1546- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77))
1522+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
15471523 if templ_7745c5c3_Err != nil {
15481524 return templ_7745c5c3_Err
15491525 }
@@ -1551,12 +1527,12 @@ func commentForm(f CommentForm, suffix string) templ.Component {
15511527 if templ_7745c5c3_Err != nil {
15521528 return templ_7745c5c3_Err
15531529 }
1554- var templ_7745c5c3_Var78 string
1555- templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
1530+ var templ_7745c5c3_Var76 string
1531+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
15561532 if templ_7745c5c3_Err != nil {
1557- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 275, Col: 28}
1533+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 273, Col: 28}
15581534 }
1559- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)
1535+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var76)
15601536 if templ_7745c5c3_Err != nil {
15611537 return templ_7745c5c3_Err
15621538 }
@@ -1564,12 +1540,12 @@ func commentForm(f CommentForm, suffix string) templ.Component {
15641540 if templ_7745c5c3_Err != nil {
15651541 return templ_7745c5c3_Err
15661542 }
1567- var templ_7745c5c3_Var79 string
1568- templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
1543+ var templ_7745c5c3_Var77 string
1544+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
15691545 if templ_7745c5c3_Err != nil {
1570- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 275, Col: 40}
1546+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 273, Col: 40}
15711547 }
1572- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))
1548+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77))
15731549 if templ_7745c5c3_Err != nil {
15741550 return templ_7745c5c3_Err
15751551 }
@@ -1577,12 +1553,12 @@ func commentForm(f CommentForm, suffix string) templ.Component {
15771553 if templ_7745c5c3_Err != nil {
15781554 return templ_7745c5c3_Err
15791555 }
1580- var templ_7745c5c3_Var80 string
1581- templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
1556+ var templ_7745c5c3_Var78 string
1557+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
15821558 if templ_7745c5c3_Err != nil {
1583- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 277, Col: 21}
1559+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 275, Col: 21}
15841560 }
1585- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1561+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)
15861562 if templ_7745c5c3_Err != nil {
15871563 return templ_7745c5c3_Err
15881564 }
@@ -1605,12 +1581,12 @@ func commentForm(f CommentForm, suffix string) templ.Component {
16051581 if templ_7745c5c3_Err != nil {
16061582 return templ_7745c5c3_Err
16071583 }
1608- var templ_7745c5c3_Var81 string
1609- templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
1584+ var templ_7745c5c3_Var79 string
1585+ templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
16101586 if templ_7745c5c3_Err != nil {
1611- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 288, Col: 50}
1587+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 286, Col: 50}
16121588 }
1613- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var81)
1589+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
16141590 if templ_7745c5c3_Err != nil {
16151591 return templ_7745c5c3_Err
16161592 }
@@ -1618,12 +1594,12 @@ func commentForm(f CommentForm, suffix string) templ.Component {
16181594 if templ_7745c5c3_Err != nil {
16191595 return templ_7745c5c3_Err
16201596 }
1621- var templ_7745c5c3_Var82 string
1622- templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Line)
1597+ var templ_7745c5c3_Var80 string
1598+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Line)
16231599 if templ_7745c5c3_Err != nil {
1624- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 289, Col: 50}
1600+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 287, Col: 50}
16251601 }
1626- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
1602+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
16271603 if templ_7745c5c3_Err != nil {
16281604 return templ_7745c5c3_Err
16291605 }
@@ -1631,44 +1607,63 @@ func commentForm(f CommentForm, suffix string) templ.Component {
16311607 if templ_7745c5c3_Err != nil {
16321608 return templ_7745c5c3_Err
16331609 }
1634- var templ_7745c5c3_Var83 string
1635- templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Side)
1610+ var templ_7745c5c3_Var81 string
1611+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Side)
16361612 if templ_7745c5c3_Err != nil {
1637- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 290, Col: 50}
1613+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 288, Col: 50}
16381614 }
1639- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83)
1615+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var81)
1616+ if templ_7745c5c3_Err != nil {
1617+ return templ_7745c5c3_Err
1618+ }
1619+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "\"> ")
1620+ if templ_7745c5c3_Err != nil {
1621+ return templ_7745c5c3_Err
1622+ }
1623+ }
1624+ if f.From > 0 {
1625+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<input type=\"hidden\" name=\"from\" value=\"")
1626+ if templ_7745c5c3_Err != nil {
1627+ return templ_7745c5c3_Err
1628+ }
1629+ var templ_7745c5c3_Var82 string
1630+ templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(f.From))
1631+ if templ_7745c5c3_Err != nil {
1632+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 291, Col: 56}
1633+ }
1634+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
16401635 if templ_7745c5c3_Err != nil {
16411636 return templ_7745c5c3_Err
16421637 }
1643- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "\">")
1638+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\">")
16441639 if templ_7745c5c3_Err != nil {
16451640 return templ_7745c5c3_Err
16461641 }
16471642 }
1648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Comment</button> ")
1643+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Comment</button> ")
16491644 if templ_7745c5c3_Err != nil {
16501645 return templ_7745c5c3_Err
16511646 }
16521647 if f.Cancel != "" {
1653- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<a class=\"btn\" href=\"")
1648+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<a class=\"btn\" href=\"")
16541649 if templ_7745c5c3_Err != nil {
16551650 return templ_7745c5c3_Err
16561651 }
1657- var templ_7745c5c3_Var84 templ.SafeURL
1658- templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Cancel))
1652+ var templ_7745c5c3_Var83 templ.SafeURL
1653+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Cancel))
16591654 if templ_7745c5c3_Err != nil {
1660- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 295, Col: 49}
1655+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 296, Col: 49}
16611656 }
1662- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84))
1657+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
16631658 if templ_7745c5c3_Err != nil {
16641659 return templ_7745c5c3_Err
16651660 }
1666- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\">Cancel</a>")
1661+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\">Cancel</a>")
16671662 if templ_7745c5c3_Err != nil {
16681663 return templ_7745c5c3_Err
16691664 }
16701665 }
1671- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "</div></form>")
1666+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</div></form>")
16721667 if templ_7745c5c3_Err != nil {
16731668 return templ_7745c5c3_Err
16741669 }
@@ -1692,241 +1687,349 @@ func mrRail(p MRPage) templ.Component {
16921687 }()
16931688 }
16941689 ctx = templ.InitializeContext(ctx)
1695- templ_7745c5c3_Var85 := templ.GetChildren(ctx)
1696- if templ_7745c5c3_Var85 == nil {
1697- templ_7745c5c3_Var85 = templ.NopComponent
1690+ templ_7745c5c3_Var84 := templ.GetChildren(ctx)
1691+ if templ_7745c5c3_Var84 == nil {
1692+ templ_7745c5c3_Var84 = templ.NopComponent
16981693 }
16991694 ctx = templ.ClearChildren(ctx)
1700- if p.Box != nil {
1701- templ_7745c5c3_Err = mergeBox(p).Render(ctx, templ_7745c5c3_Buffer)
1702- if templ_7745c5c3_Err != nil {
1703- return templ_7745c5c3_Err
1704- }
1705- }
1706- templ_7745c5c3_Err = MRChecks(p.Checks).Render(ctx, templ_7745c5c3_Buffer)
1695+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<section aria-labelledby=\"r-files\" class=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
17071696 if templ_7745c5c3_Err != nil {
17081697 return templ_7745c5c3_Err
17091698 }
1710- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
1699+ var templ_7745c5c3_Var85 string
1700+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
1701+ if templ_7745c5c3_Err != nil {
1702+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 304, Col: 65}
1703+ }
1704+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
17111705 if templ_7745c5c3_Err != nil {
17121706 return templ_7745c5c3_Err
17131707 }
1714- var templ_7745c5c3_Var86 string
1715- templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
1708+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</span><kbd aria-hidden=\"true\">g f</kbd></h2>")
17161709 if templ_7745c5c3_Err != nil {
1717- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 310, Col: 36}
1710+ return templ_7745c5c3_Err
17181711 }
1719- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
1712+ templ_7745c5c3_Err = FileList(p.Files).Render(ctx, templ_7745c5c3_Buffer)
17201713 if templ_7745c5c3_Err != nil {
17211714 return templ_7745c5c3_Err
17221715 }
1723- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</dd><dt>checks</dt><dd>")
1716+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</section>")
17241717 if templ_7745c5c3_Err != nil {
17251718 return templ_7745c5c3_Err
17261719 }
1727- if p.ChecksBlock {
1728- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "failing ones block the merge")
1729- if templ_7745c5c3_Err != nil {
1730- return templ_7745c5c3_Err
1731- }
1732- } else {
1733- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "do not block the merge")
1734- if templ_7745c5c3_Err != nil {
1735- return templ_7745c5c3_Err
1736- }
1720+ templ_7745c5c3_Err = MRChecks(p.Checks).Render(ctx, templ_7745c5c3_Buffer)
1721+ if templ_7745c5c3_Err != nil {
1722+ return templ_7745c5c3_Err
17371723 }
1738- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</dd></dl></section><section aria-labelledby=\"r-commits\"><h2 id=\"r-commits\">Commits <span class=\"count\">")
1724+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<section aria-labelledby=\"r-commits\"><h2 id=\"r-commits\">Commits <span class=\"count\">")
17391725 if templ_7745c5c3_Err != nil {
17401726 return templ_7745c5c3_Err
17411727 }
1742- var templ_7745c5c3_Var87 string
1743- templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
1728+ var templ_7745c5c3_Var86 string
1729+ templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
17441730 if templ_7745c5c3_Err != nil {
1745- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 322, Col: 71}
1731+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 309, Col: 71}
17461732 }
1747- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
1733+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
17481734 if templ_7745c5c3_Err != nil {
17491735 return templ_7745c5c3_Err
17501736 }
1751- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</span></h2><ul class=\"pcommits\">")
1737+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</span></h2><ul class=\"pcommits\">")
17521738 if templ_7745c5c3_Err != nil {
17531739 return templ_7745c5c3_Err
17541740 }
17551741 for _, c := range p.Commits {
1756- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<li")
1742+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<li")
17571743 if templ_7745c5c3_Err != nil {
17581744 return templ_7745c5c3_Err
17591745 }
17601746 if c.New {
1761- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, " data-new")
1747+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, " data-new")
17621748 if templ_7745c5c3_Err != nil {
17631749 return templ_7745c5c3_Err
17641750 }
17651751 }
1766- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "><a href=\"")
1752+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "><a href=\"")
17671753 if templ_7745c5c3_Err != nil {
17681754 return templ_7745c5c3_Err
17691755 }
1770- var templ_7745c5c3_Var88 templ.SafeURL
1771- templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
1756+ var templ_7745c5c3_Var87 templ.SafeURL
1757+ templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
17721758 if templ_7745c5c3_Err != nil {
1773- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 36}
1759+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 313, Col: 36}
17741760 }
1775- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88))
1761+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
17761762 if templ_7745c5c3_Err != nil {
17771763 return templ_7745c5c3_Err
17781764 }
1779- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "\">")
1765+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\">")
17801766 if templ_7745c5c3_Err != nil {
17811767 return templ_7745c5c3_Err
17821768 }
1783- var templ_7745c5c3_Var89 string
1784- templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
1769+ var templ_7745c5c3_Var88 string
1770+ templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
17851771 if templ_7745c5c3_Err != nil {
1786- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 48}
1772+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 313, Col: 48}
17871773 }
1788- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
1774+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88))
17891775 if templ_7745c5c3_Err != nil {
17901776 return templ_7745c5c3_Err
17911777 }
1792- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</a> <span class=\"s\">")
1778+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</a> <span class=\"s\">")
17931779 if templ_7745c5c3_Err != nil {
17941780 return templ_7745c5c3_Err
17951781 }
1796- var templ_7745c5c3_Var90 string
1797- templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
1782+ var templ_7745c5c3_Var89 string
1783+ templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
17981784 if templ_7745c5c3_Err != nil {
1799- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 32}
1785+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 314, Col: 32}
18001786 }
1801- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90))
1787+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
18021788 if templ_7745c5c3_Err != nil {
18031789 return templ_7745c5c3_Err
18041790 }
1805- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</span> <span class=\"mute\">")
1791+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</span> <span class=\"mute\">")
18061792 if templ_7745c5c3_Err != nil {
18071793 return templ_7745c5c3_Err
18081794 }
1809- var templ_7745c5c3_Var91 string
1810- templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
1795+ var templ_7745c5c3_Var90 string
1796+ templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
18111797 if templ_7745c5c3_Err != nil {
1812- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 329, Col: 16}
1798+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 316, Col: 16}
18131799 }
1814- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
1800+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90))
18151801 if templ_7745c5c3_Err != nil {
18161802 return templ_7745c5c3_Err
18171803 }
1818- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, " ")
1804+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, " ")
18191805 if templ_7745c5c3_Err != nil {
18201806 return templ_7745c5c3_Err
18211807 }
18221808 if c.WhenAt != "" {
1823- var templ_7745c5c3_Var92 string
1824- templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
1809+ var templ_7745c5c3_Var91 string
1810+ templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
18251811 if templ_7745c5c3_Err != nil {
1826- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 15}
1812+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 318, Col: 15}
18271813 }
1828- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
1814+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
18291815 if templ_7745c5c3_Err != nil {
18301816 return templ_7745c5c3_Err
18311817 }
1832- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<time datetime=\"")
1818+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "<time datetime=\"")
18331819 if templ_7745c5c3_Err != nil {
18341820 return templ_7745c5c3_Err
18351821 }
1836- var templ_7745c5c3_Var93 string
1837- templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
1822+ var templ_7745c5c3_Var92 string
1823+ templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
18381824 if templ_7745c5c3_Err != nil {
1839- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 42}
1825+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 318, Col: 42}
18401826 }
1841- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var93)
1827+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
18421828 if templ_7745c5c3_Err != nil {
18431829 return templ_7745c5c3_Err
18441830 }
1845- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "\">")
1831+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "\">")
18461832 if templ_7745c5c3_Err != nil {
18471833 return templ_7745c5c3_Err
18481834 }
1849- var templ_7745c5c3_Var94 string
1850- templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
1835+ var templ_7745c5c3_Var93 string
1836+ templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
18511837 if templ_7745c5c3_Err != nil {
1852- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 53}
1838+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 318, Col: 53}
18531839 }
1854- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94))
1840+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
18551841 if templ_7745c5c3_Err != nil {
18561842 return templ_7745c5c3_Err
18571843 }
1858- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</time> ")
1844+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</time> ")
18591845 if templ_7745c5c3_Err != nil {
18601846 return templ_7745c5c3_Err
18611847 }
18621848 }
18631849 if c.New {
1864- var templ_7745c5c3_Var95 string
1865- templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")
1850+ var templ_7745c5c3_Var94 string
1851+ templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")
18661852 if templ_7745c5c3_Err != nil {
1867- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 334, Col: 18}
1853+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 321, Col: 18}
18681854 }
1869- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
1855+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94))
18701856 if templ_7745c5c3_Err != nil {
18711857 return templ_7745c5c3_Err
18721858 }
18731859 }
1874- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</span></li>")
1860+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "</span></li>")
18751861 if templ_7745c5c3_Err != nil {
18761862 return templ_7745c5c3_Err
18771863 }
18781864 }
1879- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "</ul>")
1865+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "</ul>")
18801866 if templ_7745c5c3_Err != nil {
18811867 return templ_7745c5c3_Err
18821868 }
18831869 if len(p.Commits) == 0 {
1884- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<p class=\"dim\">no commit to merge.</p>")
1870+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "<p class=\"dim\">no commit to merge.</p>")
18851871 if templ_7745c5c3_Err != nil {
18861872 return templ_7745c5c3_Err
18871873 }
18881874 }
18891875 if p.CommitsNote != "" {
1890- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "<p class=\"note\">")
1876+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "<p class=\"note\">")
18911877 if templ_7745c5c3_Err != nil {
18921878 return templ_7745c5c3_Err
18931879 }
1894- var templ_7745c5c3_Var96 string
1895- templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)
1880+ var templ_7745c5c3_Var95 string
1881+ templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)
18961882 if templ_7745c5c3_Err != nil {
1897- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 344, Col: 34}
1883+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 34}
18981884 }
1899- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
1885+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
19001886 if templ_7745c5c3_Err != nil {
19011887 return templ_7745c5c3_Err
19021888 }
1903- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "</p>")
1889+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</p>")
19041890 if templ_7745c5c3_Err != nil {
19051891 return templ_7745c5c3_Err
19061892 }
19071893 }
1908- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</section><section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
1894+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
19091895 if templ_7745c5c3_Err != nil {
19101896 return templ_7745c5c3_Err
19111897 }
1912- var templ_7745c5c3_Var97 string
1913- templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
1898+ var templ_7745c5c3_Var96 string
1899+ templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
19141900 if templ_7745c5c3_Err != nil {
1915- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 348, Col: 65}
1901+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 338, Col: 36}
19161902 }
1917- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
1903+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
19181904 if templ_7745c5c3_Err != nil {
19191905 return templ_7745c5c3_Err
19201906 }
1921- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</span></h2>")
1907+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "</dd><dt>checks</dt><dd>")
19221908 if templ_7745c5c3_Err != nil {
19231909 return templ_7745c5c3_Err
19241910 }
1925- templ_7745c5c3_Err = FileList(p.Files).Render(ctx, templ_7745c5c3_Buffer)
1911+ if p.ChecksBlock {
1912+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "failing ones block the merge")
1913+ if templ_7745c5c3_Err != nil {
1914+ return templ_7745c5c3_Err
1915+ }
1916+ } else {
1917+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "do not block the merge")
1918+ if templ_7745c5c3_Err != nil {
1919+ return templ_7745c5c3_Err
1920+ }
1921+ }
1922+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</dd></dl></section>")
1923+ if templ_7745c5c3_Err != nil {
1924+ return templ_7745c5c3_Err
1925+ }
1926+ return nil
1927+ })
1928+}
1929+
1930+// mrBar is the bar above the diff, which follows the review: the totals, the
1931+// layout of the diff, the state of the checks and, for the owner of an open
1932+// request, the merge menu and the other actions. Each menu is a <details>: it
1933+// works without scripts; with them, M and the keys of the menu drive it.
1934+func mrBar(p MRPage) templ.Component {
1935+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1936+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1937+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1938+ return templ_7745c5c3_CtxErr
1939+ }
1940+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1941+ if !templ_7745c5c3_IsBuffer {
1942+ defer func() {
1943+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1944+ if templ_7745c5c3_Err == nil {
1945+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1946+ }
1947+ }()
1948+ }
1949+ ctx = templ.InitializeContext(ctx)
1950+ templ_7745c5c3_Var97 := templ.GetChildren(ctx)
1951+ if templ_7745c5c3_Var97 == nil {
1952+ templ_7745c5c3_Var97 = templ.NopComponent
1953+ }
1954+ ctx = templ.ClearChildren(ctx)
1955+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "<div class=\"diff-tools mr-bar\" role=\"toolbar\" aria-label=\"Diff view and merge\"><span class=\"dim\">")
1956+ if templ_7745c5c3_Err != nil {
1957+ return templ_7745c5c3_Err
1958+ }
1959+ var templ_7745c5c3_Var98 string
1960+ templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
1961+ if templ_7745c5c3_Err != nil {
1962+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 357, Col: 55}
1963+ }
1964+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
1965+ if templ_7745c5c3_Err != nil {
1966+ return templ_7745c5c3_Err
1967+ }
1968+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, " · <span class=\"plus\">+")
1969+ if templ_7745c5c3_Err != nil {
1970+ return templ_7745c5c3_Err
1971+ }
1972+ var templ_7745c5c3_Var99 string
1973+ templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
1974+ if templ_7745c5c3_Err != nil {
1975+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 357, Col: 95}
1976+ }
1977+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
1978+ if templ_7745c5c3_Err != nil {
1979+ return templ_7745c5c3_Err
1980+ }
1981+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "</span> <span class=\"minus\">-")
1982+ if templ_7745c5c3_Err != nil {
1983+ return templ_7745c5c3_Err
1984+ }
1985+ var templ_7745c5c3_Var100 string
1986+ templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
1987+ if templ_7745c5c3_Err != nil {
1988+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 357, Col: 140}
1989+ }
1990+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
1991+ if templ_7745c5c3_Err != nil {
1992+ return templ_7745c5c3_Err
1993+ }
1994+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</span></span>")
1995+ if templ_7745c5c3_Err != nil {
1996+ return templ_7745c5c3_Err
1997+ }
1998+ templ_7745c5c3_Err = viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref).Render(ctx, templ_7745c5c3_Buffer)
1999+ if templ_7745c5c3_Err != nil {
2000+ return templ_7745c5c3_Err
2001+ }
2002+ templ_7745c5c3_Err = viewLink(p.DiffView, "split", "Split", "d", p.SplitHref).Render(ctx, templ_7745c5c3_Buffer)
2003+ if templ_7745c5c3_Err != nil {
2004+ return templ_7745c5c3_Err
2005+ }
2006+ templ_7745c5c3_Err = collapseButton().Render(ctx, templ_7745c5c3_Buffer)
19262007 if templ_7745c5c3_Err != nil {
19272008 return templ_7745c5c3_Err
19282009 }
1929- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "</section>")
2010+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "<span class=\"grow\"></span>")
2011+ if templ_7745c5c3_Err != nil {
2012+ return templ_7745c5c3_Err
2013+ }
2014+ templ_7745c5c3_Err = MRChecksSum(p.ChecksSum).Render(ctx, templ_7745c5c3_Buffer)
2015+ if templ_7745c5c3_Err != nil {
2016+ return templ_7745c5c3_Err
2017+ }
2018+ if p.Box != nil {
2019+ templ_7745c5c3_Err = mergeMenu(p).Render(ctx, templ_7745c5c3_Buffer)
2020+ if templ_7745c5c3_Err != nil {
2021+ return templ_7745c5c3_Err
2022+ }
2023+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, " ")
2024+ if templ_7745c5c3_Err != nil {
2025+ return templ_7745c5c3_Err
2026+ }
2027+ templ_7745c5c3_Err = moreMenu(p).Render(ctx, templ_7745c5c3_Buffer)
2028+ if templ_7745c5c3_Err != nil {
2029+ return templ_7745c5c3_Err
2030+ }
2031+ }
2032+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "</div>")
19302033 if templ_7745c5c3_Err != nil {
19312034 return templ_7745c5c3_Err
19322035 }
@@ -1934,10 +2037,10 @@ func mrRail(p MRPage) templ.Component {
19342037 })
19352038 }
19362039
1937-// mergeBox is the first block of the rail of an open request, for the owner:
1938-// the merge form (it carries the head that was read), the terminal lines, and
1939-// the other things one does with the request.
1940-func mergeBox(p MRPage) templ.Component {
2040+// mergeMenu is the merge of an open request, for the owner: the form (it
2041+// carries the head that was read), or "Ready for review" for a draft. The
2042+// mode and the branch deletion have keys (1-3, D) while it is open.
2043+func mergeMenu(p MRPage) templ.Component {
19412044 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
19422045 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
19432046 if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -1953,212 +2056,304 @@ func mergeBox(p MRPage) templ.Component {
19532056 }()
19542057 }
19552058 ctx = templ.InitializeContext(ctx)
1956- templ_7745c5c3_Var98 := templ.GetChildren(ctx)
1957- if templ_7745c5c3_Var98 == nil {
1958- templ_7745c5c3_Var98 = templ.NopComponent
2059+ templ_7745c5c3_Var101 := templ.GetChildren(ctx)
2060+ if templ_7745c5c3_Var101 == nil {
2061+ templ_7745c5c3_Var101 = templ.NopComponent
19592062 }
19602063 ctx = templ.ClearChildren(ctx)
1961- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<section class=\"decide\" aria-labelledby=\"r-merge\" data-state=\"open\"><h2 id=\"r-merge\">Merge</h2><dl class=\"kv\"><dt>head</dt><dd>")
2064+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "<details class=\"menu mmenu\" id=\"merge-menu\" data-state=\"open\"><summary class=\"btn primary\" aria-keyshortcuts=\"Shift+M\">")
2065+ if templ_7745c5c3_Err != nil {
2066+ return templ_7745c5c3_Err
2067+ }
2068+ if p.Box.ReadyAction != "" {
2069+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "Draft ")
2070+ if templ_7745c5c3_Err != nil {
2071+ return templ_7745c5c3_Err
2072+ }
2073+ } else {
2074+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "Merge ")
2075+ if templ_7745c5c3_Err != nil {
2076+ return templ_7745c5c3_Err
2077+ }
2078+ }
2079+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "<kbd aria-hidden=\"true\">M</kbd></summary><div class=\"menu-pop\" role=\"group\" aria-label=\"Merge\"><dl class=\"kv\"><dt>head</dt><dd>")
19622080 if templ_7745c5c3_Err != nil {
19632081 return templ_7745c5c3_Err
19642082 }
19652083 if p.Box.HeadGone {
1966- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "gone: the source branch was deleted")
2084+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "gone: the source branch was deleted")
19672085 if templ_7745c5c3_Err != nil {
19682086 return templ_7745c5c3_Err
19692087 }
19702088 } else {
1971- var templ_7745c5c3_Var99 string
1972- templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)
2089+ var templ_7745c5c3_Var102 string
2090+ templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)
19732091 if templ_7745c5c3_Err != nil {
1974- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 365, Col: 18}
2092+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 390, Col: 19}
19752093 }
1976- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
2094+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
19772095 if templ_7745c5c3_Err != nil {
19782096 return templ_7745c5c3_Err
19792097 }
19802098 }
1981- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</dd><dt>into</dt><dd>")
2099+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "</dd><dt>into</dt><dd>")
19822100 if templ_7745c5c3_Err != nil {
19832101 return templ_7745c5c3_Err
19842102 }
1985- var templ_7745c5c3_Var100 string
1986- templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2103+ var templ_7745c5c3_Var103 string
2104+ templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
19872105 if templ_7745c5c3_Err != nil {
1988- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 369, Col: 17}
2106+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 18}
19892107 }
1990- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
2108+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
19912109 if templ_7745c5c3_Err != nil {
19922110 return templ_7745c5c3_Err
19932111 }
1994- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</dd></dl>")
2112+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "</dd></dl>")
19952113 if templ_7745c5c3_Err != nil {
19962114 return templ_7745c5c3_Err
19972115 }
19982116 if p.Box.ReadyAction != "" {
1999- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "<p class=\"d-draft\">this is a draft: it cannot be merged until it is ready for review.</p><form class=\"d-btns\" method=\"post\" action=\"")
2117+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "<p class=\"d-draft\">this is a draft: it cannot be merged until it is ready for review.</p><form class=\"d-btns\" method=\"post\" action=\"")
20002118 if templ_7745c5c3_Err != nil {
20012119 return templ_7745c5c3_Err
20022120 }
2003- var templ_7745c5c3_Var101 templ.SafeURL
2004- templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadyAction))
2121+ var templ_7745c5c3_Var104 templ.SafeURL
2122+ templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadyAction))
20052123 if templ_7745c5c3_Err != nil {
2006- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 373, Col: 79}
2124+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 398, Col: 80}
20072125 }
2008- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
2126+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
20092127 if templ_7745c5c3_Err != nil {
20102128 return templ_7745c5c3_Err
20112129 }
2012- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "\"><button type=\"submit\" class=\"btn primary\" title=\"remove the draft prefix from the title\">Ready for review</button></form>")
2130+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "\"><button type=\"submit\" class=\"btn primary\" data-default title=\"remove the draft prefix from the title\">Ready for review</button></form>")
20132131 if templ_7745c5c3_Err != nil {
20142132 return templ_7745c5c3_Err
20152133 }
20162134 } else if !p.Box.HeadGone {
2017- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<form class=\"d-acts\" method=\"post\" action=\"")
2135+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "<form class=\"d-acts\" method=\"post\" action=\"")
20182136 if templ_7745c5c3_Err != nil {
20192137 return templ_7745c5c3_Err
20202138 }
2021- var templ_7745c5c3_Var102 templ.SafeURL
2022- templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
2139+ var templ_7745c5c3_Var105 templ.SafeURL
2140+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
20232141 if templ_7745c5c3_Err != nil {
2024- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 79}
2142+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 402, Col: 80}
20252143 }
2026- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
2144+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
20272145 if templ_7745c5c3_Err != nil {
20282146 return templ_7745c5c3_Err
20292147 }
2030- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2148+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
20312149 if templ_7745c5c3_Err != nil {
20322150 return templ_7745c5c3_Err
20332151 }
2034- var templ_7745c5c3_Var103 string
2035- templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2152+ var templ_7745c5c3_Var106 string
2153+ templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
20362154 if templ_7745c5c3_Err != nil {
2037- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 378, Col: 58}
2155+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 403, Col: 59}
20382156 }
2039- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var103)
2157+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var106)
20402158 if templ_7745c5c3_Err != nil {
20412159 return templ_7745c5c3_Err
20422160 }
2043- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "\"> <label class=\"sr\" for=\"merge-mode\">merge mode</label> <select id=\"merge-mode\" name=\"mode\">")
2161+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "\"><fieldset class=\"d-modes\"><legend class=\"sr\">merge mode</legend> ")
20442162 if templ_7745c5c3_Err != nil {
20452163 return templ_7745c5c3_Err
20462164 }
2047- for _, m := range p.Box.Modes {
2048- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<option value=\"")
2165+ for i, m := range p.Box.Modes {
2166+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "<label><input type=\"radio\" name=\"mode\" value=\"")
20492167 if templ_7745c5c3_Err != nil {
20502168 return templ_7745c5c3_Err
20512169 }
2052- var templ_7745c5c3_Var104 string
2053- templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
2170+ var templ_7745c5c3_Var107 string
2171+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
20542172 if templ_7745c5c3_Err != nil {
2055- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 382, Col: 29}
2173+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 408, Col: 55}
20562174 }
2057- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var104)
2175+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var107)
20582176 if templ_7745c5c3_Err != nil {
20592177 return templ_7745c5c3_Err
20602178 }
2061- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "\">")
2179+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "\"")
20622180 if templ_7745c5c3_Err != nil {
20632181 return templ_7745c5c3_Err
20642182 }
2065- var templ_7745c5c3_Var105 string
2066- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
2183+ if i == 0 {
2184+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, " checked")
2185+ if templ_7745c5c3_Err != nil {
2186+ return templ_7745c5c3_Err
2187+ }
2188+ }
2189+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "> ")
20672190 if templ_7745c5c3_Err != nil {
2068- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 382, Col: 41}
2191+ return templ_7745c5c3_Err
20692192 }
2070- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
2193+ var templ_7745c5c3_Var108 string
2194+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
2195+ if templ_7745c5c3_Err != nil {
2196+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 409, Col: 17}
2197+ }
2198+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
20712199 if templ_7745c5c3_Err != nil {
20722200 return templ_7745c5c3_Err
20732201 }
2074- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "</option>")
2202+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, " ")
2203+ if templ_7745c5c3_Err != nil {
2204+ return templ_7745c5c3_Err
2205+ }
2206+ if i < 9 {
2207+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "<kbd aria-hidden=\"true\">")
2208+ if templ_7745c5c3_Err != nil {
2209+ return templ_7745c5c3_Err
2210+ }
2211+ var templ_7745c5c3_Var109 string
2212+ templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(i + 1))
2213+ if templ_7745c5c3_Err != nil {
2214+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 411, Col: 46}
2215+ }
2216+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
2217+ if templ_7745c5c3_Err != nil {
2218+ return templ_7745c5c3_Err
2219+ }
2220+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "</kbd>")
2221+ if templ_7745c5c3_Err != nil {
2222+ return templ_7745c5c3_Err
2223+ }
2224+ }
2225+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "</label>")
20752226 if templ_7745c5c3_Err != nil {
20762227 return templ_7745c5c3_Err
20772228 }
20782229 }
2079- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</select> ")
2230+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "</fieldset>")
20802231 if templ_7745c5c3_Err != nil {
20812232 return templ_7745c5c3_Err
20822233 }
20832234 if p.Box.CanDeleteBranch {
2084- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")
2235+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")
20852236 if templ_7745c5c3_Err != nil {
20862237 return templ_7745c5c3_Err
20872238 }
2088- var templ_7745c5c3_Var106 string
2089- templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2239+ var templ_7745c5c3_Var110 string
2240+ templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
20902241 if templ_7745c5c3_Err != nil {
2091- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 386, Col: 101}
2242+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 417, Col: 102}
20922243 }
2093- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
2244+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var110))
20942245 if templ_7745c5c3_Err != nil {
20952246 return templ_7745c5c3_Err
20962247 }
2097- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, " after the merge</label>")
2248+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, " after the merge <kbd aria-hidden=\"true\">D</kbd></label>")
20982249 if templ_7745c5c3_Err != nil {
20992250 return templ_7745c5c3_Err
21002251 }
21012252 }
2102- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\">Merge</button></div></form>")
2253+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\" data-default>Merge into ")
2254+ if templ_7745c5c3_Err != nil {
2255+ return templ_7745c5c3_Err
2256+ }
2257+ var templ_7745c5c3_Var111 string
2258+ templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2259+ if templ_7745c5c3_Err != nil {
2260+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 420, Col: 82}
2261+ }
2262+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
2263+ if templ_7745c5c3_Err != nil {
2264+ return templ_7745c5c3_Err
2265+ }
2266+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "</button></div></form>")
21032267 if templ_7745c5c3_Err != nil {
21042268 return templ_7745c5c3_Err
21052269 }
21062270 }
2107- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "<div class=\"d-btns\">")
2271+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "</div></details>")
2272+ if templ_7745c5c3_Err != nil {
2273+ return templ_7745c5c3_Err
2274+ }
2275+ return nil
2276+ })
2277+}
2278+
2279+// moreMenu is what else the owner does with an open request: mark the head
2280+// read, close it, merge it from a terminal.
2281+func moreMenu(p MRPage) templ.Component {
2282+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
2283+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
2284+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
2285+ return templ_7745c5c3_CtxErr
2286+ }
2287+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
2288+ if !templ_7745c5c3_IsBuffer {
2289+ defer func() {
2290+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
2291+ if templ_7745c5c3_Err == nil {
2292+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
2293+ }
2294+ }()
2295+ }
2296+ ctx = templ.InitializeContext(ctx)
2297+ templ_7745c5c3_Var112 := templ.GetChildren(ctx)
2298+ if templ_7745c5c3_Var112 == nil {
2299+ templ_7745c5c3_Var112 = templ.NopComponent
2300+ }
2301+ ctx = templ.ClearChildren(ctx)
2302+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "<details class=\"menu omenu\"><summary class=\"btn icon\" aria-label=\"More actions\" title=\"more actions\">⋯</summary><div class=\"menu-pop\" role=\"group\" aria-label=\"More actions\">")
21082303 if templ_7745c5c3_Err != nil {
21092304 return templ_7745c5c3_Err
21102305 }
21112306 if p.Box.CanMarkRead {
2112- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "<form method=\"post\" action=\"")
2307+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "<form method=\"post\" action=\"")
21132308 if templ_7745c5c3_Err != nil {
21142309 return templ_7745c5c3_Err
21152310 }
2116- var templ_7745c5c3_Var107 templ.SafeURL
2117- templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
2311+ var templ_7745c5c3_Var113 templ.SafeURL
2312+ templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
21182313 if templ_7745c5c3_Err != nil {
2119- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 395, Col: 64}
2314+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 435, Col: 64}
21202315 }
2121- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
2316+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
21222317 if templ_7745c5c3_Err != nil {
21232318 return templ_7745c5c3_Err
21242319 }
2125- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2320+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
21262321 if templ_7745c5c3_Err != nil {
21272322 return templ_7745c5c3_Err
21282323 }
2129- var templ_7745c5c3_Var108 string
2130- templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2324+ var templ_7745c5c3_Var114 string
2325+ templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
21312326 if templ_7745c5c3_Err != nil {
2132- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 396, Col: 59}
2327+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 436, Col: 59}
21332328 }
2134- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var108)
2329+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var114)
21352330 if templ_7745c5c3_Err != nil {
21362331 return templ_7745c5c3_Err
21372332 }
2138- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\" class=\"btn\">Mark read</button></form>")
2333+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\" class=\"btn\" aria-keyshortcuts=\"Shift+R\" data-read>Mark read <kbd aria-hidden=\"true\">R</kbd></button></form>")
21392334 if templ_7745c5c3_Err != nil {
21402335 return templ_7745c5c3_Err
21412336 }
21422337 }
2143- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "<form method=\"post\" action=\"")
2338+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "<form method=\"post\" action=\"")
21442339 if templ_7745c5c3_Err != nil {
21452340 return templ_7745c5c3_Err
21462341 }
2147- var templ_7745c5c3_Var109 templ.SafeURL
2148- templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
2342+ var templ_7745c5c3_Var115 templ.SafeURL
2343+ templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
21492344 if templ_7745c5c3_Err != nil {
2150- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 400, Col: 64}
2345+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 440, Col: 64}
21512346 }
2152- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
2347+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
21532348 if templ_7745c5c3_Err != nil {
21542349 return templ_7745c5c3_Err
21552350 }
2156- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\" class=\"btn danger\">Close</button></form></div>")
2351+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\" class=\"btn danger\">Close</button></form>")
21572352 if templ_7745c5c3_Err != nil {
21582353 return templ_7745c5c3_Err
21592354 }
21602355 if !p.Box.HeadGone && p.Box.ReadyAction == "" {
2161- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "<details class=\"d-or\"><summary class=\"mute\">or from a terminal</summary>")
2356+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "<details class=\"d-or\"><summary class=\"mute\">or from a terminal</summary>")
21622357 if templ_7745c5c3_Err != nil {
21632358 return templ_7745c5c3_Err
21642359 }
@@ -2166,12 +2361,12 @@ func mergeBox(p MRPage) templ.Component {
21662361 if templ_7745c5c3_Err != nil {
21672362 return templ_7745c5c3_Err
21682363 }
2169- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "</details>")
2364+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "</details>")
21702365 if templ_7745c5c3_Err != nil {
21712366 return templ_7745c5c3_Err
21722367 }
21732368 }
2174- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "</section>")
2369+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "</div></details>")
21752370 if templ_7745c5c3_Err != nil {
21762371 return templ_7745c5c3_Err
21772372 }
@@ -2179,8 +2374,6 @@ func mergeBox(p MRPage) templ.Component {
21792374 })
21802375 }
21812376
2182-// Compare is a branch against the default branch: the commits it is ahead by,
2183-// its diff when the API can give one, and the form that opens a merge request.
21842377 func Compare(l Layout, p ComparePage) templ.Component {
21852378 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
21862379 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -2197,12 +2390,12 @@ func Compare(l Layout, p ComparePage) templ.Component {
21972390 }()
21982391 }
21992392 ctx = templ.InitializeContext(ctx)
2200- templ_7745c5c3_Var110 := templ.GetChildren(ctx)
2201- if templ_7745c5c3_Var110 == nil {
2202- templ_7745c5c3_Var110 = templ.NopComponent
2393+ templ_7745c5c3_Var116 := templ.GetChildren(ctx)
2394+ if templ_7745c5c3_Var116 == nil {
2395+ templ_7745c5c3_Var116 = templ.NopComponent
22032396 }
22042397 ctx = templ.ClearChildren(ctx)
2205- templ_7745c5c3_Var111 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
2398+ templ_7745c5c3_Var117 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
22062399 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
22072400 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
22082401 if !templ_7745c5c3_IsBuffer {
@@ -2214,263 +2407,263 @@ func Compare(l Layout, p ComparePage) templ.Component {
22142407 }()
22152408 }
22162409 ctx = templ.InitializeContext(ctx)
2217- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "<header class=\"show mr-h\"><h1>")
2410+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "<header class=\"show mr-h\"><h1>")
22182411 if templ_7745c5c3_Err != nil {
22192412 return templ_7745c5c3_Err
22202413 }
2221- var templ_7745c5c3_Var112 string
2222- templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2414+ var templ_7745c5c3_Var118 string
2415+ templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
22232416 if templ_7745c5c3_Err != nil {
2224- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 17}
2417+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 456, Col: 17}
22252418 }
2226- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
2419+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
22272420 if templ_7745c5c3_Err != nil {
22282421 return templ_7745c5c3_Err
22292422 }
2230- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, " → ")
2423+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, " → ")
22312424 if templ_7745c5c3_Err != nil {
22322425 return templ_7745c5c3_Err
22332426 }
2234- var templ_7745c5c3_Var113 string
2235- templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2427+ var templ_7745c5c3_Var119 string
2428+ templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
22362429 if templ_7745c5c3_Err != nil {
2237- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 34}
2430+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 456, Col: 34}
22382431 }
2239- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
2432+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))
22402433 if templ_7745c5c3_Err != nil {
22412434 return templ_7745c5c3_Err
22422435 }
2243- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
2436+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
22442437 if templ_7745c5c3_Err != nil {
22452438 return templ_7745c5c3_Err
22462439 }
2247- var templ_7745c5c3_Var114 templ.SafeURL
2248- templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
2440+ var templ_7745c5c3_Var120 templ.SafeURL
2441+ templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
22492442 if templ_7745c5c3_Err != nil {
2250- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 421, Col: 45}
2443+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 459, Col: 45}
22512444 }
2252- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114))
2445+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
22532446 if templ_7745c5c3_Err != nil {
22542447 return templ_7745c5c3_Err
22552448 }
2256- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "\">")
2449+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "\">")
22572450 if templ_7745c5c3_Err != nil {
22582451 return templ_7745c5c3_Err
22592452 }
2260- var templ_7745c5c3_Var115 string
2261- templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2453+ var templ_7745c5c3_Var121 string
2454+ templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
22622455 if templ_7745c5c3_Err != nil {
2263- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 421, Col: 58}
2456+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 459, Col: 58}
22642457 }
2265- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
2458+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
22662459 if templ_7745c5c3_Err != nil {
22672460 return templ_7745c5c3_Err
22682461 }
2269- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "</a></dd><dt>target</dt><dd><a href=\"")
2462+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "</a></dd><dt>target</dt><dd><a href=\"")
22702463 if templ_7745c5c3_Err != nil {
22712464 return templ_7745c5c3_Err
22722465 }
2273- var templ_7745c5c3_Var116 templ.SafeURL
2274- templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
2466+ var templ_7745c5c3_Var122 templ.SafeURL
2467+ templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
22752468 if templ_7745c5c3_Err != nil {
2276- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 423, Col: 45}
2469+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 461, Col: 45}
22772470 }
2278- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
2471+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122))
22792472 if templ_7745c5c3_Err != nil {
22802473 return templ_7745c5c3_Err
22812474 }
2282- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "\">")
2475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "\">")
22832476 if templ_7745c5c3_Err != nil {
22842477 return templ_7745c5c3_Err
22852478 }
2286- var templ_7745c5c3_Var117 string
2287- templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2479+ var templ_7745c5c3_Var123 string
2480+ templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
22882481 if templ_7745c5c3_Err != nil {
2289- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 423, Col: 58}
2482+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 461, Col: 58}
22902483 }
2291- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))
2484+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))
22922485 if templ_7745c5c3_Err != nil {
22932486 return templ_7745c5c3_Err
22942487 }
2295- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</a></dd><dt>ahead</dt><dd>")
2488+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "</a></dd><dt>ahead</dt><dd>")
22962489 if templ_7745c5c3_Err != nil {
22972490 return templ_7745c5c3_Err
22982491 }
2299- var templ_7745c5c3_Var118 string
2300- templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
2492+ var templ_7745c5c3_Var124 string
2493+ templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
23012494 if templ_7745c5c3_Err != nil {
2302- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 425, Col: 21}
2495+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 463, Col: 21}
23032496 }
2304- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
2497+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))
23052498 if templ_7745c5c3_Err != nil {
23062499 return templ_7745c5c3_Err
23072500 }
2308- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "</dd></dl></header>")
2501+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "</dd></dl></header>")
23092502 if templ_7745c5c3_Err != nil {
23102503 return templ_7745c5c3_Err
23112504 }
23122505 if p.Note != "" {
2313- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "<p class=\"notice\">")
2506+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "<p class=\"notice\">")
23142507 if templ_7745c5c3_Err != nil {
23152508 return templ_7745c5c3_Err
23162509 }
2317- var templ_7745c5c3_Var119 string
2318- templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
2510+ var templ_7745c5c3_Var125 string
2511+ templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
23192512 if templ_7745c5c3_Err != nil {
2320- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 429, Col: 29}
2513+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 467, Col: 29}
23212514 }
2322- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))
2515+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
23232516 if templ_7745c5c3_Err != nil {
23242517 return templ_7745c5c3_Err
23252518 }
2326- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "</p>")
2519+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "</p>")
23272520 if templ_7745c5c3_Err != nil {
23282521 return templ_7745c5c3_Err
23292522 }
23302523 }
2331- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, " ")
2524+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, " ")
23322525 if templ_7745c5c3_Err != nil {
23332526 return templ_7745c5c3_Err
23342527 }
23352528 if p.Existing != nil {
2336- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
2529+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
23372530 if templ_7745c5c3_Err != nil {
23382531 return templ_7745c5c3_Err
23392532 }
2340- var templ_7745c5c3_Var120 templ.SafeURL
2341- templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
2533+ var templ_7745c5c3_Var126 templ.SafeURL
2534+ templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
23422535 if templ_7745c5c3_Err != nil {
2343- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 432, Col: 114}
2536+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 470, Col: 114}
23442537 }
2345- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
2538+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
23462539 if templ_7745c5c3_Err != nil {
23472540 return templ_7745c5c3_Err
23482541 }
2349- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "\">")
2542+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "\">")
23502543 if templ_7745c5c3_Err != nil {
23512544 return templ_7745c5c3_Err
23522545 }
2353- var templ_7745c5c3_Var121 string
2354- templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
2546+ var templ_7745c5c3_Var127 string
2547+ templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
23552548 if templ_7745c5c3_Err != nil {
2356- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 432, Col: 135}
2549+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 470, Col: 135}
23572550 }
2358- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
2551+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127))
23592552 if templ_7745c5c3_Err != nil {
23602553 return templ_7745c5c3_Err
23612554 }
2362- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "</a></p>")
2555+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "</a></p>")
23632556 if templ_7745c5c3_Err != nil {
23642557 return templ_7745c5c3_Err
23652558 }
23662559 }
2367- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, " ")
2560+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, " ")
23682561 if templ_7745c5c3_Err != nil {
23692562 return templ_7745c5c3_Err
23702563 }
23712564 if p.Create != nil {
2372- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "<form class=\"cform create\" method=\"post\" action=\"")
2565+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "<form class=\"cform create\" method=\"post\" action=\"")
23732566 if templ_7745c5c3_Err != nil {
23742567 return templ_7745c5c3_Err
23752568 }
2376- var templ_7745c5c3_Var122 templ.SafeURL
2377- templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
2569+ var templ_7745c5c3_Var128 templ.SafeURL
2570+ templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
23782571 if templ_7745c5c3_Err != nil {
2379- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 435, Col: 83}
2572+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 473, Col: 83}
23802573 }
2381- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122))
2574+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128))
23822575 if templ_7745c5c3_Err != nil {
23832576 return templ_7745c5c3_Err
23842577 }
2385- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "\"><input type=\"hidden\" name=\"source\" value=\"")
2578+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "\"><input type=\"hidden\" name=\"source\" value=\"")
23862579 if templ_7745c5c3_Err != nil {
23872580 return templ_7745c5c3_Err
23882581 }
2389- var templ_7745c5c3_Var123 string
2390- templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
2582+ var templ_7745c5c3_Var129 string
2583+ templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
23912584 if templ_7745c5c3_Err != nil {
2392- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 436, Col: 62}
2585+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 474, Col: 62}
23932586 }
2394- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var123)
2587+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var129)
23952588 if templ_7745c5c3_Err != nil {
23962589 return templ_7745c5c3_Err
23972590 }
2398- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "\"> <input type=\"hidden\" name=\"target\" value=\"")
2591+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "\"> <input type=\"hidden\" name=\"target\" value=\"")
23992592 if templ_7745c5c3_Err != nil {
24002593 return templ_7745c5c3_Err
24012594 }
2402- var templ_7745c5c3_Var124 string
2403- templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
2595+ var templ_7745c5c3_Var130 string
2596+ templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
24042597 if templ_7745c5c3_Err != nil {
2405- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 437, Col: 62}
2598+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 475, Col: 62}
24062599 }
2407- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var124)
2600+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var130)
24082601 if templ_7745c5c3_Err != nil {
24092602 return templ_7745c5c3_Err
24102603 }
2411- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
2604+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
24122605 if templ_7745c5c3_Err != nil {
24132606 return templ_7745c5c3_Err
24142607 }
2415- var templ_7745c5c3_Var125 string
2416- templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
2608+ var templ_7745c5c3_Var131 string
2609+ templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
24172610 if templ_7745c5c3_Err != nil {
2418- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 439, Col: 97}
2611+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 477, Col: 97}
24192612 }
2420- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var125)
2613+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var131)
24212614 if templ_7745c5c3_Err != nil {
24222615 return templ_7745c5c3_Err
24232616 }
2424- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"plain text, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Create merge request</button></div></form>")
2617+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"plain text, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Create merge request</button></div></form>")
24252618 if templ_7745c5c3_Err != nil {
24262619 return templ_7745c5c3_Err
24272620 }
24282621 }
2429- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, " ")
2622+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, " ")
24302623 if templ_7745c5c3_Err != nil {
24312624 return templ_7745c5c3_Err
24322625 }
24332626 if p.HasDiff {
2434- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "<div class=\"diff-tools\" role=\"toolbar\" aria-label=\"Diff view\"><span class=\"dim\">")
2627+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "<div class=\"diff-tools\" role=\"toolbar\" aria-label=\"Diff view\"><span class=\"dim\">")
24352628 if templ_7745c5c3_Err != nil {
24362629 return templ_7745c5c3_Err
24372630 }
2438- var templ_7745c5c3_Var126 string
2439- templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
2631+ var templ_7745c5c3_Var132 string
2632+ templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
24402633 if templ_7745c5c3_Err != nil {
2441- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 449, Col: 57}
2634+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 487, Col: 57}
24422635 }
2443- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
2636+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
24442637 if templ_7745c5c3_Err != nil {
24452638 return templ_7745c5c3_Err
24462639 }
2447- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, " · <span class=\"plus\">+")
2640+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, " · <span class=\"plus\">+")
24482641 if templ_7745c5c3_Err != nil {
24492642 return templ_7745c5c3_Err
24502643 }
2451- var templ_7745c5c3_Var127 string
2452- templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
2644+ var templ_7745c5c3_Var133 string
2645+ templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
24532646 if templ_7745c5c3_Err != nil {
2454- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 449, Col: 97}
2647+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 487, Col: 97}
24552648 }
2456- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127))
2649+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
24572650 if templ_7745c5c3_Err != nil {
24582651 return templ_7745c5c3_Err
24592652 }
2460- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "</span> <span class=\"minus\">-")
2653+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "</span> <span class=\"minus\">-")
24612654 if templ_7745c5c3_Err != nil {
24622655 return templ_7745c5c3_Err
24632656 }
2464- var templ_7745c5c3_Var128 string
2465- templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
2657+ var templ_7745c5c3_Var134 string
2658+ templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
24662659 if templ_7745c5c3_Err != nil {
2467- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 449, Col: 142}
2660+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 487, Col: 142}
24682661 }
2469- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128))
2662+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
24702663 if templ_7745c5c3_Err != nil {
24712664 return templ_7745c5c3_Err
24722665 }
2473- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "</span></span> <span class=\"grow\"></span>")
2666+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "</span></span> <span class=\"grow\"></span>")
24742667 if templ_7745c5c3_Err != nil {
24752668 return templ_7745c5c3_Err
24762669 }
@@ -2486,64 +2679,64 @@ func Compare(l Layout, p ComparePage) templ.Component {
24862679 if templ_7745c5c3_Err != nil {
24872680 return templ_7745c5c3_Err
24882681 }
2489- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "</div>")
2682+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "</div>")
24902683 if templ_7745c5c3_Err != nil {
24912684 return templ_7745c5c3_Err
24922685 }
24932686 if p.Invisible {
2494- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
2687+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
24952688 if templ_7745c5c3_Err != nil {
24962689 return templ_7745c5c3_Err
24972690 }
24982691 }
2499- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, " <div id=\"diffs\"")
2692+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, " <div id=\"diffs\"")
25002693 if templ_7745c5c3_Err != nil {
25012694 return templ_7745c5c3_Err
25022695 }
25032696 if p.Both {
2504- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, " data-diff=\"")
2697+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, " data-diff=\"")
25052698 if templ_7745c5c3_Err != nil {
25062699 return templ_7745c5c3_Err
25072700 }
2508- var templ_7745c5c3_Var129 string
2509- templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
2701+ var templ_7745c5c3_Var135 string
2702+ templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
25102703 if templ_7745c5c3_Err != nil {
2511- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 464, Col: 23}
2704+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 502, Col: 23}
25122705 }
2513- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var129)
2706+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var135)
25142707 if templ_7745c5c3_Err != nil {
25152708 return templ_7745c5c3_Err
25162709 }
2517- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "\"")
2710+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "\"")
25182711 if templ_7745c5c3_Err != nil {
25192712 return templ_7745c5c3_Err
25202713 }
25212714 } else {
2522- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, " data-view=\"")
2715+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, " data-view=\"")
25232716 if templ_7745c5c3_Err != nil {
25242717 return templ_7745c5c3_Err
25252718 }
2526- var templ_7745c5c3_Var130 string
2527- templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
2719+ var templ_7745c5c3_Var136 string
2720+ templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
25282721 if templ_7745c5c3_Err != nil {
2529- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 466, Col: 23}
2722+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 504, Col: 23}
25302723 }
2531- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var130)
2724+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var136)
25322725 if templ_7745c5c3_Err != nil {
25332726 return templ_7745c5c3_Err
25342727 }
2535- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "\"")
2728+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "\"")
25362729 if templ_7745c5c3_Err != nil {
25372730 return templ_7745c5c3_Err
25382731 }
25392732 }
25402733 if p.Both && !p.Pinned {
2541- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
2734+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
25422735 if templ_7745c5c3_Err != nil {
25432736 return templ_7745c5c3_Err
25442737 }
25452738 }
2546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, ">")
2739+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, ">")
25472740 if templ_7745c5c3_Err != nil {
25482741 return templ_7745c5c3_Err
25492742 }
@@ -2553,14 +2746,14 @@ func Compare(l Layout, p ComparePage) templ.Component {
25532746 return templ_7745c5c3_Err
25542747 }
25552748 }
2556- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "</div>")
2749+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "</div>")
25572750 if templ_7745c5c3_Err != nil {
25582751 return templ_7745c5c3_Err
25592752 }
25602753 }
25612754 return nil
25622755 })
2563- templ_7745c5c3_Err = Shell(l, compareRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var111), templ_7745c5c3_Buffer)
2756+ templ_7745c5c3_Err = Shell(l, compareRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var117), templ_7745c5c3_Buffer)
25642757 if templ_7745c5c3_Err != nil {
25652758 return templ_7745c5c3_Err
25662759 }
@@ -2584,173 +2777,173 @@ func compareRail(p ComparePage) templ.Component {
25842777 }()
25852778 }
25862779 ctx = templ.InitializeContext(ctx)
2587- templ_7745c5c3_Var131 := templ.GetChildren(ctx)
2588- if templ_7745c5c3_Var131 == nil {
2589- templ_7745c5c3_Var131 = templ.NopComponent
2780+ templ_7745c5c3_Var137 := templ.GetChildren(ctx)
2781+ if templ_7745c5c3_Var137 == nil {
2782+ templ_7745c5c3_Var137 = templ.NopComponent
25902783 }
25912784 ctx = templ.ClearChildren(ctx)
2592- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">Commits <span class=\"count\">")
2785+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">Commits <span class=\"count\">")
25932786 if templ_7745c5c3_Err != nil {
25942787 return templ_7745c5c3_Err
25952788 }
2596- var templ_7745c5c3_Var132 string
2597- templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
2789+ var templ_7745c5c3_Var138 string
2790+ templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
25982791 if templ_7745c5c3_Err != nil {
2599- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 482, Col: 67}
2792+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 520, Col: 67}
26002793 }
2601- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
2794+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
26022795 if templ_7745c5c3_Err != nil {
26032796 return templ_7745c5c3_Err
26042797 }
2605- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "</span></h2><ul class=\"pcommits\">")
2798+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, "</span></h2><ul class=\"pcommits\">")
26062799 if templ_7745c5c3_Err != nil {
26072800 return templ_7745c5c3_Err
26082801 }
26092802 for _, c := range p.Ahead {
2610- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "<li><a href=\"")
2803+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "<li><a href=\"")
26112804 if templ_7745c5c3_Err != nil {
26122805 return templ_7745c5c3_Err
26132806 }
2614- var templ_7745c5c3_Var133 templ.SafeURL
2615- templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
2807+ var templ_7745c5c3_Var139 templ.SafeURL
2808+ templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
26162809 if templ_7745c5c3_Err != nil {
2617- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 37}
2810+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 524, Col: 37}
26182811 }
2619- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
2812+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
26202813 if templ_7745c5c3_Err != nil {
26212814 return templ_7745c5c3_Err
26222815 }
2623- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "\">")
2816+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "\">")
26242817 if templ_7745c5c3_Err != nil {
26252818 return templ_7745c5c3_Err
26262819 }
2627- var templ_7745c5c3_Var134 string
2628- templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
2820+ var templ_7745c5c3_Var140 string
2821+ templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
26292822 if templ_7745c5c3_Err != nil {
2630- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 49}
2823+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 524, Col: 49}
26312824 }
2632- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
2825+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
26332826 if templ_7745c5c3_Err != nil {
26342827 return templ_7745c5c3_Err
26352828 }
2636- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "</a> <span class=\"s\">")
2829+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, "</a> <span class=\"s\">")
26372830 if templ_7745c5c3_Err != nil {
26382831 return templ_7745c5c3_Err
26392832 }
2640- var templ_7745c5c3_Var135 string
2641- templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
2833+ var templ_7745c5c3_Var141 string
2834+ templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
26422835 if templ_7745c5c3_Err != nil {
2643- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 487, Col: 32}
2836+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 525, Col: 32}
26442837 }
2645- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
2838+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141))
26462839 if templ_7745c5c3_Err != nil {
26472840 return templ_7745c5c3_Err
26482841 }
2649- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "</span> <span class=\"mute\">")
2842+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "</span> <span class=\"mute\">")
26502843 if templ_7745c5c3_Err != nil {
26512844 return templ_7745c5c3_Err
26522845 }
2653- var templ_7745c5c3_Var136 string
2654- templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
2846+ var templ_7745c5c3_Var142 string
2847+ templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
26552848 if templ_7745c5c3_Err != nil {
2656- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 489, Col: 16}
2849+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 527, Col: 16}
26572850 }
2658- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
2851+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var142))
26592852 if templ_7745c5c3_Err != nil {
26602853 return templ_7745c5c3_Err
26612854 }
2662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, " ")
2855+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, " ")
26632856 if templ_7745c5c3_Err != nil {
26642857 return templ_7745c5c3_Err
26652858 }
26662859 if c.WhenAt != "" {
2667- var templ_7745c5c3_Var137 string
2668- templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
2860+ var templ_7745c5c3_Var143 string
2861+ templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
26692862 if templ_7745c5c3_Err != nil {
2670- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 491, Col: 15}
2863+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 529, Col: 15}
26712864 }
2672- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137))
2865+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var143))
26732866 if templ_7745c5c3_Err != nil {
26742867 return templ_7745c5c3_Err
26752868 }
2676- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "<time datetime=\"")
2869+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, "<time datetime=\"")
26772870 if templ_7745c5c3_Err != nil {
26782871 return templ_7745c5c3_Err
26792872 }
2680- var templ_7745c5c3_Var138 string
2681- templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
2873+ var templ_7745c5c3_Var144 string
2874+ templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
26822875 if templ_7745c5c3_Err != nil {
2683- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 491, Col: 42}
2876+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 529, Col: 42}
26842877 }
2685- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var138)
2878+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var144)
26862879 if templ_7745c5c3_Err != nil {
26872880 return templ_7745c5c3_Err
26882881 }
2689- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "\">")
2882+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "\">")
26902883 if templ_7745c5c3_Err != nil {
26912884 return templ_7745c5c3_Err
26922885 }
2693- var templ_7745c5c3_Var139 string
2694- templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
2886+ var templ_7745c5c3_Var145 string
2887+ templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
26952888 if templ_7745c5c3_Err != nil {
2696- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 491, Col: 53}
2889+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 529, Col: 53}
26972890 }
2698- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
2891+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var145))
26992892 if templ_7745c5c3_Err != nil {
27002893 return templ_7745c5c3_Err
27012894 }
2702- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "</time>")
2895+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, "</time>")
27032896 if templ_7745c5c3_Err != nil {
27042897 return templ_7745c5c3_Err
27052898 }
27062899 }
2707- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "</span></li>")
2900+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "</span></li>")
27082901 if templ_7745c5c3_Err != nil {
27092902 return templ_7745c5c3_Err
27102903 }
27112904 }
2712- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "</ul>")
2905+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "</ul>")
27132906 if templ_7745c5c3_Err != nil {
27142907 return templ_7745c5c3_Err
27152908 }
27162909 if len(p.Ahead) == 0 {
2717- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "<p class=\"dim\">no commit ahead of ")
2910+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "<p class=\"dim\">no commit ahead of ")
27182911 if templ_7745c5c3_Err != nil {
27192912 return templ_7745c5c3_Err
27202913 }
2721- var templ_7745c5c3_Var140 string
2722- templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2914+ var templ_7745c5c3_Var146 string
2915+ templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
27232916 if templ_7745c5c3_Err != nil {
2724- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 498, Col: 47}
2917+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 536, Col: 47}
27252918 }
2726- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
2919+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146))
27272920 if templ_7745c5c3_Err != nil {
27282921 return templ_7745c5c3_Err
27292922 }
2730- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, ".</p>")
2923+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, ".</p>")
27312924 if templ_7745c5c3_Err != nil {
27322925 return templ_7745c5c3_Err
27332926 }
27342927 }
2735- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "</section>")
2928+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "</section>")
27362929 if templ_7745c5c3_Err != nil {
27372930 return templ_7745c5c3_Err
27382931 }
27392932 if p.HasDiff {
2740- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
2933+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
27412934 if templ_7745c5c3_Err != nil {
27422935 return templ_7745c5c3_Err
27432936 }
2744- var templ_7745c5c3_Var141 string
2745- templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
2937+ var templ_7745c5c3_Var147 string
2938+ templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
27462939 if templ_7745c5c3_Err != nil {
2747- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 503, Col: 66}
2940+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 541, Col: 66}
27482941 }
2749- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141))
2942+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147))
27502943 if templ_7745c5c3_Err != nil {
27512944 return templ_7745c5c3_Err
27522945 }
2753- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "</span></h2>")
2946+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "</span></h2>")
27542947 if templ_7745c5c3_Err != nil {
27552948 return templ_7745c5c3_Err
27562949 }
@@ -2758,12 +2951,12 @@ func compareRail(p ComparePage) templ.Component {
27582951 if templ_7745c5c3_Err != nil {
27592952 return templ_7745c5c3_Err
27602953 }
2761- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "</section>")
2954+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, "</section>")
27622955 if templ_7745c5c3_Err != nil {
27632956 return templ_7745c5c3_Err
27642957 }
27652958 }
2766- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")
2959+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")
27672960 if templ_7745c5c3_Err != nil {
27682961 return templ_7745c5c3_Err
27692962 }
@@ -2771,7 +2964,7 @@ func compareRail(p ComparePage) templ.Component {
27712964 if templ_7745c5c3_Err != nil {
27722965 return templ_7745c5c3_Err
27732966 }
2774- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "</section>")
2967+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "</section>")
27752968 if templ_7745c5c3_Err != nil {
27762969 return templ_7745c5c3_Err
27772970 }
M internal/adapters/web/overlays.templ +8 -2
@@ -4,8 +4,9 @@ package web
44
55 import "strings"
66
7-// OverlayHelp is the keyboard map panel.
8-templ OverlayHelp() {
7+// OverlayHelp is the keyboard map panel; the keys of the merge request page
8+// show on that page only.
9+templ OverlayHelp(mr bool) {
910 <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
1011 <div class="panel-head"><h2>Keyboard shortcuts</h2><button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd> close</button></div>
1112 <dl class="keys">
@@ -21,6 +22,11 @@ templ OverlayHelp() {
2122 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
2223 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
2324 <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>
25+ if mr {
26+ <div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div>
27+ <div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div>
28+ <div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div>
29+ }
2430 <div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div>
2531 </dl>
2632 </dialog>
M internal/adapters/web/overlays_templ.go +45 -34 generated file: collapsed
M internal/adapters/web/shell.templ +4 -1
@@ -29,6 +29,9 @@ templ Shell(l Layout, rail templ.Component) {
2929 <script src={ l.Assets.Files } defer></script>
3030 <script src={ l.Assets.Passkey } defer></script>
3131 <script src={ l.Assets.Log } defer></script>
32+ if l.MR {
33+ <script src={ l.Assets.MR } defer></script>
34+ }
3235 </head>
3336 <body
3437 data-ref={ l.RefPath }
@@ -57,7 +60,7 @@ templ Shell(l Layout, rail templ.Component) {
5760 </div>
5861 @Footer(l)
5962 @Hint(l)
60- @OverlayHelp()
63+ @OverlayHelp(l.MR)
6164 @OverlayRefs(l)
6265 @OverlayCmd(l.Cmd)
6366 @Keys()
M internal/adapters/web/shell_templ.go +185 -162 generated file: collapsed
M internal/adapters/web/static/css/50-code.css +37 -11
@@ -46,10 +46,18 @@
4646 }
4747
4848 /* one file of a diff */
49+ .dfile { --lnw: 4ch; }
50+ .lnw-2 { --lnw: 2ch; } .lnw-3 { --lnw: 3ch; } .lnw-4 { --lnw: 4ch; } .lnw-5 { --lnw: 5ch; } .lnw-6 { --lnw: 6ch; } .lnw-7 { --lnw: 7ch; }
4951 .dfile {
50- margin-top: var(--s3); scroll-margin-top: calc(var(--hdr-h, 0px) + 56px);
52+ margin-top: var(--s3); scroll-margin-top: calc(var(--hdr-h, 0px) + var(--bar-h, 48px) + 8px);
53+ /* a file far from the screen is neither laid out nor painted: a diff of
54+ hundreds of files scrolls and switches layout like a short one. The
55+ browser remembers the last size it rendered (auto). */
56+ content-visibility: auto; contain-intrinsic-size: auto 480px;
5157 & > summary {
52- position: sticky; top: calc(var(--hdr-h, 0px) + 48px); z-index: 4;
58+ /* under the diff tools, whose height --bar-h is (two lines when they
59+ wrap: the merge request bar on a narrow screen) */
60+ position: sticky; top: calc(var(--hdr-h, 0px) + var(--bar-h, 48px)); z-index: 4;
5361 background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--r) var(--r) 0 0; padding: var(--s2) var(--s3);
5462 font-family: var(--mono); font-size: var(--fs-s);
5563 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; list-style: none;
@@ -66,7 +74,9 @@
6674 width: max-content; min-width: 100%; font-family: var(--mono);
6775 font-size: var(--fs-code); line-height: var(--lh-code);
6876 & td { padding: 0; vertical-align: top; }
69- & .ln { width: 6ch; min-width: 6ch; padding-right: 1ch; text-align: right; color: var(--ink-mute); user-select: none; }
77+ /* the gutter is as wide as the largest line number of the file (lnw-N on
78+ .dfile), plus 1ch on each side */
79+ & .ln { width: calc(var(--lnw) + 2ch); min-width: calc(var(--lnw) + 2ch); padding: 0 1ch; text-align: right; color: var(--ink-mute); user-select: none; }
7080 & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); }
7181 & .tx { white-space: pre; }
7282 & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; }
@@ -83,22 +93,38 @@
8393 the hunk header, which spans all columns); long lines wrap in their half */
8494 .spl {
8595 width: 100%; table-layout: fixed;
86- & .c-ln { width: 7ch; } & .c-m { width: 2.5ch; }
96+ & .c-ln { width: calc(var(--lnw) + 2ch); } & .c-m { width: 2.5ch; }
8797 & .tx, & .hunk td { white-space: pre-wrap; overflow-wrap: anywhere; }
8898 & td.ln:nth-child(4) { border-left: 1px solid var(--border); }
8999 }
90100 #diffs[data-diff="unified"] .spl, #diffs[data-diff="split"] .uni { display: none; }
91101
92- /* diff table of contents in the rail */
102+ /* diff table of contents in the rail: one line a file, the name first and
103+ its directory dimmed after it (cut from its start when long), the
104+ comments, the counts */
93105 .files {
94- font-size: var(--fs-s);
95- & a { display: grid; grid-template-columns: 2ch minmax(0, 1fr) auto; column-gap: var(--s2); padding: 2px var(--s2); margin: 0 calc(-1 * var(--s2)); border-radius: 4px; text-decoration: none; font-family: var(--mono); }
106+ font-size: var(--fs-s); position: relative; /* the offsets of its links count from it (ermite.js) */
107+ & li[hidden] { display: none; }
108+ & a {
109+ display: grid; grid-template-columns: 1.5ch minmax(0, 1fr) auto auto; column-gap: var(--s2); align-items: baseline;
110+ height: 26px; line-height: 26px; padding: 0 var(--s2); margin: 0 calc(-1 * var(--s2));
111+ border-radius: 4px; border-left: 2px solid transparent; text-decoration: none; font-family: var(--mono); color: var(--ink);
112+ }
96113 & a:hover { background: var(--bg-raised); }
97114 & a:hover .p { text-decoration: underline; }
98- & a[aria-current="true"] { background: var(--row); font-weight: bold; }
99- & .p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
100- & .n { color: var(--ink-dim); font-size: var(--fs-s); }
101- & .b { grid-column: 2 / -1; color: var(--ink-mute); font-size: var(--fs-s); letter-spacing: .1ch; line-height: 1; padding-bottom: 4px; }
115+ & a[aria-current="true"] { background: var(--row); border-left-color: var(--ink-hi); }
116+ & a[aria-current="true"] .p { font-weight: bold; color: var(--ink-hi); }
117+ & .st { color: var(--ink-mute); font-weight: bold; }
118+ & .st[data-st="A"] { color: var(--teal); }
119+ & .st[data-st="D"] { color: var(--red); }
120+ & .nm { overflow: hidden; white-space: nowrap; display: flex; min-width: 0; }
121+ & .nm .p { flex: none; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
122+ /* cut from the start (rtl), the path itself kept in its order (bdi) */
123+ & .nm .d { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; color: var(--ink-mute); padding-left: 1ch; }
124+ & .nm .d bdi { direction: ltr; }
125+ & .cn { font: 600 var(--fs-xs) / 16px var(--font); padding: 0 6px; border-radius: 999px; background: var(--ink-hi); color: var(--on-ink); align-self: center; }
126+ & .cn.done { background: var(--bg-strong); color: var(--ink-dim); }
127+ & .n { color: var(--ink-dim); font-size: var(--fs-xs); white-space: nowrap; }
102128 }
103129
104130 @media (max-width: 700px) {
M internal/adapters/web/static/css/60-mr.css +91 -23
@@ -18,41 +18,109 @@
1818 & .mute { font-weight: 400; font-family: var(--mono); }
1919 }
2020
21- /* merge block: first thing in the rail */
22- .decide {
23- border-color: var(--border-strong) !important;
24- & select {
25- display: block; width: 100%; height: 36px; padding: 0 var(--s3);
26- font: inherit; color: inherit; background: var(--bg-inset);
27- border: 1px solid var(--border-strong); border-radius: var(--r-s);
21+ /* the bar above the diff of a merge request: the diff tools, the checks and
22+ the menus of the owner, on one line that follows the review */
23+ /* on a narrow screen the bar takes two lines: the diff tools, then the
24+ checks and the menus; the file headers stick under both */
25+ @media (max-width: 860px) {
26+ body:has(.mr-bar) { --bar-h: 96px; }
27+ .mr-bar .grow { flex-basis: 100%; height: 0; }
28+ }
29+ .mr-bar {
30+ gap: var(--s2);
31+ & .mr-ck { margin-left: 0; height: 32px; padding: 0 var(--s2); border-radius: var(--r-s); text-decoration: none; color: var(--ink-dim); font-size: var(--fs-s); }
32+ & .mr-ck:hover { background: var(--bg-raised); color: var(--ink-hi); }
33+ }
34+
35+ /* a menu of the bar: a <details> whose panel opens under its button */
36+ .menu {
37+ position: relative;
38+ & > summary { list-style: none; }
39+ & > summary::-webkit-details-marker { display: none; }
40+ & > summary.primary::after { content: ""; width: 0; height: 0; margin-left: 2px; border: 4px solid transparent; border-top-color: currentColor; transform: translateY(2px); }
41+ & .menu-pop {
42+ position: absolute; right: 0; top: calc(100% + var(--s1)); z-index: 6; width: 320px; max-width: calc(100vw - 2 * var(--pad));
43+ display: grid; gap: var(--s3); padding: var(--s3) var(--s4);
44+ background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r); box-shadow: 0 8px 24px rgb(0 0 0 / .18);
45+ font-size: var(--fs-s);
2846 }
29- & .d-check { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s2); color: var(--ink-dim); font-size: var(--fs-s); cursor: pointer; }
30- & .d-check input { accent-color: var(--ink-hi); margin: 0; }
31- & .d-btns { display: flex; gap: var(--s2); margin-top: var(--s3); flex-wrap: wrap; }
32- & .d-btns .primary { flex: 1; justify-content: center; }
33- /* the terminal lines fold away: a reviewer who merges in the page does
34- not need them in sight */
35- & .d-draft { font-size: var(--fs-s); color: var(--ink-dim); }
36- & .d-or { font-size: var(--fs-s); }
37- & .d-or > summary { cursor: pointer; list-style: none; }
47+ & .kv { margin: 0; }
48+ }
49+ .mmenu {
50+ & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; }
51+ & .d-modes label, & .d-check {
52+ display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2));
53+ border-radius: var(--r-s); color: var(--ink); cursor: pointer;
54+ }
55+ & .d-modes label:hover, & .d-check:hover { background: var(--bg-raised); }
56+ & .d-modes label:has(:checked) { background: var(--bg-raised); color: var(--ink-hi); font-weight: 600; }
57+ & .d-modes kbd, & .d-check kbd { margin-left: auto; }
58+ & input { accent-color: var(--ink-hi); margin: 0; }
59+ & .d-check { color: var(--ink-dim); }
60+ & .d-btns { display: flex; margin-top: var(--s1); }
61+ & .d-btns .btn { flex: 1; justify-content: center; }
62+ & .d-draft { color: var(--ink-dim); }
63+ }
64+ .omenu {
65+ & > summary { font-size: 18px; line-height: 1; }
66+ & .menu-pop { width: 280px; }
67+ & form .btn { width: 100%; justify-content: flex-start; }
68+ & .btn kbd { margin-left: auto; }
69+ & .d-or > summary { cursor: pointer; list-style: none; color: var(--ink-mute); }
3870 & .d-or > summary::-webkit-details-marker { display: none; }
3971 & .d-or > summary::before { content: "▸ "; }
4072 & .d-or[open] > summary::before { content: "▾ "; }
4173 & .d-or > summary:hover { color: var(--ink-hi); }
4274 & .d-or[open] > .clone { margin-top: var(--s2); }
43- & .clone { margin-top: 0; }
4475 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
4576 }
4677
47- /* the rail of a merge request follows the review: the merge block and the
48- checks stay in reach at the end of a long diff. It scrolls on its own
49- when it is taller than the window. Only on a two-column layout. */
78+ /* the rail of a merge request: the list of files on top, which follows the
79+ review and scrolls on its own; checks, commits and settings scroll with
80+ the page. Only on a two-column layout. */
5081 @media (min-width: 1081px) {
51- .work.has-rail:has(#mr-checks) .rail {
52- position: sticky; top: calc(var(--hdr-h, 0px) + var(--s4));
82+ /* the rail runs the height of the content, so that its first block can
83+ stick all along the diff */
84+ .work.has-rail:has(.r-files) { align-items: stretch; }
85+ .rail:has(.r-files) { align-self: stretch; }
86+ .rail .r-files {
87+ position: sticky; top: calc(var(--hdr-h, 0px) + var(--s4)); z-index: 3;
88+ display: flex; flex-direction: column;
5389 max-height: calc(100dvh - var(--hdr-h, 0px) - var(--s4) * 2);
54- overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin;
5590 }
91+ .rail .r-files > .files { overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; min-height: 0; }
92+ }
93+ /* on one column the rail goes under the diff: the list of files keeps its
94+ own scroll, under a height */
95+ @media (max-width: 1080px) {
96+ .rail .r-files > .files { max-height: 50dvh; overflow-y: auto; overscroll-behavior: contain; }
97+ }
98+ .r-files {
99+ & > h2 kbd { margin-left: auto; }
100+ & > .ffilter { margin: var(--s2) var(--s3) 0; }
101+ & > .ffilter input {
102+ width: 100%; height: 30px; padding: 0 var(--s2); font: inherit; font-size: var(--fs-s); color: inherit;
103+ background: var(--bg-inset); border: 1px solid var(--border-strong); border-radius: var(--r-s);
104+ }
105+ & > .files { margin: var(--s2) 0 !important; padding: 0 var(--s2); }
106+ }
107+
108+ /* a file of the diff whose window is not loaded yet: about its room */
109+ .dlazy {
110+ display: flex; align-items: center; justify-content: center;
111+ background: repeating-linear-gradient(var(--bg-surface) 0 var(--lh-code), var(--paper-alt) var(--lh-code) calc(var(--lh-code) * 2));
112+ border: 1px solid var(--border); border-top: 0; border-radius: 0 0 var(--r) var(--r);
113+ font-size: var(--fs-s);
114+ & a { color: var(--ink-mute); }
115+ &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }
116+ /* with scripts the window comes by itself: the link of the page without
117+ scripts would only flash */
118+ html.js & > a { visibility: hidden; }
119+ & .notice { margin: 0; }
120+ }
121+ .dloose {
122+ padding: var(--s2) var(--s3) var(--s3); background: var(--bg-surface);
123+ border: 1px solid var(--border); border-top: 0; border-radius: 0 0 var(--r) var(--r);
56124 }
57125
58126 .pcommits { font-size: var(--fs-s); display: grid; gap: var(--s1); }
M internal/adapters/web/static/js/ermite.js +42 -5
@@ -41,6 +41,7 @@
4141 };
4242
4343 const ermite = {
44+ flash,
4445 fire(name) {
4546 body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
4647 },
@@ -48,6 +49,7 @@
4849 // Row cursor: j / k.
4950 move(d) {
5051 if ($('dialog[open]')) return;
52+ if (window.ermiteMR?.stepFiles(d)) return;
5153 const rows = visibleRows();
5254 if (!rows.length) {
5355 scrollBy(0, d * 180);
@@ -73,6 +75,11 @@
7375 open() {
7476 if (!$('dialog[open]')) $('[data-cursor] a')?.click();
7577 },
78+ // openFile opens a file of the diff the table of contents links to.
79+ openFile(id) {
80+ const f = document.getElementById(id);
81+ if (f) f.open = true;
82+ },
7683 focusFilter() {
7784 $('[data-filter]')?.focus();
7885 },
@@ -210,9 +217,11 @@
210217
211218 // 1. Keys.
212219 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
213- s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' };
220+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc',
221+ // the merge request page (mr.js): the merge menu, mark read
222+ M: 'merge', R: 'read' };
214223 // "g t" is the key "t": the tabs have one event each.
215- const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top' };
224+ const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top', f: 'gf' };
216225 let pending = 0;
217226 let pendingTimer = 0;
218227 addEventListener('keydown', (e) => {
@@ -221,7 +230,16 @@
221230 // field keeps ctrl k (kill to end of line on macOS).
222231 if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return ermite.fire('cmd'); }
223232 if (e.ctrlKey || e.metaKey || e.altKey) return;
224- if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return ermite.fire('esc'); }
233+ const mr = window.ermiteMR;
234+ if (e.key === 'Escape') {
235+ pending = 0;
236+ if (mr?.closeMenus() || mr?.leaveFiles()) return;
237+ if (typing) e.target.blur();
238+ return ermite.fire('esc');
239+ }
240+ // An open merge menu takes its keys (1-9, D) before the field check: its
241+ // radios and its checkbox hold the focus.
242+ if (mr?.menuKey(e)) return;
225243 if (typing) return;
226244 // An open dialog takes only the keys that move between dialogs: the page
227245 // behind it does not react (scripted clicks would go through inert).
@@ -273,7 +291,7 @@
273291 // line (the first file when none is). One observer tracks the files in the
274292 // band; it reports only when a file enters or leaves it, so a scroll costs
275293 // nothing in between, and a jump that skips the band is caught by the scan.
276- const files = $$('.dfile');
294+ let files = $$('.dfile');
277295 const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a]));
278296 if (files.length && links.size && 'IntersectionObserver' in window) {
279297 const inBand = new Set();
@@ -284,7 +302,17 @@
284302 ?? files[0];
285303 if (f === current) return;
286304 links.get(current?.id)?.removeAttribute('aria-current');
287- links.get(f.id)?.setAttribute('aria-current', 'true');
305+ const a = links.get(f.id);
306+ a?.setAttribute('aria-current', 'true');
307+ // The list scrolls on its own: the current file stays in it. Only the
308+ // list moves, never the page (scrollIntoView would scroll the page to
309+ // the list when the rail is under the diff).
310+ const box = a?.closest('.files');
311+ if (a && box && box.scrollHeight > box.clientHeight && !box.contains(document.activeElement)) {
312+ const top = a.offsetTop;
313+ if (top < box.scrollTop) box.scrollTop = top;
314+ else if (top + a.offsetHeight > box.scrollTop + box.clientHeight) box.scrollTop = top + a.offsetHeight - box.clientHeight;
315+ }
288316 current = f;
289317 };
290318 const io = new IntersectionObserver((entries) => {
@@ -295,6 +323,15 @@
295323 mark();
296324 }, { rootMargin: '0px 0px -67% 0px' });
297325 for (const f of files) io.observe(f);
326+ // A window of files of a merge request came in (mr.js): its files replace
327+ // placeholders, which the observer and the list knew by element.
328+ ermite.watchFile = (f, old) => {
329+ if (old) { io.unobserve(old); inBand.delete(old); }
330+ files = $$('.dfile');
331+ for (const x of [...inBand]) if (!x.isConnected) inBand.delete(x);
332+ if (current && !current.isConnected) current = document.getElementById(current.id);
333+ io.observe(f);
334+ };
298335 }
299336
300337 // 5. A failure of a handler or of a request (htmx logs it to the console
A internal/adapters/web/static/js/mr.js +249 -0
@@ -0,0 +1,249 @@
1+// mr.js: the merge request page beyond what htmx and hx-live give.
2+//
3+// 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.
8+// 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, Enter
10+// submits (the browser does it: the focus is on the button), Esc closes.
11+// R marks the head read. Nothing merges on one key.
12+// 3. g f puts the focus in the list of files; j / k move in it, Enter opens
13+// the file, Esc goes back to the diff.
14+//
15+// Nothing runs on a page without #diffs[data-mr]. window.ermiteMR.core is pure
16+// (jstest/mr.test.mjs).
17+window.ermiteMR = (() => {
18+ // core: the decisions, without the DOM.
19+ const core = {
20+ // windowOf is the start of the window of file i (files are f1, f2, ...).
21+ windowOf(id, size) {
22+ const n = Number(String(id).replace(/^f/, '')) - 1;
23+ return Number.isInteger(n) && n >= 0 ? n - (n % size) : -1;
24+ },
25+ // menuKey says what a key does while the merge menu is open: a mode
26+ // (index), the branch deletion, or nothing.
27+ menuKey(key, modes) {
28+ if (key === 'D') return { toggle: true };
29+ if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 };
30+ return null;
31+ },
32+ // step moves an index within [0, n).
33+ step(i, d, n) {
34+ if (n === 0) return -1;
35+ if (i < 0) return d > 0 ? 0 : n - 1;
36+ return Math.max(0, Math.min(n - 1, i + d));
37+ },
38+ };
39+
40+ const $ = (sel, root = document) => root.querySelector(sel);
41+ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
42+ const loading = new Map(); // src -> promise of the swap
43+ let moved = false;
44+ let lazyIO = null; // the observer of the placeholders (watch)
45+ let revealGen = 0; // the last reveal wins the scroll
46+
47+ // load fetches the window of a placeholder and swaps its files in. The
48+ // promise resolves once they are in the page (or the page said to reload).
49+ function load(ph) {
50+ const src = ph?.dataset.src;
51+ if (!src || moved) return Promise.resolve();
52+ if (loading.has(src)) return loading.get(src);
53+ for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
54+ // The window renders like the page: the layout shown, and both layouts
55+ // when the page switches them itself (data-diff) or one (data-view).
56+ const d = $('#diffs')?.dataset || {};
57+ const view = d.diff || d.view;
58+ const layouts = d.diff ? 'both' : d.view ? 'one' : '';
59+ const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
60+ const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
61+ .then(async (res) => {
62+ const html = await res.text();
63+ if (res.status === 409) return stale(html);
64+ if (!res.ok) throw new Error('loading files: ' + res.status);
65+ // A window of the old diff after the page went stale stays out.
66+ if (!moved) swap(html);
67+ })
68+ .catch((err) => {
69+ loading.delete(src);
70+ for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
71+ window.ermite?.flash?.(err.message, 4000);
72+ });
73+ loading.set(src, p);
74+ return p;
75+ }
76+
77+ // swap replaces each placeholder file with the file of the same id. A
78+ // file the reader folded stays folded.
79+ function swap(html) {
80+ const t = document.createElement('template');
81+ t.innerHTML = html;
82+ for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
83+ const old = document.getElementById(f.id);
84+ if (!old || !old.querySelector('.dlazy')) continue;
85+ f.open = old.open;
86+ for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
87+ old.replaceWith(f);
88+ window.htmx?.process(f);
89+ window.ermite?.watchFile?.(f, old);
90+ }
91+ }
92+
93+ // stale: the diff changed under the page. The placeholders say so; no file
94+ // of another diff comes in.
95+ function stale(html) {
96+ moved = true;
97+ for (const p of $$('.dlazy')) {
98+ p.removeAttribute('data-loading');
99+ p.innerHTML = html;
100+ }
101+ }
102+
103+ // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
104+ // a placeholder, then scrolls to it. It returns false when there is nothing
105+ // to load.
106+ function reveal(id, scroll = true) {
107+ // Any navigation, loaded or not, makes the reveals still loading stale.
108+ const gen = ++revealGen;
109+ let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
110+ if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
111+ const base = id.replace(/-s$/, '');
112+ ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
113+ }
114+ if (!ph) return false;
115+ load(ph).then(() => {
116+ // A later reveal (another file asked meanwhile) owns the view.
117+ if (gen !== revealGen) return;
118+ const el = document.getElementById(id);
119+ if (!el) return;
120+ const d = el.closest('details');
121+ if (d) d.open = true;
122+ if (!scroll) return;
123+ // The files above may have changed size while it loaded (placeholders,
124+ // content-visibility): place it, then again once laid out.
125+ el.scrollIntoView({ block: 'start' });
126+ requestAnimationFrame(() => requestAnimationFrame(() => {
127+ if (gen === revealGen) el.scrollIntoView({ block: 'start' });
128+ }));
129+ });
130+ return true;
131+ }
132+
133+ // Placeholders load as they come within two screens of the view; files
134+ // that come in are marked in the list of files (ermite.js marks the one in
135+ // view).
136+ function watch() {
137+ if (!('IntersectionObserver' in window)) {
138+ for (const p of $$('.dlazy[data-src]')) load(p);
139+ return;
140+ }
141+ lazyIO = new IntersectionObserver((entries) => {
142+ for (const e of entries) {
143+ if (!e.isIntersecting) continue;
144+ lazyIO.unobserve(e.target);
145+ load(e.target);
146+ }
147+ }, { rootMargin: '200% 0px 200% 0px' });
148+ for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
149+ }
150+
151+ // The merge menu.
152+ const menu = () => $('#merge-menu');
153+ function openMerge() {
154+ const m = menu();
155+ if (!m) return false;
156+ for (const d of $$('details.menu[open]')) if (d !== m) d.open = false;
157+ m.open = true;
158+ $('[data-default]', m)?.focus();
159+ return true;
160+ }
161+ function closeMenus() {
162+ const open = $$('details.menu[open]');
163+ for (const d of open) d.open = false;
164+ if (open.length) $('summary', open[0])?.focus();
165+ return open.length > 0;
166+ }
167+ // menuKey handles a key while the merge menu is open; true when it used it.
168+ function menuKey(e) {
169+ const m = menu();
170+ if (!m?.open || e.ctrlKey || e.metaKey || e.altKey) return false;
171+ // An open dialog (help, commands) is above the menu: it keeps the keys.
172+ if ($('dialog[open]')) return false;
173+ // A field outside the menu (a comment being written) keeps its keys.
174+ const field = e.target.closest?.('input, textarea, select, [contenteditable]');
175+ if (field && !m.contains(field)) return false;
176+ const radios = $$('input[name="mode"]', m);
177+ const act = core.menuKey(e.key, radios.length);
178+ if (!act) return false;
179+ e.preventDefault();
180+ if (act.toggle) {
181+ const box = $('input[name="delete_branch"]', m);
182+ if (box) box.checked = !box.checked;
183+ } else {
184+ radios[act.mode].checked = true;
185+ }
186+ $('[data-default]', m)?.focus();
187+ return true;
188+ }
189+ function markRead() {
190+ const b = $('button[data-read]');
191+ if (!b) return false;
192+ b.closest('form').requestSubmit(b);
193+ return true;
194+ }
195+
196+ // The list of files by keyboard: g f focuses the current file.
197+ const links = () => $$('.r-files .files > li:not([hidden]) > a');
198+ function focusFiles() {
199+ const all = links();
200+ if (!all.length) return false;
201+ const cur = all.find((a) => a.getAttribute('aria-current') === 'true') || all[0];
202+ cur.focus();
203+ cur.scrollIntoView({ block: 'nearest' });
204+ return true;
205+ }
206+ // stepFiles moves the focus in the list; false when the focus is not in it.
207+ function stepFiles(d) {
208+ const all = links();
209+ const i = all.indexOf(document.activeElement);
210+ if (i < 0) return false;
211+ const j = core.step(i, d, all.length);
212+ all[j].focus();
213+ all[j].scrollIntoView({ block: 'nearest' });
214+ return true;
215+ }
216+ function leaveFiles() {
217+ if (!links().includes(document.activeElement)) return false;
218+ document.getElementById('main')?.focus({ preventScroll: true });
219+ return true;
220+ }
221+
222+ // openFile opens the file of a link of the list (and loads it first).
223+ function openFile(id) {
224+ const f = document.getElementById(id);
225+ if (f) f.open = true;
226+ reveal(id);
227+ }
228+
229+ function start() {
230+ if (!$('#diffs[data-mr]')) return;
231+ watch();
232+ // A link to a file or a comment that is not loaded yet.
233+ const hash = () => {
234+ const id = decodeURIComponent(location.hash.slice(1));
235+ if (id) reveal(id);
236+ };
237+ addEventListener('hashchange', hash);
238+ hash();
239+ // A click outside an open menu closes it.
240+ addEventListener('click', (e) => {
241+ for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;
242+ });
243+ }
244+
245+ return { core, start, load, reveal, openMerge, closeMenus, menuKey, markRead, focusFiles, stepFiles, leaveFiles, openFile };
246+})();
247+
248+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
249+else window.ermiteMR.start();
M internal/adapters/web/view.go +2 -0
@@ -67,6 +67,8 @@ type Layout struct {
6767 // Passkeys tells that the passkey routes exist: the header then links to
6868 // them.
6969 Passkeys bool
70+ // MR loads the script of the merge request page (mr.js).
71+ MR bool
7072 }
7173
7274 // Link is a plain label + href pair.
M internal/adapters/web/web.go +1 -0
@@ -177,6 +177,7 @@ func (s *Server) routes() *http.ServeMux {
177177 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
178178 mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))
179179 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
180+ mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))
180181 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
181182 // The writes: POST only, behind the gate (ServeHTTP).
182183 mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))
M internal/app/testdata/web/blob-binary.html +4 -1
@@ -374,7 +374,10 @@
374374 k-split from:body -> ermite.layout('split');
375375 k-unified from:body -> ermite.layout('unified');
376376 k-collapse from:body -> ermite.collapse();
377- k-permalink from:body -> ermite.permalink()">
377+ k-permalink from:body -> ermite.permalink();
378+ k-merge from:body -> window.ermiteMR?.openMerge();
379+ k-read from:body -> window.ermiteMR?.markRead();
380+ k-gf from:body -> window.ermiteMR?.focusFiles()">
378381 </div>
379382 </body>
380383 </html>
M internal/app/testdata/web/blob-code.html +4 -1
@@ -444,7 +444,10 @@
444444 k-split from:body -> ermite.layout('split');
445445 k-unified from:body -> ermite.layout('unified');
446446 k-collapse from:body -> ermite.collapse();
447- k-permalink from:body -> ermite.permalink()">
447+ k-permalink from:body -> ermite.permalink();
448+ k-merge from:body -> window.ermiteMR?.openMerge();
449+ k-read from:body -> window.ermiteMR?.markRead();
450+ k-gf from:body -> window.ermiteMR?.focusFiles()">
448451 </div>
449452 </body>
450453 </html>
M internal/app/testdata/web/blob-empty.html +4 -1
@@ -374,7 +374,10 @@
374374 k-split from:body -> ermite.layout('split');
375375 k-unified from:body -> ermite.layout('unified');
376376 k-collapse from:body -> ermite.collapse();
377- k-permalink from:body -> ermite.permalink()">
377+ k-permalink from:body -> ermite.permalink();
378+ k-merge from:body -> window.ermiteMR?.openMerge();
379+ k-read from:body -> window.ermiteMR?.markRead();
380+ k-gf from:body -> window.ermiteMR?.focusFiles()">
378381 </div>
379382 </body>
380383 </html>
M internal/app/testdata/web/blob-image.html +4 -1
@@ -376,7 +376,10 @@
376376 k-split from:body -> ermite.layout('split');
377377 k-unified from:body -> ermite.layout('unified');
378378 k-collapse from:body -> ermite.collapse();
379- k-permalink from:body -> ermite.permalink()">
379+ k-permalink from:body -> ermite.permalink();
380+ k-merge from:body -> window.ermiteMR?.openMerge();
381+ k-read from:body -> window.ermiteMR?.markRead();
382+ k-gf from:body -> window.ermiteMR?.focusFiles()">
380383 </div>
381384 </body>
382385 </html>
M internal/app/testdata/web/blob-markdown.html +4 -1
@@ -441,7 +441,10 @@
441441 k-split from:body -> ermite.layout('split');
442442 k-unified from:body -> ermite.layout('unified');
443443 k-collapse from:body -> ermite.collapse();
444- k-permalink from:body -> ermite.permalink()">
444+ k-permalink from:body -> ermite.permalink();
445+ k-merge from:body -> window.ermiteMR?.openMerge();
446+ k-read from:body -> window.ermiteMR?.markRead();
447+ k-gf from:body -> window.ermiteMR?.focusFiles()">
445448 </div>
446449 </body>
447450 </html>
M internal/app/testdata/web/blob-svg.html +4 -1
@@ -399,7 +399,10 @@
399399 k-split from:body -> ermite.layout('split');
400400 k-unified from:body -> ermite.layout('unified');
401401 k-collapse from:body -> ermite.collapse();
402- k-permalink from:body -> ermite.permalink()">
402+ k-permalink from:body -> ermite.permalink();
403+ k-merge from:body -> window.ermiteMR?.openMerge();
404+ k-read from:body -> window.ermiteMR?.markRead();
405+ k-gf from:body -> window.ermiteMR?.focusFiles()">
403406 </div>
404407 </body>
405408 </html>
M internal/app/testdata/web/blob-text.html +4 -1
@@ -394,7 +394,10 @@
394394 k-split from:body -> ermite.layout('split');
395395 k-unified from:body -> ermite.layout('unified');
396396 k-collapse from:body -> ermite.collapse();
397- k-permalink from:body -> ermite.permalink()">
397+ k-permalink from:body -> ermite.permalink();
398+ k-merge from:body -> window.ermiteMR?.openMerge();
399+ k-read from:body -> window.ermiteMR?.markRead();
400+ k-gf from:body -> window.ermiteMR?.focusFiles()">
398401 </div>
399402 </body>
400403 </html>
M internal/app/testdata/web/blob-too-large.html +4 -1
@@ -374,7 +374,10 @@
374374 k-split from:body -> ermite.layout('split');
375375 k-unified from:body -> ermite.layout('unified');
376376 k-collapse from:body -> ermite.collapse();
377- k-permalink from:body -> ermite.permalink()">
377+ k-permalink from:body -> ermite.permalink();
378+ k-merge from:body -> window.ermiteMR?.openMerge();
379+ k-read from:body -> window.ermiteMR?.markRead();
380+ k-gf from:body -> window.ermiteMR?.focusFiles()">
378381 </div>
379382 </body>
380383 </html>
M internal/app/testdata/web/blob-trojan.html +4 -1
@@ -441,7 +441,10 @@
441441 k-split from:body -> ermite.layout('split');
442442 k-unified from:body -> ermite.layout('unified');
443443 k-collapse from:body -> ermite.collapse();
444- k-permalink from:body -> ermite.permalink()">
444+ k-permalink from:body -> ermite.permalink();
445+ k-merge from:body -> window.ermiteMR?.openMerge();
446+ k-read from:body -> window.ermiteMR?.markRead();
447+ k-gf from:body -> window.ermiteMR?.focusFiles()">
445448 </div>
446449 </body>
447450 </html>
M internal/app/testdata/web/commit-big.html +14 -10
@@ -130,7 +130,7 @@
130130 </div>
131131 <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>
132132 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
133-<details class="dfile" id="f1" data-file>
133+<details class="dfile lnw-2" id="f1" data-file>
134134 <summary>
135135 <span class="st">A</span>
136136 <span class="p">big.txt</span>
@@ -148,18 +148,19 @@
148148 </h2>
149149 <ol class="files" aria-label="Changed files">
150150 <li>
151-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
152-<span class="st" title="A">A</span>
153-<span class="p" title="big.txt">big.txt</span>
151+<a href="#f1" title="big.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
152+<span class="st" data-st="A" title="added">A</span>
153+<span class="nm">
154+<span class="p">big.txt</span>
155+<span class="d">
156+<bdi>
157+</bdi>
158+</span>
159+</span>
154160 <span class="n">
155161 <span class="plus">+2600</span>
156162 <span class="minus">-0</span>
157163 </span>
158-<span class="b" aria-hidden="true">
159-<span class="plus">++++++++++</span>
160-<span class="minus">
161-</span>
162-</span>
163164 </a>
164165 </li>
165166 </ol>
@@ -411,7 +412,10 @@
411412 k-split from:body -> ermite.layout('split');
412413 k-unified from:body -> ermite.layout('unified');
413414 k-collapse from:body -> ermite.collapse();
414- k-permalink from:body -> ermite.permalink()">
415+ k-permalink from:body -> ermite.permalink();
416+ k-merge from:body -> window.ermiteMR?.openMerge();
417+ k-read from:body -> window.ermiteMR?.markRead();
418+ k-gf from:body -> window.ermiteMR?.focusFiles()">
415419 </div>
416420 </body>
417421 </html>
M internal/app/testdata/web/commit-binary.html +13 -12
@@ -125,7 +125,7 @@
125125 </button>
126126 </div>
127127 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
128-<details class="dfile" id="f1" data-file>
128+<details class="dfile lnw-2" id="f1" data-file>
129129 <summary>
130130 <span class="st">M</span>
131131 <span class="p">logo.bin</span>
@@ -143,18 +143,16 @@
143143 </h2>
144144 <ol class="files" aria-label="Changed files">
145145 <li>
146-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
147-<span class="st" title="M">M</span>
148-<span class="p" title="logo.bin">logo.bin</span>
149-<span class="n">
150-<span class="plus">+0</span>
151-<span class="minus">-0</span>
146+<a href="#f1" title="logo.bin" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
147+<span class="st" data-st="M" title="modified">M</span>
148+<span class="nm">
149+<span class="p">logo.bin</span>
150+<span class="d">
151+<bdi>
152+</bdi>
152153 </span>
153-<span class="b" aria-hidden="true">
154-<span class="plus">
155154 </span>
156-<span class="minus">
157-</span>bin</span>
155+<span class="n mute">bin</span>
158156 </a>
159157 </li>
160158 </ol>
@@ -406,7 +404,10 @@
406404 k-split from:body -> ermite.layout('split');
407405 k-unified from:body -> ermite.layout('unified');
408406 k-collapse from:body -> ermite.collapse();
409- k-permalink from:body -> ermite.permalink()">
407+ k-permalink from:body -> ermite.permalink();
408+ k-merge from:body -> window.ermiteMR?.openMerge();
409+ k-read from:body -> window.ermiteMR?.markRead();
410+ k-gf from:body -> window.ermiteMR?.focusFiles()">
410411 </div>
411412 </body>
412413 </html>
M internal/app/testdata/web/commit-merge.html +14 -10
@@ -124,7 +124,7 @@
124124 </button>
125125 </div>
126126 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
127-<details class="dfile" id="f1" data-file open>
127+<details class="dfile lnw-2" id="f1" data-file open>
128128 <summary>
129129 <span class="st">A</span>
130130 <span class="p">side.txt</span>
@@ -182,18 +182,19 @@
182182 </h2>
183183 <ol class="files" aria-label="Changed files">
184184 <li>
185-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
186-<span class="st" title="A">A</span>
187-<span class="p" title="side.txt">side.txt</span>
185+<a href="#f1" title="side.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
186+<span class="st" data-st="A" title="added">A</span>
187+<span class="nm">
188+<span class="p">side.txt</span>
189+<span class="d">
190+<bdi>
191+</bdi>
192+</span>
193+</span>
188194 <span class="n">
189195 <span class="plus">+1</span>
190196 <span class="minus">-0</span>
191197 </span>
192-<span class="b" aria-hidden="true">
193-<span class="plus">++++++++++</span>
194-<span class="minus">
195-</span>
196-</span>
197198 </a>
198199 </li>
199200 </ol>
@@ -445,7 +446,10 @@
445446 k-split from:body -> ermite.layout('split');
446447 k-unified from:body -> ermite.layout('unified');
447448 k-collapse from:body -> ermite.collapse();
448- k-permalink from:body -> ermite.permalink()">
449+ k-permalink from:body -> ermite.permalink();
450+ k-merge from:body -> window.ermiteMR?.openMerge();
451+ k-read from:body -> window.ermiteMR?.markRead();
452+ k-gf from:body -> window.ermiteMR?.focusFiles()">
449453 </div>
450454 </body>
451455 </html>
M internal/app/testdata/web/commit-modify-split.html +24 -18
@@ -131,7 +131,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
131131 </button>
132132 </div>
133133 <div id="diffs" data-diff="split">
134-<details class="dfile" id="f1" data-file open>
134+<details class="dfile lnw-2" id="f1" data-file open>
135135 <summary>
136136 <span class="st">M</span>
137137 <span class="p">a.txt</span>
@@ -271,7 +271,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
271271 </table>
272272 </div>
273273 </details>
274-<details class="dfile" id="f2" data-file>
274+<details class="dfile lnw-2" id="f2" data-file>
275275 <summary>
276276 <span class="st">M</span>
277277 <span class="p">run.sh</span>
@@ -289,32 +289,35 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
289289 </h2>
290290 <ol class="files" aria-label="Changed files">
291291 <li>
292-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
293-<span class="st" title="M">M</span>
294-<span class="p" title="a.txt">a.txt</span>
292+<a href="#f1" title="a.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
293+<span class="st" data-st="M" title="modified">M</span>
294+<span class="nm">
295+<span class="p">a.txt</span>
296+<span class="d">
297+<bdi>
298+</bdi>
299+</span>
300+</span>
295301 <span class="n">
296302 <span class="plus">+2</span>
297303 <span class="minus">-1</span>
298304 </span>
299-<span class="b" aria-hidden="true">
300-<span class="plus">+++++++</span>
301-<span class="minus">---</span>
302-</span>
303305 </a>
304306 </li>
305307 <li>
306-<a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
307-<span class="st" title="M">M</span>
308-<span class="p" title="run.sh">run.sh</span>
308+<a href="#f2" title="run.sh" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
309+<span class="st" data-st="M" title="modified">M</span>
310+<span class="nm">
311+<span class="p">run.sh</span>
312+<span class="d">
313+<bdi>
314+</bdi>
315+</span>
316+</span>
309317 <span class="n">
310318 <span class="plus">+0</span>
311319 <span class="minus">-0</span>
312320 </span>
313-<span class="b" aria-hidden="true">
314-<span class="plus">
315-</span>
316-<span class="minus">
317-</span>··········</span>
318321 </a>
319322 </li>
320323 </ol>
@@ -566,7 +569,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
566569 k-split from:body -> ermite.layout('split');
567570 k-unified from:body -> ermite.layout('unified');
568571 k-collapse from:body -> ermite.collapse();
569- k-permalink from:body -> ermite.permalink()">
572+ k-permalink from:body -> ermite.permalink();
573+ k-merge from:body -> window.ermiteMR?.openMerge();
574+ k-read from:body -> window.ermiteMR?.markRead();
575+ k-gf from:body -> window.ermiteMR?.focusFiles()">
570576 </div>
571577 </body>
572578 </html>
M internal/app/testdata/web/commit-modify.html +24 -18
@@ -131,7 +131,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
131131 </button>
132132 </div>
133133 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
134-<details class="dfile" id="f1" data-file open>
134+<details class="dfile lnw-2" id="f1" data-file open>
135135 <summary>
136136 <span class="st">M</span>
137137 <span class="p">a.txt</span>
@@ -271,7 +271,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
271271 </table>
272272 </div>
273273 </details>
274-<details class="dfile" id="f2" data-file>
274+<details class="dfile lnw-2" id="f2" data-file>
275275 <summary>
276276 <span class="st">M</span>
277277 <span class="p">run.sh</span>
@@ -289,32 +289,35 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
289289 </h2>
290290 <ol class="files" aria-label="Changed files">
291291 <li>
292-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
293-<span class="st" title="M">M</span>
294-<span class="p" title="a.txt">a.txt</span>
292+<a href="#f1" title="a.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
293+<span class="st" data-st="M" title="modified">M</span>
294+<span class="nm">
295+<span class="p">a.txt</span>
296+<span class="d">
297+<bdi>
298+</bdi>
299+</span>
300+</span>
295301 <span class="n">
296302 <span class="plus">+2</span>
297303 <span class="minus">-1</span>
298304 </span>
299-<span class="b" aria-hidden="true">
300-<span class="plus">+++++++</span>
301-<span class="minus">---</span>
302-</span>
303305 </a>
304306 </li>
305307 <li>
306-<a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
307-<span class="st" title="M">M</span>
308-<span class="p" title="run.sh">run.sh</span>
308+<a href="#f2" title="run.sh" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
309+<span class="st" data-st="M" title="modified">M</span>
310+<span class="nm">
311+<span class="p">run.sh</span>
312+<span class="d">
313+<bdi>
314+</bdi>
315+</span>
316+</span>
309317 <span class="n">
310318 <span class="plus">+0</span>
311319 <span class="minus">-0</span>
312320 </span>
313-<span class="b" aria-hidden="true">
314-<span class="plus">
315-</span>
316-<span class="minus">
317-</span>··········</span>
318321 </a>
319322 </li>
320323 </ol>
@@ -566,7 +569,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
566569 k-split from:body -> ermite.layout('split');
567570 k-unified from:body -> ermite.layout('unified');
568571 k-collapse from:body -> ermite.collapse();
569- k-permalink from:body -> ermite.permalink()">
572+ k-permalink from:body -> ermite.permalink();
573+ k-merge from:body -> window.ermiteMR?.openMerge();
574+ k-read from:body -> window.ermiteMR?.markRead();
575+ k-gf from:body -> window.ermiteMR?.focusFiles()">
570576 </div>
571577 </body>
572578 </html>
M internal/app/testdata/web/commit-rename.html +24 -19
@@ -125,7 +125,7 @@
125125 </button>
126126 </div>
127127 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
128-<details class="dfile" id="f1" data-file>
128+<details class="dfile lnw-2" id="f1" data-file>
129129 <summary>
130130 <span class="st">R</span>
131131 <span class="p">old.txt → docs-old.txt</span>
@@ -135,7 +135,7 @@
135135 </summary>
136136 <p class="note pad">renamed, 100% similar</p>
137137 </details>
138-<details class="dfile" id="f2" data-file open>
138+<details class="dfile lnw-2" id="f2" data-file open>
139139 <summary>
140140 <span class="st">D</span>
141141 <span class="p">gone.txt</span>
@@ -193,33 +193,35 @@
193193 </h2>
194194 <ol class="files" aria-label="Changed files">
195195 <li>
196-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
197-<span class="st" title="R">R</span>
198-<span class="p" title="old.txt → docs-old.txt">old.txt → docs-old.txt</span>
196+<a href="#f1" title="old.txt → docs-old.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
197+<span class="st" data-st="R" title="renamed">R</span>
198+<span class="nm">
199+<span class="p">docs-old.txt</span>
200+<span class="d">
201+<bdi>
202+</bdi>
203+</span>
204+</span>
199205 <span class="n">
200206 <span class="plus">+0</span>
201207 <span class="minus">-0</span>
202208 </span>
203-<span class="b" aria-hidden="true">
204-<span class="plus">
205-</span>
206-<span class="minus">
207-</span>··········</span>
208209 </a>
209210 </li>
210211 <li>
211-<a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
212-<span class="st" title="D">D</span>
213-<span class="p" title="gone.txt">gone.txt</span>
212+<a href="#f2" title="gone.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
213+<span class="st" data-st="D" title="deleted">D</span>
214+<span class="nm">
215+<span class="p">gone.txt</span>
216+<span class="d">
217+<bdi>
218+</bdi>
219+</span>
220+</span>
214221 <span class="n">
215222 <span class="plus">+0</span>
216223 <span class="minus">-1</span>
217224 </span>
218-<span class="b" aria-hidden="true">
219-<span class="plus">
220-</span>
221-<span class="minus">----------</span>
222-</span>
223225 </a>
224226 </li>
225227 </ol>
@@ -471,7 +473,10 @@
471473 k-split from:body -> ermite.layout('split');
472474 k-unified from:body -> ermite.layout('unified');
473475 k-collapse from:body -> ermite.collapse();
474- k-permalink from:body -> ermite.permalink()">
476+ k-permalink from:body -> ermite.permalink();
477+ k-merge from:body -> window.ermiteMR?.openMerge();
478+ k-read from:body -> window.ermiteMR?.markRead();
479+ k-gf from:body -> window.ermiteMR?.focusFiles()">
475480 </div>
476481 </body>
477482 </html>
M internal/app/testdata/web/commit-root.html +53 -45
@@ -123,7 +123,7 @@
123123 </button>
124124 </div>
125125 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
126-<details class="dfile" id="f1" data-file open>
126+<details class="dfile lnw-2" id="f1" data-file open>
127127 <summary>
128128 <span class="st">A</span>
129129 <span class="p">a.txt</span>
@@ -245,7 +245,7 @@
245245 </table>
246246 </div>
247247 </details>
248-<details class="dfile" id="f2" data-file open>
248+<details class="dfile lnw-2" id="f2" data-file open>
249249 <summary>
250250 <span class="st">A</span>
251251 <span class="p">gone.txt</span>
@@ -295,7 +295,7 @@
295295 </table>
296296 </div>
297297 </details>
298-<details class="dfile" id="f3" data-file>
298+<details class="dfile lnw-2" id="f3" data-file>
299299 <summary>
300300 <span class="st">A</span>
301301 <span class="p">logo.bin</span>
@@ -305,7 +305,7 @@
305305 </summary>
306306 <p class="note pad">binary file: no text diff</p>
307307 </details>
308-<details class="dfile" id="f4" data-file open>
308+<details class="dfile lnw-2" id="f4" data-file open>
309309 <summary>
310310 <span class="st">A</span>
311311 <span class="p">old.txt</span>
@@ -391,7 +391,7 @@
391391 </table>
392392 </div>
393393 </details>
394-<details class="dfile" id="f5" data-file open>
394+<details class="dfile lnw-2" id="f5" data-file open>
395395 <summary>
396396 <span class="st">A</span>
397397 <span class="p">run.sh</span>
@@ -467,75 +467,80 @@
467467 </h2>
468468 <ol class="files" aria-label="Changed files">
469469 <li>
470-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
471-<span class="st" title="A">A</span>
472-<span class="p" title="a.txt">a.txt</span>
470+<a href="#f1" title="a.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
471+<span class="st" data-st="A" title="added">A</span>
472+<span class="nm">
473+<span class="p">a.txt</span>
474+<span class="d">
475+<bdi>
476+</bdi>
477+</span>
478+</span>
473479 <span class="n">
474480 <span class="plus">+5</span>
475481 <span class="minus">-0</span>
476482 </span>
477-<span class="b" aria-hidden="true">
478-<span class="plus">++++++++++</span>
479-<span class="minus">
480-</span>
481-</span>
482483 </a>
483484 </li>
484485 <li>
485-<a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
486-<span class="st" title="A">A</span>
487-<span class="p" title="gone.txt">gone.txt</span>
486+<a href="#f2" title="gone.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
487+<span class="st" data-st="A" title="added">A</span>
488+<span class="nm">
489+<span class="p">gone.txt</span>
490+<span class="d">
491+<bdi>
492+</bdi>
493+</span>
494+</span>
488495 <span class="n">
489496 <span class="plus">+1</span>
490497 <span class="minus">-0</span>
491498 </span>
492-<span class="b" aria-hidden="true">
493-<span class="plus">++</span>
494-<span class="minus">
495-</span>········</span>
496499 </a>
497500 </li>
498501 <li>
499-<a href="#f3" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
500-<span class="st" title="A">A</span>
501-<span class="p" title="logo.bin">logo.bin</span>
502-<span class="n">
503-<span class="plus">+0</span>
504-<span class="minus">-0</span>
502+<a href="#f3" title="logo.bin" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
503+<span class="st" data-st="A" title="added">A</span>
504+<span class="nm">
505+<span class="p">logo.bin</span>
506+<span class="d">
507+<bdi>
508+</bdi>
505509 </span>
506-<span class="b" aria-hidden="true">
507-<span class="plus">
508510 </span>
509-<span class="minus">
510-</span>bin</span>
511+<span class="n mute">bin</span>
511512 </a>
512513 </li>
513514 <li>
514-<a href="#f4" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
515-<span class="st" title="A">A</span>
516-<span class="p" title="old.txt">old.txt</span>
515+<a href="#f4" title="old.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
516+<span class="st" data-st="A" title="added">A</span>
517+<span class="nm">
518+<span class="p">old.txt</span>
519+<span class="d">
520+<bdi>
521+</bdi>
522+</span>
523+</span>
517524 <span class="n">
518525 <span class="plus">+3</span>
519526 <span class="minus">-0</span>
520527 </span>
521-<span class="b" aria-hidden="true">
522-<span class="plus">++++++</span>
523-<span class="minus">
524-</span>····</span>
525528 </a>
526529 </li>
527530 <li>
528-<a href="#f5" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
529-<span class="st" title="A">A</span>
530-<span class="p" title="run.sh">run.sh</span>
531+<a href="#f5" title="run.sh" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
532+<span class="st" data-st="A" title="added">A</span>
533+<span class="nm">
534+<span class="p">run.sh</span>
535+<span class="d">
536+<bdi>
537+</bdi>
538+</span>
539+</span>
531540 <span class="n">
532541 <span class="plus">+2</span>
533542 <span class="minus">-0</span>
534543 </span>
535-<span class="b" aria-hidden="true">
536-<span class="plus">++++</span>
537-<span class="minus">
538-</span>······</span>
539544 </a>
540545 </li>
541546 </ol>
@@ -787,7 +792,10 @@
787792 k-split from:body -> ermite.layout('split');
788793 k-unified from:body -> ermite.layout('unified');
789794 k-collapse from:body -> ermite.collapse();
790- k-permalink from:body -> ermite.permalink()">
795+ k-permalink from:body -> ermite.permalink();
796+ k-merge from:body -> window.ermiteMR?.openMerge();
797+ k-read from:body -> window.ermiteMR?.markRead();
798+ k-gf from:body -> window.ermiteMR?.focusFiles()">
791799 </div>
792800 </body>
793801 </html>
M internal/app/testdata/web/commit-trojan.html +14 -10
@@ -127,7 +127,7 @@
127127 </div>
128128 <p class="notice">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.</p>
129129 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
130-<details class="dfile" id="f1" data-file open>
130+<details class="dfile lnw-2" id="f1" data-file open>
131131 <summary>
132132 <span class="st">A</span>
133133 <span class="p">evil.go</span>
@@ -317,18 +317,19 @@
317317 </h2>
318318 <ol class="files" aria-label="Changed files">
319319 <li>
320-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
321-<span class="st" title="A">A</span>
322-<span class="p" title="evil.go">evil.go</span>
320+<a href="#f1" title="evil.go" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
321+<span class="st" data-st="A" title="added">A</span>
322+<span class="nm">
323+<span class="p">evil.go</span>
324+<span class="d">
325+<bdi>
326+</bdi>
327+</span>
328+</span>
323329 <span class="n">
324330 <span class="plus">+8</span>
325331 <span class="minus">-0</span>
326332 </span>
327-<span class="b" aria-hidden="true">
328-<span class="plus">++++++++++</span>
329-<span class="minus">
330-</span>
331-</span>
332333 </a>
333334 </li>
334335 </ol>
@@ -580,7 +581,10 @@
580581 k-split from:body -> ermite.layout('split');
581582 k-unified from:body -> ermite.layout('unified');
582583 k-collapse from:body -> ermite.collapse();
583- k-permalink from:body -> ermite.permalink()">
584+ k-permalink from:body -> ermite.permalink();
585+ k-merge from:body -> window.ermiteMR?.openMerge();
586+ k-read from:body -> window.ermiteMR?.markRead();
587+ k-gf from:body -> window.ermiteMR?.focusFiles()">
584588 </div>
585589 </body>
586590 </html>
M internal/app/testdata/web/commits-page2.html +4 -1
@@ -391,7 +391,10 @@
391391 k-split from:body -> ermite.layout('split');
392392 k-unified from:body -> ermite.layout('unified');
393393 k-collapse from:body -> ermite.collapse();
394- k-permalink from:body -> ermite.permalink()">
394+ k-permalink from:body -> ermite.permalink();
395+ k-merge from:body -> window.ermiteMR?.openMerge();
396+ k-read from:body -> window.ermiteMR?.markRead();
397+ k-gf from:body -> window.ermiteMR?.focusFiles()">
395398 </div>
396399 </body>
397400 </html>
M internal/app/testdata/web/commits-path.html +4 -1
@@ -406,7 +406,10 @@
406406 k-split from:body -> ermite.layout('split');
407407 k-unified from:body -> ermite.layout('unified');
408408 k-collapse from:body -> ermite.collapse();
409- k-permalink from:body -> ermite.permalink()">
409+ k-permalink from:body -> ermite.permalink();
410+ k-merge from:body -> window.ermiteMR?.openMerge();
411+ k-read from:body -> window.ermiteMR?.markRead();
412+ k-gf from:body -> window.ermiteMR?.focusFiles()">
410413 </div>
411414 </body>
412415 </html>
M internal/app/testdata/web/commits-root.html +4 -1
@@ -1291,7 +1291,10 @@
12911291 k-split from:body -> ermite.layout('split');
12921292 k-unified from:body -> ermite.layout('unified');
12931293 k-collapse from:body -> ermite.collapse();
1294- k-permalink from:body -> ermite.permalink()">
1294+ k-permalink from:body -> ermite.permalink();
1295+ k-merge from:body -> window.ermiteMR?.openMerge();
1296+ k-read from:body -> window.ermiteMR?.markRead();
1297+ k-gf from:body -> window.ermiteMR?.focusFiles()">
12951298 </div>
12961299 </body>
12971300 </html>
M internal/app/testdata/web/commits-small.html +4 -1
@@ -572,7 +572,10 @@
572572 k-split from:body -> ermite.layout('split');
573573 k-unified from:body -> ermite.layout('unified');
574574 k-collapse from:body -> ermite.collapse();
575- k-permalink from:body -> ermite.permalink()">
575+ k-permalink from:body -> ermite.permalink();
576+ k-merge from:body -> window.ermiteMR?.openMerge();
577+ k-read from:body -> window.ermiteMR?.markRead();
578+ k-gf from:body -> window.ermiteMR?.focusFiles()">
576579 </div>
577580 </body>
578581 </html>
M internal/app/testdata/web/empty.html +4 -1
@@ -240,7 +240,10 @@
240240 k-split from:body -> ermite.layout('split');
241241 k-unified from:body -> ermite.layout('unified');
242242 k-collapse from:body -> ermite.collapse();
243- k-permalink from:body -> ermite.permalink()">
243+ k-permalink from:body -> ermite.permalink();
244+ k-merge from:body -> window.ermiteMR?.openMerge();
245+ k-read from:body -> window.ermiteMR?.markRead();
246+ k-gf from:body -> window.ermiteMR?.focusFiles()">
244247 </div>
245248 </body>
246249 </html>
M internal/app/testdata/web/home.html +4 -1
@@ -278,7 +278,10 @@
278278 k-split from:body -> ermite.layout('split');
279279 k-unified from:body -> ermite.layout('unified');
280280 k-collapse from:body -> ermite.collapse();
281- k-permalink from:body -> ermite.permalink()">
281+ k-permalink from:body -> ermite.permalink();
282+ k-merge from:body -> window.ermiteMR?.openMerge();
283+ k-read from:body -> window.ermiteMR?.markRead();
284+ k-gf from:body -> window.ermiteMR?.focusFiles()">
282285 </div>
283286 </body>
284287 </html>
M internal/app/testdata/web/not-found-page.html +4 -1
@@ -223,7 +223,10 @@
223223 k-split from:body -> ermite.layout('split');
224224 k-unified from:body -> ermite.layout('unified');
225225 k-collapse from:body -> ermite.collapse();
226- k-permalink from:body -> ermite.permalink()">
226+ k-permalink from:body -> ermite.permalink();
227+ k-merge from:body -> window.ermiteMR?.openMerge();
228+ k-read from:body -> window.ermiteMR?.markRead();
229+ k-gf from:body -> window.ermiteMR?.focusFiles()">
227230 </div>
228231 </body>
229232 </html>
M internal/app/testdata/web/not-found.html +4 -1
@@ -223,7 +223,10 @@
223223 k-split from:body -> ermite.layout('split');
224224 k-unified from:body -> ermite.layout('unified');
225225 k-collapse from:body -> ermite.collapse();
226- k-permalink from:body -> ermite.permalink()">
226+ k-permalink from:body -> ermite.permalink();
227+ k-merge from:body -> window.ermiteMR?.openMerge();
228+ k-read from:body -> window.ermiteMR?.markRead();
229+ k-gf from:body -> window.ermiteMR?.focusFiles()">
227230 </div>
228231 </body>
229232 </html>
M internal/app/testdata/web/refs.html +4 -1
@@ -458,7 +458,10 @@
458458 k-split from:body -> ermite.layout('split');
459459 k-unified from:body -> ermite.layout('unified');
460460 k-collapse from:body -> ermite.collapse();
461- k-permalink from:body -> ermite.permalink()">
461+ k-permalink from:body -> ermite.permalink();
462+ k-merge from:body -> window.ermiteMR?.openMerge();
463+ k-read from:body -> window.ermiteMR?.markRead();
464+ k-gf from:body -> window.ermiteMR?.focusFiles()">
462465 </div>
463466 </body>
464467 </html>
M internal/app/testdata/web/summary.html +4 -1
@@ -487,7 +487,10 @@
487487 k-split from:body -> ermite.layout('split');
488488 k-unified from:body -> ermite.layout('unified');
489489 k-collapse from:body -> ermite.collapse();
490- k-permalink from:body -> ermite.permalink()">
490+ k-permalink from:body -> ermite.permalink();
491+ k-merge from:body -> window.ermiteMR?.openMerge();
492+ k-read from:body -> window.ermiteMR?.markRead();
493+ k-gf from:body -> window.ermiteMR?.focusFiles()">
491494 </div>
492495 </body>
493496 </html>
M internal/app/testdata/web/tree-nested.html +4 -1
@@ -394,7 +394,10 @@
394394 k-split from:body -> ermite.layout('split');
395395 k-unified from:body -> ermite.layout('unified');
396396 k-collapse from:body -> ermite.collapse();
397- k-permalink from:body -> ermite.permalink()">
397+ k-permalink from:body -> ermite.permalink();
398+ k-merge from:body -> window.ermiteMR?.openMerge();
399+ k-read from:body -> window.ermiteMR?.markRead();
400+ k-gf from:body -> window.ermiteMR?.focusFiles()">
398401 </div>
399402 </body>
400403 </html>
M internal/app/testdata/web/tree-root.html +4 -1
@@ -621,7 +621,10 @@
621621 k-split from:body -> ermite.layout('split');
622622 k-unified from:body -> ermite.layout('unified');
623623 k-collapse from:body -> ermite.collapse();
624- k-permalink from:body -> ermite.permalink()">
624+ k-permalink from:body -> ermite.permalink();
625+ k-merge from:body -> window.ermiteMR?.openMerge();
626+ k-read from:body -> window.ermiteMR?.markRead();
627+ k-gf from:body -> window.ermiteMR?.focusFiles()">
625628 </div>
626629 </body>
627630 </html>
M internal/app/testdata/web/tree-slash-ref.html +4 -1
@@ -391,7 +391,10 @@
391391 k-split from:body -> ermite.layout('split');
392392 k-unified from:body -> ermite.layout('unified');
393393 k-collapse from:body -> ermite.collapse();
394- k-permalink from:body -> ermite.permalink()">
394+ k-permalink from:body -> ermite.permalink();
395+ k-merge from:body -> window.ermiteMR?.openMerge();
396+ k-read from:body -> window.ermiteMR?.markRead();
397+ k-gf from:body -> window.ermiteMR?.focusFiles()">
395398 </div>
396399 </body>
397400 </html>
M internal/app/testdata/web/tree-subdir.html +4 -1
@@ -416,7 +416,10 @@
416416 k-split from:body -> ermite.layout('split');
417417 k-unified from:body -> ermite.layout('unified');
418418 k-collapse from:body -> ermite.collapse();
419- k-permalink from:body -> ermite.permalink()">
419+ k-permalink from:body -> ermite.permalink();
420+ k-merge from:body -> window.ermiteMR?.openMerge();
421+ k-read from:body -> window.ermiteMR?.markRead();
422+ k-gf from:body -> window.ermiteMR?.focusFiles()">
420423 </div>
421424 </body>
422425 </html>
M internal/app/web_mr_test.go +92 -7
@@ -3,6 +3,7 @@
33 package app
44
55 import (
6+ "fmt"
67 "html"
78 "net/http"
89 "net/url"
@@ -141,7 +142,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
141142 assert.Contains(t, mr.body, "Finds things.\nSee ", "line breaks are kept")
142143 assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form")
143144 assert.NotContains(t, mr.body, "ermite mr merge", "nor the merge line")
144- assert.NotContains(t, mr.body, ">Merge</button>")
145+ assert.NotContains(t, mr.body, `id="merge-menu"`)
145146 assert.Contains(t, mr.body, `href="/proj/commit/`+head+`"`, "the commits are in the rail")
146147 assert.NotContains(t, strings.ToLower(mr.body), "proposal")
147148 assertNoInline(t, mr.body)
@@ -157,10 +158,11 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
157158 assert.Contains(t, own.hdr.Values("Vary"), "Cookie")
158159 assert.Equal(t, head, formHead(t, own.body))
159160 assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`)
160- assert.Contains(t, own.body, `<option value="fast-forward">`)
161- assert.Contains(t, own.body, `<option value="squash">`)
161+ assert.Contains(t, own.body, `id="merge-menu"`, "the merge is a menu of the bar above the diff")
162+ assert.Contains(t, own.body, `<input type="radio" name="mode" value="fast-forward" checked>`, "the first mode is the default")
163+ assert.Contains(t, own.body, `<input type="radio" name="mode" value="squash">`)
162164 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head)
163- assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge`)
165+ assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`)
164166 assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default")
165167 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash")
166168 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)
@@ -313,7 +315,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
313315 assert.Contains(t, own.body, `action="/proj/merge-requests/1/read"`)
314316 res = b.form(base+"/read", token, url.Values{"head_id": {head1}})
315317 require.Equal(t, http.StatusSeeOther, res.status, res.body)
316- assert.NotContains(t, b.get(base, as(token)).body, ">Mark read</button>", "the head is the one that was read")
318+ assert.NotContains(t, b.get(base, as(token)).body, "data-read>Mark read", "the head is the one that was read")
317319 r.writeFile(r.wt, "extra.txt", []byte("extra\n"))
318320 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
319321 head2 := r.headOf("feat/search")
@@ -322,7 +324,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
322324 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)
323325 assert.Contains(t, all.body, "since your last read")
324326 assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked")
325- assert.Contains(t, all.body, ">Mark read</button>")
327+ assert.Contains(t, all.body, "data-read>Mark read")
326328 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")
327329 since := b.get(base+"/since", as(token))
328330 require.Equal(t, 200, since.status, since.body)
@@ -357,7 +359,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
357359 assert.Contains(t, done.body, "fast-forward")
358360 assert.Contains(t, done.body, `href="/proj/commit/`+head2+`"`)
359361 assert.NotContains(t, done.body, "<form")
360- assert.NotContains(t, b.get(base, as(token)).body, ">Merge</button>", "nothing to merge any more")
362+ assert.NotContains(t, b.get(base, as(token)).body, `id="merge-menu"`, "nothing to merge any more")
361363 // Writes on a merged request are refused with a page.
362364 late := b.form(base+"/comments", token, url.Values{"text": {"too late"}})
363365 assert.Equal(t, http.StatusConflict, late.status)
@@ -628,3 +630,86 @@ func TestWebMergeRequestSinceAfterForcePush(t *testing.T) {
628630 assert.Contains(t, lost.body, "is gone, the branch was rewritten")
629631 assert.Contains(t, lost.body, "other.txt", "the whole change is shown")
630632 }
633+
634+// A request of many files: the page lists every file and holds the hunks of
635+// the first window only; the other files are placeholders that load their
636+// window from /files, which carries the base and the head of the diff. A
637+// comment on a far file comes with its window; a moved head answers 409; the
638+// page of a window (?from=, the links of a page without scripts) shows its
639+// hunks and its line forms.
640+func TestWebMergeRequestFileWindows(t *testing.T) {
641+ r := newMRRepo(t, domain.VisibilityPublic)
642+ b := browser{r.harness}
643+ token := r.signIn(t)
644+ const n = 60
645+ for i := range n {
646+ r.writeFile(r.wt, fmt.Sprintf("many/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i)))
647+ }
648+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
649+ head := r.headOf("feat/search")
650+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Many").ok(t)
651+ r.cli(t, adminToken, "mr", "comment", "proj", "1", "--path", "many/f55.txt", "--line", "1", "-m", "far note").ok(t)
652+
653+ page := b.get("/proj/merge-requests/1", nil)
654+ require.Equal(t, 200, page.status)
655+ assert.Equal(t, n+1, strings.Count(page.body, `<li><a href="#f`), "every file is listed")
656+ assert.Equal(t, n+1-25, strings.Count(page.body, `class="dlazy`), "the files past the first window are placeholders")
657+ assert.NotContains(t, page.body, "far note", "the comment of a far file comes with its window")
658+ assert.Contains(t, page.body, `data-notes="c1"`, "its placeholder knows it")
659+ assert.Contains(t, page.body, `<span class="cn" title="1 open comment">1</span>`, "the list counts it")
660+ m := reWindowSrc.FindStringSubmatch(page.body)
661+ require.Len(t, m, 2, "a placeholder holds the address of its window")
662+ src := html.UnescapeString(m[1])
663+ assert.Contains(t, src, "head="+head)
664+
665+ win := b.get(strings.Replace(src, "from=25", "from=50", 1), nil)
666+ require.Equal(t, 200, win.status, win.body)
667+ assert.Contains(t, win.body, "far note")
668+ assert.Contains(t, win.body, `line 55</td>`)
669+ assert.NotContains(t, win.body, "<html", "a window is a fragment")
670+ assert.NotContains(t, win.body, `class="dlazy`)
671+ // The window renders the layouts of the page: both when it switches them,
672+ // the one it shows otherwise.
673+ both := b.get(src+"&view=split&layouts=both", nil)
674+ assert.Contains(t, both.body, `class="dt uni"`)
675+ assert.Contains(t, both.body, `class="dt spl"`)
676+ one := b.get(src+"&view=split&layouts=one", nil)
677+ assert.NotContains(t, one.body, `class="dt uni"`)
678+ assert.Contains(t, one.body, `class="dt spl"`)
679+ assert.Equal(t, 404, b.get(strings.Replace(src, "from=25", "from=7", 1), nil).status, "not a window start")
680+ assert.Equal(t, 404, b.get(strings.Replace(src, "from=25", "from=100", 1), nil).status, "past the files")
681+
682+ // The page of a window, without scripts: its hunks, the line forms of the
683+ // owner point back to it.
684+ own := b.get("/proj/merge-requests/1?from=50", as(token))
685+ require.Equal(t, 200, own.status)
686+ assert.Contains(t, own.body, "line 55</td>")
687+ assert.Contains(t, own.body, `href="/proj/merge-requests/1?from=50&amp;on=new:1:many%2Ff55.txt#f`)
688+ assert.NotContains(t, own.body, "line 0</td>", "the first window is a placeholder there")
689+ assert.Contains(t, own.body, `href="/proj/merge-requests/1?view=split&amp;from=50"`, "the layout links keep the window")
690+ assert.Equal(t, 404, b.get("/proj/merge-requests/1?from=3", nil).status)
691+
692+ // A comment on a line of a far window: its form, its cancel link and the
693+ // answer return to that window.
694+ form := b.get("/proj/merge-requests/1?from=50&on=new:1:many%2Ff55.txt", as(token))
695+ require.Equal(t, 200, form.status)
696+ assert.Contains(t, form.body, `name="from" value="50"`)
697+ assert.Contains(t, form.body, `class="btn" href="/proj/merge-requests/1?from=50#f`)
698+ res := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"near it"}, "path": {"many/f55.txt"}, "line": {"1"}, "side": {"new"}, "from": {"50"}})
699+ require.Equal(t, http.StatusSeeOther, res.status, res.body)
700+ assert.Equal(t, "/proj/merge-requests/1?from=50#c2", res.hdr.Get("Location"))
701+ assert.Contains(t, b.get("/proj/merge-requests/1?from=50", as(token)).body, `<input type="hidden" name="from" value="50">`, "its resolve form too")
702+ res = b.form("/proj/merge-requests/1/comments/2/resolve", token, url.Values{"resolved": {"true"}, "from": {"50"}})
703+ assert.Equal(t, "/proj/merge-requests/1?from=50#c2", res.hdr.Get("Location"))
704+ res = b.form("/proj/merge-requests/1/comments/2/resolve", token, url.Values{"resolved": {"false"}, "from": {"7"}})
705+ assert.Equal(t, "/proj/merge-requests/1#c2", res.hdr.Get("Location"), "a window that is none is dropped")
706+
707+ // The head moved: the window says to reload, never a file of another diff.
708+ r.writeFile(r.wt, "later.txt", []byte("later\n"))
709+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
710+ stale := b.get(src, nil)
711+ assert.Equal(t, http.StatusConflict, stale.status)
712+ assert.Contains(t, stale.body, "data-moved")
713+}
714+
715+var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`)
M internal/domain/git.go +14 -3
@@ -493,7 +493,8 @@ type FileDiff struct {
493493 // Similarity is a percentage for a renamed or copied file, 0 otherwise.
494494 Similarity int
495495 // Truncated means the file exceeded a diff limit: Hunks is empty although
496- // the file changed. Additions and Deletions stay accurate.
496+ // the file changed. Additions and Deletions stay accurate. A file outside
497+ // the DiffLimits window has no Hunks and is not Truncated.
497498 Truncated bool
498499 Hunks []Hunk
499500 }
@@ -502,9 +503,11 @@ type FileDiff struct {
502503 // parent, or against the empty tree for a root commit.
503504 type Diff struct {
504505 // Files are ordered as Git reports them (by path). Files past
505- // DiffLimits.MaxFiles are left out.
506+ // DiffLimits.MaxFiles (when it is not 0) are left out.
506507 Files []FileDiff
507508 // Truncated is true when files were left out or a listed file is truncated.
509+ // Files listed without hunks because they are outside the DiffLimits window
510+ // do not make it true.
508511 Truncated bool
509512 }
510513
@@ -523,7 +526,8 @@ type CommitDetail struct {
523526
524527 // DiffLimits bound the size of a Diff. Lines are hunk lines, context included.
525528 type DiffLimits struct {
526- // MaxFiles is the number of files kept.
529+ // MaxFiles is the number of files kept; 0 means no limit on the number of
530+ // files (every changed file is listed).
527531 MaxFiles int
528532 // MaxFileLines is the most lines one file may have; a file over it is
529533 // truncated.
@@ -536,6 +540,13 @@ type DiffLimits struct {
536540 // the memory of a diff whose lines are very long; files are truncated
537541 // like for MaxTotalLines.
538542 MaxTotalBytes int
543+ // From and To select the files whose hunks are read: those with an index in
544+ // [From, To), counted from 0 in the order of Diff.Files. The other files
545+ // are listed (paths, status, modes, ids, Binary, Additions, Deletions,
546+ // Similarity) without hunks; they are not Truncated and use none of
547+ // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values
548+ // select every file. MaxFileLines applies to each file of the window.
549+ From, To int
539550 }
540551
541552 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
M internal/ports/dto.go +20 -0
@@ -194,6 +194,26 @@ type MergeRequestDetail struct {
194194 LastReadLost bool
195195 }
196196
197+// GetMergeRequestOptions say which diff MergeRequestService.Get reads.
198+type GetMergeRequestOptions struct {
199+ // SinceLastRead compares the head recorded by MarkRead with the head (see Get).
200+ SinceLastRead bool
201+ // Files, when set, lists every changed file, without the file-count
202+ // limit, and reads the hunks of the files in the window only, each within
203+ // the per-file and the total limits of domain.DefaultDiffLimits. Unset,
204+ // the diff is bounded by domain.DefaultDiffLimits (the API contract).
205+ Files *FileWindow
206+ // Base and Head, when not zero, are the diff the caller shows: Get
207+ // returns domain.ErrConflict when the request's diff base or head differ
208+ // (the branch moved, a read was marked).
209+ Base, Head domain.ObjectID
210+ // DiffOnly skips the commits of the request (Commits stays empty).
211+ DiffOnly bool
212+}
213+
214+// FileWindow is the range [From, To) of files whose hunks are read.
215+type FileWindow struct{ From, To int }
216+
197217 // AddCommentParams describes the comment to add.
198218 type AddCommentParams struct {
199219 // Path, Line and Side designate a line of the diff; all empty for a
M internal/ports/mocks/MergeRequestService.go +15 -15
@@ -361,8 +361,8 @@ func (_c *MergeRequestService_Create_Call) RunAndReturn(run func(ctx context.Con
361361 }
362362
363363 // Get provides a mock function for the type MergeRequestService
364-func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (ports.MergeRequestDetail, error) {
365- ret := _mock.Called(ctx, p, name, number, sinceLastRead)
364+func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
365+ ret := _mock.Called(ctx, p, name, number, opts)
366366
367367 if len(ret) == 0 {
368368 panic("no return value specified for Get")
@@ -370,16 +370,16 @@ func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, n
370370
371371 var r0 ports.MergeRequestDetail
372372 var r1 error
373- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) (ports.MergeRequestDetail, error)); ok {
374- return returnFunc(ctx, p, name, number, sinceLastRead)
373+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error)); ok {
374+ return returnFunc(ctx, p, name, number, opts)
375375 }
376- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) ports.MergeRequestDetail); ok {
377- r0 = returnFunc(ctx, p, name, number, sinceLastRead)
376+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.GetMergeRequestOptions) ports.MergeRequestDetail); ok {
377+ r0 = returnFunc(ctx, p, name, number, opts)
378378 } else {
379379 r0 = ret.Get(0).(ports.MergeRequestDetail)
380380 }
381- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) error); ok {
382- r1 = returnFunc(ctx, p, name, number, sinceLastRead)
381+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.GetMergeRequestOptions) error); ok {
382+ r1 = returnFunc(ctx, p, name, number, opts)
383383 } else {
384384 r1 = ret.Error(1)
385385 }
@@ -396,12 +396,12 @@ type MergeRequestService_Get_Call struct {
396396 // - p domain.Principal
397397 // - name domain.RepoName
398398 // - number int64
399-// - sinceLastRead bool
400-func (_e *MergeRequestService_Expecter) Get(ctx any, p any, name any, number any, sinceLastRead any) *MergeRequestService_Get_Call {
401- return &MergeRequestService_Get_Call{Call: _e.mock.On("Get", ctx, p, name, number, sinceLastRead)}
399+// - opts ports.GetMergeRequestOptions
400+func (_e *MergeRequestService_Expecter) Get(ctx any, p any, name any, number any, opts any) *MergeRequestService_Get_Call {
401+ return &MergeRequestService_Get_Call{Call: _e.mock.On("Get", ctx, p, name, number, opts)}
402402 }
403403
404-func (_c *MergeRequestService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool)) *MergeRequestService_Get_Call {
404+func (_c *MergeRequestService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions)) *MergeRequestService_Get_Call {
405405 _c.Call.Run(func(args mock.Arguments) {
406406 var arg0 context.Context
407407 if args[0] != nil {
@@ -419,9 +419,9 @@ func (_c *MergeRequestService_Get_Call) Run(run func(ctx context.Context, p doma
419419 if args[3] != nil {
420420 arg3 = args[3].(int64)
421421 }
422- var arg4 bool
422+ var arg4 ports.GetMergeRequestOptions
423423 if args[4] != nil {
424- arg4 = args[4].(bool)
424+ arg4 = args[4].(ports.GetMergeRequestOptions)
425425 }
426426 run(
427427 arg0,
@@ -439,7 +439,7 @@ func (_c *MergeRequestService_Get_Call) Return(mergeRequestDetail ports.MergeReq
439439 return _c
440440 }
441441
442-func (_c *MergeRequestService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (ports.MergeRequestDetail, error)) *MergeRequestService_Get_Call {
442+func (_c *MergeRequestService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error)) *MergeRequestService_Get_Call {
443443 _c.Call.Return(run)
444444 return _c
445445 }
M internal/ports/services.go +1 -1
@@ -108,7 +108,7 @@ type EventService interface {
108108 type MergeRequestService interface {
109109 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)
110110 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)
111- Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (MergeRequestDetail, error)
111+ Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error)
112112 AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error)
113113 ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error)
114114 Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
M internal/usecase/ci_actions_test.go +1 -1
@@ -75,7 +75,7 @@ func TestPickRemoteRef(t *testing.T) {
7575
7676 // countingRemote answers ListRefs from a function and counts the calls.
7777 type countingRemote struct {
78- calls int
78+ calls int
7979 answer func() ([]ports.RemoteRef, error)
8080 }
8181
M internal/usecase/mergerequests.go +72 -14
@@ -7,6 +7,8 @@ import (
77 "encoding/base64"
88 "errors"
99 "fmt"
10+ "math"
11+ "slices"
1012 "strconv"
1113 "strings"
1214 "sync"
@@ -419,12 +421,24 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain
419421 // Get returns a merge request with its commits, its diff, its comments and the
420422 // read position. The diff compares the merge base of the target and the head
421423 // with the head ("target...head"), within domain.DefaultDiffLimits. With
422-// sinceLastRead it compares the head recorded by MarkRead with the current
423-// head instead, which works after a force-push as long as the recorded commit
424-// still exists; when none was recorded the diff is the default one, and when
425-// the recorded commit is gone too, with LastReadLost set. A merged or closed
426-// request keeps the head and the base it had.
427-func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (ports.MergeRequestDetail, error) {
424+// opts.SinceLastRead it compares the head recorded by MarkRead with the
425+// current head instead, which works after a force-push as long as the recorded
426+// commit still exists; when none was recorded the diff is the default one, and
427+// when the recorded commit is gone too, with LastReadLost set. A merged or
428+// closed request keeps the head and the base it had.
429+//
430+// opts.Files lists every changed file and reads the hunks of a window of them
431+// only; opts.Base and opts.Head make Get fail with domain.ErrConflict when the
432+// diff is no longer the one the caller shows (before any diff is read);
433+// opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid.
434+func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
435+ limits := domain.DefaultDiffLimits()
436+ if w := opts.Files; w != nil {
437+ if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) {
438+ return ports.MergeRequestDetail{}, fmt.Errorf("%w: file window [%d, %d)", domain.ErrInvalid, w.From, w.To)
439+ }
440+ limits.MaxFiles, limits.From, limits.To = 0, w.From, w.To
441+ }
428442 _, mr, err := m.get(ctx, p, name, number)
429443 if err != nil {
430444 return ports.MergeRequestDetail{}, err
@@ -432,6 +446,16 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
432446 if mr, err = m.withHead(ctx, mr); err != nil {
433447 return ports.MergeRequestDetail{}, err
434448 }
449+ if !opts.Head.IsZero() && opts.Head != mr.HeadID {
450+ return ports.MergeRequestDetail{}, fmt.Errorf("%w: the head of !%d moved to %s", domain.ErrConflict, number, mr.HeadID)
451+ }
452+ // checkBase fails when the caller shows another diff base than base.
453+ checkBase := func(base domain.ObjectID) error {
454+ if !opts.Base.IsZero() && opts.Base != base {
455+ return fmt.Errorf("%w: the diff base of !%d is now %s", domain.ErrConflict, number, base)
456+ }
457+ return nil
458+ }
435459 d := ports.MergeRequestDetail{MergeRequest: mr}
436460 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
437461 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)
@@ -446,7 +470,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
446470 }
447471 head := mr.HeadID
448472 if head.IsZero() {
449- return d, nil // the source branch is gone: no commits, no diff
473+ return d, checkBase(domain.ObjectID{}) // the source branch is gone: no commits, no diff
450474 }
451475 if !mr.IsOpen() {
452476 // After a squash the reviewed commits are only reachable from the
@@ -455,13 +479,19 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
455479 case err != nil:
456480 return ports.MergeRequestDetail{}, err
457481 case !ok:
458- return d, nil // the reviewed commits are gone: no commits, no diff
482+ // The reviewed commits are gone: no commits, no diff.
483+ if err := checkBase(domain.ObjectID{}); err != nil {
484+ return ports.MergeRequestDetail{}, err
485+ }
486+ return d, nil
459487 }
460488 }
461- if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil {
462- return ports.MergeRequestDetail{}, err
489+ if !opts.DiffOnly {
490+ if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil {
491+ return ports.MergeRequestDetail{}, err
492+ }
463493 }
464- if sinceLastRead && !d.LastReadID.IsZero() {
494+ if opts.SinceLastRead && !d.LastReadID.IsZero() {
465495 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {
466496 case err != nil:
467497 return ports.MergeRequestDetail{}, err
@@ -471,11 +501,14 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
471501 d.LastReadLost = true
472502 }
473503 }
504+ if err := checkBase(base); err != nil {
505+ return ports.MergeRequestDetail{}, err
506+ }
474507 if base.IsZero() {
475508 return d, nil
476509 }
477510 d.DiffBaseID = base
478- if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, domain.DefaultDiffLimits()); err != nil {
511+ if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, limits); err != nil {
479512 return ports.MergeRequestDetail{}, err
480513 }
481514 return d, nil
@@ -601,16 +634,41 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
601634 v.Add("path", "the merge request has no diff to comment on")
602635 return v.Err()
603636 }
604- _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, domain.DefaultDiffLimits())
637+ // A comment may be on a file past the 300th that the web view lists, so
638+ // the file is looked up among all the files (MaxFiles 0). Reading every
639+ // file's hunks within the default total limits would reject a comment on a
640+ // file the earlier ones left no budget for. So a first read lists the files
641+ // with no hunks (From past the end: cheap, git is stopped at once), and a
642+ // second reads the hunks of the one file within the default per-file limit
643+ // and total limits, as the web view does for a window holding it.
644+ list := domain.DefaultDiffLimits()
645+ list.MaxFiles, list.From = 0, math.MaxInt
646+ _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, list)
605647 if err != nil {
606648 return err
607649 }
608- if !diff.HasLine(params.Path, params.Side, params.Line) {
650+ idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return diffFileName(f) == params.Path })
651+ if idx >= 0 {
652+ one := domain.DefaultDiffLimits()
653+ one.MaxFiles, one.From, one.To = 0, idx, idx+1
654+ if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil {
655+ return err
656+ }
657+ }
658+ if idx < 0 || !diff.HasLine(params.Path, params.Side, params.Line) {
609659 v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side))
610660 }
611661 return v.Err()
612662 }
613663
664+// diffFileName is the path a comment designates for f, as Diff.HasLine reads it.
665+func diffFileName(f domain.FileDiff) string {
666+ if f.Status == domain.FileDeleted {
667+ return f.OldPath
668+ }
669+ return f.NewPath
670+}
671+
614672 // ResolveComment marks a comment resolved or unresolved. The principal needs
615673 // mr:write. An unknown comment gives domain.ErrNotFound and a request that is
616674 // not open domain.ErrPreconditionFailed. Setting the state the comment already
M internal/usecase/mergerequests_branches_test.go +1 -1
@@ -39,7 +39,7 @@ func TestMergeWithBranchLimitedToken(t *testing.T) {
3939 }
4040 assert.Equal(t, main, r.branchHead("main"), "main did not move")
4141 assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays")
42- d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
42+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
4343 require.NoError(t, err)
4444 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
4545 assert.Empty(t, r.drain(), "no event")
M internal/usecase/mergerequests_delete_test.go +3 -3
@@ -42,7 +42,7 @@ func TestMergeDeletesTheSourceBranch(t *testing.T) {
4242 }, evs[1].(domain.PushEvent).Updates)
4343
4444 // The merged request still reads, with its commits and diff.
45- d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
45+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
4646 require.NoError(t, err)
4747 assert.Len(t, d.Commits, 2)
4848 assert.Len(t, d.Diff.Files, 2)
@@ -82,7 +82,7 @@ func TestMergeRefusesToDeleteANeededBranch(t *testing.T) {
8282 assert.Contains(t, err.Error(), "!2 still uses")
8383 assert.Equal(t, main, r.branchHead("main"), "not merged")
8484 assert.Equal(t, head, r.branchHead("feat"))
85- d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
85+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
8686 require.NoError(t, err)
8787 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
8888 })
@@ -138,7 +138,7 @@ func TestSquashMergedRequestReadsAfterItsCommitsArePruned(t *testing.T) {
138138 require.NoError(t, err)
139139 require.False(t, ok, "the reviewed commits are pruned")
140140
141- d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
141+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
142142 require.NoError(t, err)
143143 assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
144144 assert.Equal(t, res.MergedCommitID, d.MergeRequest.MergedCommitID)
A internal/usecase/mergerequests_files_test.go +192 -0
@@ -0,0 +1,192 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// manyFiles commits n one-line files m/f000... in one commit and
19+// returns the commit id.
20+func (r *mrRig) manyFiles(n int) domain.ObjectID {
21+ r.t.Helper()
22+ for i := range n {
23+ full := filepath.Join(r.work, "m", fmt.Sprintf("f%03d", i))
24+ require.NoError(r.t, os.MkdirAll(filepath.Dir(full), 0o755))
25+ require.NoError(r.t, os.WriteFile(full, []byte(fmt.Sprintf("line %d\n", i)), 0o644))
26+ }
27+ r.sh("add", "m")
28+ r.sh("commit", "-q", "-m", "many files")
29+ return r.head()
30+}
31+
32+const manyFilesCount = 320
33+
34+// bigMR opens a request that changes manyFilesCount files.
35+func bigMR(t *testing.T) (*mrRig, domain.MergeRequest) {
36+ t.Helper()
37+ r := newMRRig(t)
38+ r.seed()
39+ r.sh("checkout", "-q", "-B", "feat", "main")
40+ r.manyFiles(manyFilesCount)
41+ r.push("feat")
42+ return r, r.open("feat")
43+}
44+
45+func TestGetMergeRequestFilesWindow(t *testing.T) {
46+ r, mr := bigMR(t)
47+ n := mr.Number
48+
49+ t.Run("the default diff keeps the contract limit", func(t *testing.T) {
50+ d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{})
51+ require.NoError(t, err)
52+ assert.Len(t, d.Diff.Files, 300)
53+ assert.True(t, d.Diff.Truncated)
54+ assert.Equal(t, manyFilesCount, d.Stats.FilesChanged)
55+ })
56+
57+ t.Run("every file is listed, the hunks are those of the window", func(t *testing.T) {
58+ d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 305, To: 310}})
59+ require.NoError(t, err)
60+ require.Len(t, d.Diff.Files, manyFilesCount)
61+ assert.Equal(t, manyFilesCount, d.Stats.FilesChanged)
62+ assert.False(t, d.Diff.Truncated)
63+ for i, f := range d.Diff.Files {
64+ assert.Equal(t, fmt.Sprintf("m/f%03d", i), f.NewPath)
65+ assert.Equal(t, 1, f.Additions)
66+ assert.False(t, f.Truncated)
67+ if i >= 305 && i < 310 {
68+ require.Len(t, f.Hunks, 1, f.NewPath)
69+ assert.Equal(t, fmt.Sprintf("line %d", i), f.Hunks[0].Lines[0].Text)
70+ } else {
71+ assert.Empty(t, f.Hunks, f.NewPath)
72+ }
73+ }
74+ })
75+
76+ t.Run("an empty window past the end lists the files only", func(t *testing.T) {
77+ d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 1000}})
78+ require.NoError(t, err)
79+ require.Len(t, d.Diff.Files, manyFilesCount)
80+ for _, f := range d.Diff.Files {
81+ assert.Empty(t, f.Hunks)
82+ }
83+ })
84+
85+ t.Run("an open window reads through the end", func(t *testing.T) {
86+ d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 315}})
87+ require.NoError(t, err)
88+ require.Len(t, d.Diff.Files, manyFilesCount)
89+ assert.Empty(t, d.Diff.Files[314].Hunks)
90+ for _, f := range d.Diff.Files[315:] {
91+ assert.Len(t, f.Hunks, 1)
92+ }
93+ })
94+
95+ t.Run("an invalid window is refused", func(t *testing.T) {
96+ for _, w := range []ports.FileWindow{{From: -1}, {To: -1}, {From: 5, To: 3}} {
97+ _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &w})
98+ require.ErrorIs(t, err, domain.ErrInvalid, "%+v", w)
99+ }
100+ })
101+
102+ t.Run("a comment on a line of a file past the 300th is accepted", func(t *testing.T) {
103+ c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f315", Line: 1, Side: domain.SideNew, Text: "late file"})
104+ require.NoError(t, err)
105+ assert.Equal(t, "m/f315", c.Path)
106+ _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f319", Line: 1, Side: domain.SideNew, Text: "last file"})
107+ require.NoError(t, err)
108+
109+ _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f315", Line: 2, Side: domain.SideNew, Text: "no such line"})
110+ require.ErrorIs(t, err, domain.ErrInvalid)
111+ _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f999", Line: 1, Side: domain.SideNew, Text: "no such file"})
112+ require.ErrorIs(t, err, domain.ErrInvalid)
113+ })
114+}
115+
116+func TestGetMergeRequestBaseAndHeadChecks(t *testing.T) {
117+ r := newMRRig(t)
118+ base := r.seed()
119+ head1 := r.feature("feat")
120+ mr := r.open("feat")
121+ n := mr.Number
122+ other := oidFrom(t, "1111111111111111111111111111111111111111")
123+
124+ t.Run("matching or unset base and head", func(t *testing.T) {
125+ for _, o := range []ports.GetMergeRequestOptions{
126+ {}, {Base: base}, {Head: head1}, {Base: base, Head: head1}, {Base: base, Head: head1, Files: &ports.FileWindow{To: 1}},
127+ } {
128+ d, err := r.mrs.Get(r.ctx, reader, r.repo, n, o)
129+ require.NoError(t, err, "%+v", o)
130+ assert.Equal(t, base, d.DiffBaseID)
131+ }
132+ })
133+
134+ t.Run("another base or head is a conflict", func(t *testing.T) {
135+ for _, o := range []ports.GetMergeRequestOptions{
136+ {Base: other}, {Head: other}, {Base: base, Head: other}, {Base: other, Head: head1},
137+ {Base: head1}, // the head is not the base
138+ {Base: other, Files: &ports.FileWindow{From: 1, To: 2}},
139+ } {
140+ _, err := r.mrs.Get(r.ctx, reader, r.repo, n, o)
141+ require.ErrorIs(t, err, domain.ErrConflict, "%+v", o)
142+ }
143+ })
144+
145+ t.Run("the head moved", func(t *testing.T) {
146+ r.commit(author, "d.txt", "dee\n", "add d")
147+ head2 := r.head()
148+ r.push("feat")
149+ _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Base: base, Head: head1})
150+ require.ErrorIs(t, err, domain.ErrConflict)
151+ d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Base: base, Head: head2})
152+ require.NoError(t, err)
153+ assert.Equal(t, head2, d.MergeRequest.HeadID)
154+ })
155+
156+ t.Run("the base is the one after since-last-read", func(t *testing.T) {
157+ head2 := r.branchHead("feat")
158+ _, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, head1)
159+ require.NoError(t, err)
160+ // The whole diff (base) is no longer what since-last-read shows (head1).
161+ _, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true, Base: base, Head: head2})
162+ require.ErrorIs(t, err, domain.ErrConflict)
163+ d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true, Base: head1, Head: head2})
164+ require.NoError(t, err)
165+ assert.Equal(t, head1, d.DiffBaseID)
166+ require.Len(t, d.Diff.Files, 1)
167+ assert.Equal(t, "d.txt", d.Diff.Files[0].NewPath)
168+ // Without since-last-read the base is still the merge base.
169+ _, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Base: head1, Head: head2})
170+ require.ErrorIs(t, err, domain.ErrConflict)
171+ })
172+}
173+
174+func TestGetMergeRequestDiffOnly(t *testing.T) {
175+ r := newMRRig(t)
176+ base := r.seed()
177+ r.feature("feat")
178+ mr := r.open("feat")
179+
180+ full, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
181+ require.NoError(t, err)
182+ require.Len(t, full.Commits, 2)
183+
184+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{DiffOnly: true})
185+ require.NoError(t, err)
186+ assert.Empty(t, d.Commits)
187+ assert.False(t, d.CommitsTruncated)
188+ assert.Equal(t, base, d.DiffBaseID)
189+ assert.Equal(t, full.Diff, d.Diff)
190+ assert.Equal(t, full.Stats, d.Stats)
191+ assert.Equal(t, full.Comments, d.Comments)
192+}
M internal/usecase/mergerequests_integration_test.go +22 -22
@@ -345,7 +345,7 @@ func TestMergeRequestAccess(t *testing.T) {
345345 return err
346346 },
347347 "Get": func(p domain.Principal) error {
348- _, err := r.mrs.Get(r.ctx, p, r.repo, n, false)
348+ _, err := r.mrs.Get(r.ctx, p, r.repo, n, ports.GetMergeRequestOptions{})
349349 return err
350350 },
351351 }
@@ -356,7 +356,7 @@ func TestMergeRequestAccess(t *testing.T) {
356356 assert.NoError(t, call(reader), what+" with repo:read")
357357 assert.NoError(t, call(root), what+" by root")
358358 }
359- _, err := r.mrs.Get(r.ctx, reader, r.repo, n+1, false)
359+ _, err := r.mrs.Get(r.ctx, reader, r.repo, n+1, ports.GetMergeRequestOptions{})
360360 assert.ErrorIs(t, err, domain.ErrNotFound, "unknown number")
361361 _, err = r.mrs.List(r.ctx, root, name(t, "ghost"), ports.ListMergeRequestsParams{})
362362 assert.ErrorIs(t, err, domain.ErrNotFound, "unknown repository")
@@ -505,7 +505,7 @@ func TestGetMergeRequest(t *testing.T) {
505505 _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: "second"})
506506 require.NoError(t, err)
507507
508- d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
508+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
509509 require.NoError(t, err)
510510 assert.Equal(t, head, d.MergeRequest.HeadID)
511511 assert.Equal(t, main, d.DiffBaseID, "the base is the merge base of the target and the head")
@@ -523,14 +523,14 @@ func TestGetMergeRequest(t *testing.T) {
523523 r.sh("checkout", "-q", "main")
524524 r.commit(author, "later.txt", "x\n", "later on main")
525525 r.push("main")
526- d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
526+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
527527 require.NoError(t, err)
528528 assert.Equal(t, main, d.DiffBaseID)
529529 assert.Len(t, d.Commits, 2, "the commits that main lacks")
530530
531531 // A deleted source branch: no commits, no diff, an empty head.
532532 r.run(r.bare(), r.env(author), "branch", "-D", "feat")
533- d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
533+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
534534 require.NoError(t, err)
535535 assert.True(t, d.MergeRequest.HeadID.IsZero())
536536 assert.Empty(t, d.Commits)
@@ -548,7 +548,7 @@ func TestGetMergeRequestTruncatesCommits(t *testing.T) {
548548 }
549549 r.push("long")
550550 mr := r.open("long")
551- d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false)
551+ d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
552552 require.NoError(t, err)
553553 assert.Len(t, d.Commits, usecase.MaxMergeRequestCommits)
554554 assert.True(t, d.CommitsTruncated)
@@ -610,7 +610,7 @@ func TestAddComment(t *testing.T) {
610610 }
611611
612612 // Ids count 1, 2, ... in order of creation and only the valid ones are kept.
613- d, err := r.mrs.Get(r.ctx, root, r.repo, n, false)
613+ d, err := r.mrs.Get(r.ctx, root, r.repo, n, ports.GetMergeRequestOptions{})
614614 require.NoError(t, err)
615615 var kept int
616616 for _, tc := range tests {
@@ -657,7 +657,7 @@ func TestConcurrentComments(t *testing.T) {
657657 seen[id] = true
658658 }
659659 assert.Len(t, seen, workers)
660- d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false)
660+ d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
661661 require.NoError(t, err)
662662 assert.Len(t, d.Comments, workers)
663663 }
@@ -752,7 +752,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) {
752752 n := mr.Number
753753
754754 // Nothing recorded: the flag changes nothing.
755- d, err := r.mrs.Get(r.ctx, mrW, r.repo, n, true)
755+ d, err := r.mrs.Get(r.ctx, mrW, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true})
756756 require.NoError(t, err)
757757 assert.True(t, d.LastReadID.IsZero())
758758 assert.False(t, d.LastReadLost)
@@ -762,7 +762,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) {
762762 got, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, domain.ObjectID{})
763763 require.NoError(t, err)
764764 assert.Equal(t, head1, got)
765- d, err = r.mrs.Get(r.ctx, reader, r.repo, n, false)
765+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{})
766766 require.NoError(t, err)
767767 assert.Equal(t, head1, d.LastReadID, "reported whether or not since_last_read is set")
768768 assert.Equal(t, base, d.DiffBaseID)
@@ -771,7 +771,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) {
771771 r.commit(author, "d.txt", "dee\n", "add d")
772772 head2 := r.head()
773773 r.push("feat")
774- d, err = r.mrs.Get(r.ctx, reader, r.repo, n, true)
774+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true})
775775 require.NoError(t, err)
776776 assert.Equal(t, head1, d.DiffBaseID)
777777 assert.Equal(t, head2, d.MergeRequest.HeadID)
@@ -791,7 +791,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) {
791791 // An explicit head is recorded as given.
792792 _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, base)
793793 require.NoError(t, err)
794- d, err = r.mrs.Get(r.ctx, reader, r.repo, n, true)
794+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true})
795795 require.NoError(t, err)
796796 assert.Equal(t, base, d.DiffBaseID)
797797 }
@@ -819,7 +819,7 @@ func TestSinceLastReadAfterForcePush(t *testing.T) {
819819 // head1 is not an ancestor of head2 any more.
820820 r.run(r.bare(), r.env(author), "cat-file", "-e", head1.String())
821821
822- d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, true)
822+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{SinceLastRead: true})
823823 require.NoError(t, err)
824824 assert.False(t, d.LastReadLost)
825825 assert.Equal(t, head1, d.DiffBaseID, "the diff starts at the head that was read")
@@ -838,14 +838,14 @@ func TestSinceLastReadAfterForcePush(t *testing.T) {
838838 assert.Equal(t, []string{"see"}, removed)
839839
840840 // The whole change is still available without the flag.
841- d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
841+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
842842 require.NoError(t, err)
843843 assert.Equal(t, base, d.DiffBaseID)
844844 assert.Len(t, d.Diff.Files, 2)
845845
846846 // Once the recorded commit is pruned, the response says so.
847847 r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now")
848- d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, true)
848+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{SinceLastRead: true})
849849 require.NoError(t, err)
850850 assert.True(t, d.LastReadLost, "the recorded head is gone")
851851 assert.Equal(t, head1, d.LastReadID, "still reported")
@@ -871,7 +871,7 @@ func TestMergeFastForward(t *testing.T) {
871871 _ = main
872872
873873 // The stored request is the merged one, and reads keep a diff.
874- d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
874+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
875875 require.NoError(t, err)
876876 assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
877877 assert.Equal(t, head, d.MergeRequest.HeadID)
@@ -940,7 +940,7 @@ func TestMergeRefusals(t *testing.T) {
940940 assert.Contains(t, err.Error(), "branch moved, read again")
941941 assert.NotEqual(t, head, r.branchHead("main"), "main did not move")
942942 // Reading again gives the new head, which merges.
943- d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false)
943+ d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
944944 require.NoError(t, err)
945945 _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: d.MergeRequest.HeadID})
946946 require.NoError(t, err)
@@ -962,7 +962,7 @@ func TestMergeRefusals(t *testing.T) {
962962 assert.Contains(t, err.Error(), "rebase", mode)
963963 }
964964 assert.Equal(t, tip, r.branchHead("main"))
965- d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false)
965+ d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
966966 require.NoError(t, err)
967967 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
968968
@@ -1081,14 +1081,14 @@ func TestConcurrentMergesSerialize(t *testing.T) {
10811081 require.Len(t, won, 1, "exactly one merge request was merged: %v", won)
10821082 for number, times := range won {
10831083 assert.Equal(t, 1, times, "!%d merged twice", number)
1084- mr, err := r.mrs.Get(r.ctx, root, r.repo, number, false)
1084+ mr, err := r.mrs.Get(r.ctx, root, r.repo, number, ports.GetMergeRequestOptions{})
10851085 require.NoError(t, err)
10861086 assert.Equal(t, domain.MRStateMerged, mr.MergeRequest.State)
10871087 assert.Equal(t, mr.MergeRequest.MergedCommitID, r.branchHead("main"))
10881088 }
10891089 for number := range heads {
10901090 if won[number] == 0 {
1091- d, err := r.mrs.Get(r.ctx, root, r.repo, number, false)
1091+ d, err := r.mrs.Get(r.ctx, root, r.repo, number, ports.GetMergeRequestOptions{})
10921092 require.NoError(t, err)
10931093 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State, "!%d lost and stays open", number)
10941094 }
@@ -1275,7 +1275,7 @@ func TestAgentPresetWorksOnItsPrivateRepository(t *testing.T) {
12751275 // repo:write alone does not read merge requests.
12761276 _, err = r.mrs.List(r.ctx, agent, r.repo, ports.ListMergeRequestsParams{})
12771277 assert.ErrorIs(t, err, domain.ErrNotFound)
1278- _, err = r.mrs.Get(r.ctx, agent, r.repo, mr.Number, false)
1278+ _, err = r.mrs.Get(r.ctx, agent, r.repo, mr.Number, ports.GetMergeRequestOptions{})
12791279 assert.ErrorIs(t, err, domain.ErrNotFound)
12801280
12811281 closed, err := r.mrs.Close(r.ctx, agent, r.repo, mr.Number)
@@ -1328,7 +1328,7 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) {
13281328 }
13291329 _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "one too many"})
13301330 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
1331- d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false)
1331+ d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
13321332 require.NoError(t, err)
13331333 assert.Len(t, d.Comments, usecase.MaxCommentsPerMergeRequest)
13341334

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 44

↑↓ moveenter openesc close