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
@@ -769,147 +769,231 @@ func FileList(files []DiffFile) templ.Component {
769769 templ_7745c5c3_Var35 = templ.NopComponent
770770 }
771771 ctx = templ.ClearChildren(ctx)
772- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<ol class=\"files\" aria-label=\"Changed files\">")
772+ if len(files) > fileFilterMin {
773+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<div class=\"ffilter\"><label for=\"files-filter\" class=\"sr\">filter the changed files</label> <input id=\"files-filter\" type=\"search\" placeholder=\"Filter files…\" autocomplete=\"off\" data-pick=\".files > li\" hx-on:input=\"ermite.filter(this)\"></div>")
774+ if templ_7745c5c3_Err != nil {
775+ return templ_7745c5c3_Err
776+ }
777+ }
778+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<ol class=\"files\" aria-label=\"Changed files\">")
773779 if templ_7745c5c3_Err != nil {
774780 return templ_7745c5c3_Err
775781 }
776782 for _, f := range files {
777- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<li><a href=\"")
783+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<li><a href=\"")
778784 if templ_7745c5c3_Err != nil {
779785 return templ_7745c5c3_Err
780786 }
781787 var templ_7745c5c3_Var36 templ.SafeURL
782788 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + f.ID))
783789 if templ_7745c5c3_Err != nil {
784- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 148, Col: 37}
790+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 161, Col: 37}
785791 }
786792 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
787793 if templ_7745c5c3_Err != nil {
788794 return templ_7745c5c3_Err
789795 }
790- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" hx-on:click=\"document.getElementById(this.hash.slice(1)).open = true\"><span class=\"st\" title=\"")
796+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" title=\"")
791797 if templ_7745c5c3_Err != nil {
792798 return templ_7745c5c3_Err
793799 }
794800 var templ_7745c5c3_Var37 string
795- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Status)
801+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
796802 if templ_7745c5c3_Err != nil {
797- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 151, Col: 38}
803+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 162, Col: 19}
798804 }
799805 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
800806 if templ_7745c5c3_Err != nil {
801807 return templ_7745c5c3_Err
802808 }
803- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\">")
809+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" hx-on:click=\"(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))\"><span class=\"st\" data-st=\"")
804810 if templ_7745c5c3_Err != nil {
805811 return templ_7745c5c3_Err
806812 }
807813 var templ_7745c5c3_Var38 string
808- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status)
814+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Status)
809815 if templ_7745c5c3_Err != nil {
810- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 151, Col: 51}
816+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 165, Col: 40}
811817 }
812- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
818+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
813819 if templ_7745c5c3_Err != nil {
814820 return templ_7745c5c3_Err
815821 }
816- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span> <span class=\"p\" title=\"")
822+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" title=\"")
817823 if templ_7745c5c3_Err != nil {
818824 return templ_7745c5c3_Err
819825 }
820826 var templ_7745c5c3_Var39 string
821- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
827+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(statusName(f.Status))
822828 if templ_7745c5c3_Err != nil {
823- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 152, Col: 35}
829+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 165, Col: 71}
824830 }
825831 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
826832 if templ_7745c5c3_Err != nil {
827833 return templ_7745c5c3_Err
828834 }
829- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\">")
835+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\">")
830836 if templ_7745c5c3_Err != nil {
831837 return templ_7745c5c3_Err
832838 }
833839 var templ_7745c5c3_Var40 string
834- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path)
840+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status)
835841 if templ_7745c5c3_Err != nil {
836- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 152, Col: 46}
842+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 165, Col: 84}
837843 }
838844 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
839845 if templ_7745c5c3_Err != nil {
840846 return templ_7745c5c3_Err
841847 }
842- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span> <span class=\"n\"><span class=\"plus\">+")
848+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> <span class=\"nm\"><span class=\"p\">")
843849 if templ_7745c5c3_Err != nil {
844850 return templ_7745c5c3_Err
845851 }
846852 var templ_7745c5c3_Var41 string
847- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add))
853+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(f.Name)
848854 if templ_7745c5c3_Err != nil {
849- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 153, Col: 54}
855+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 166, Col: 46}
850856 }
851857 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
852858 if templ_7745c5c3_Err != nil {
853859 return templ_7745c5c3_Err
854860 }
855- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> <span class=\"minus\">-")
861+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span>")
856862 if templ_7745c5c3_Err != nil {
857863 return templ_7745c5c3_Err
858864 }
859865 var templ_7745c5c3_Var42 string
860- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del))
866+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
861867 if templ_7745c5c3_Err != nil {
862- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 153, Col: 98}
868+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 166, Col: 60}
863869 }
864870 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
865871 if templ_7745c5c3_Err != nil {
866872 return templ_7745c5c3_Err
867873 }
868- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span></span> <span class=\"b\" aria-hidden=\"true\"><span class=\"plus\">")
874+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<span class=\"d\"><bdi>")
869875 if templ_7745c5c3_Err != nil {
870876 return templ_7745c5c3_Err
871877 }
872878 var templ_7745c5c3_Var43 string
873- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(f.Bar.Add)
879+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(f.Dir)
874880 if templ_7745c5c3_Err != nil {
875- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 154, Col: 70}
881+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 166, Col: 90}
876882 }
877883 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
878884 if templ_7745c5c3_Err != nil {
879885 return templ_7745c5c3_Err
880886 }
881- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span><span class=\"minus\">")
887+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</bdi></span></span> ")
882888 if templ_7745c5c3_Err != nil {
883889 return templ_7745c5c3_Err
884890 }
885- var templ_7745c5c3_Var44 string
886- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(f.Bar.Del)
887- if templ_7745c5c3_Err != nil {
888- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 154, Col: 110}
889- }
890- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
891- if templ_7745c5c3_Err != nil {
892- return templ_7745c5c3_Err
893- }
894- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span>")
895- if templ_7745c5c3_Err != nil {
896- return templ_7745c5c3_Err
897- }
898- var templ_7745c5c3_Var45 string
899- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(f.Bar.Rest)
900- if templ_7745c5c3_Err != nil {
901- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 154, Col: 131}
891+ if f.OpenNotes > 0 {
892+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"cn\" title=\"")
893+ if templ_7745c5c3_Err != nil {
894+ return templ_7745c5c3_Err
895+ }
896+ var templ_7745c5c3_Var44 string
897+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(plural(f.OpenNotes, "open comment", "open comments"))
898+ if templ_7745c5c3_Err != nil {
899+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 168, Col: 83}
900+ }
901+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
902+ if templ_7745c5c3_Err != nil {
903+ return templ_7745c5c3_Err
904+ }
905+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\">")
906+ if templ_7745c5c3_Err != nil {
907+ return templ_7745c5c3_Err
908+ }
909+ var templ_7745c5c3_Var45 string
910+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.OpenNotes))
911+ if templ_7745c5c3_Err != nil {
912+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 168, Col: 105}
913+ }
914+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
915+ if templ_7745c5c3_Err != nil {
916+ return templ_7745c5c3_Err
917+ }
918+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span> ")
919+ if templ_7745c5c3_Err != nil {
920+ return templ_7745c5c3_Err
921+ }
922+ } else if f.Notes > 0 {
923+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span class=\"cn done\" title=\"")
924+ if templ_7745c5c3_Err != nil {
925+ return templ_7745c5c3_Err
926+ }
927+ var templ_7745c5c3_Var46 string
928+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(plural(f.Notes, "resolved comment", "resolved comments"))
929+ if templ_7745c5c3_Err != nil {
930+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 170, Col: 92}
931+ }
932+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
933+ if templ_7745c5c3_Err != nil {
934+ return templ_7745c5c3_Err
935+ }
936+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\">")
937+ if templ_7745c5c3_Err != nil {
938+ return templ_7745c5c3_Err
939+ }
940+ var templ_7745c5c3_Var47 string
941+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Notes))
942+ if templ_7745c5c3_Err != nil {
943+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 170, Col: 110}
944+ }
945+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
946+ if templ_7745c5c3_Err != nil {
947+ return templ_7745c5c3_Err
948+ }
949+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span> ")
950+ if templ_7745c5c3_Err != nil {
951+ return templ_7745c5c3_Err
952+ }
902953 }
903- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
904- if templ_7745c5c3_Err != nil {
905- return templ_7745c5c3_Err
954+ if f.Bin {
955+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<span class=\"n mute\">bin</span>")
956+ if templ_7745c5c3_Err != nil {
957+ return templ_7745c5c3_Err
958+ }
959+ } else {
960+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span class=\"n\"><span class=\"plus\">+")
961+ if templ_7745c5c3_Err != nil {
962+ return templ_7745c5c3_Err
963+ }
964+ var templ_7745c5c3_Var48 string
965+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add))
966+ if templ_7745c5c3_Err != nil {
967+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 55}
968+ }
969+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
970+ if templ_7745c5c3_Err != nil {
971+ return templ_7745c5c3_Err
972+ }
973+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span> <span class=\"minus\">-")
974+ if templ_7745c5c3_Err != nil {
975+ return templ_7745c5c3_Err
976+ }
977+ var templ_7745c5c3_Var49 string
978+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del))
979+ if templ_7745c5c3_Err != nil {
980+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 99}
981+ }
982+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
983+ if templ_7745c5c3_Err != nil {
984+ return templ_7745c5c3_Err
985+ }
986+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span></span>")
987+ if templ_7745c5c3_Err != nil {
988+ return templ_7745c5c3_Err
989+ }
906990 }
907- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span></a></li>")
991+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</a></li>")
908992 if templ_7745c5c3_Err != nil {
909993 return templ_7745c5c3_Err
910994 }
911995 }
912- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</ol>")
996+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</ol>")
913997 if templ_7745c5c3_Err != nil {
914998 return templ_7745c5c3_Err
915999 }
@@ -937,222 +1021,306 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
9371021 }()
9381022 }
9391023 ctx = templ.InitializeContext(ctx)
940- templ_7745c5c3_Var46 := templ.GetChildren(ctx)
941- if templ_7745c5c3_Var46 == nil {
942- templ_7745c5c3_Var46 = templ.NopComponent
1024+ templ_7745c5c3_Var50 := templ.GetChildren(ctx)
1025+ if templ_7745c5c3_Var50 == nil {
1026+ templ_7745c5c3_Var50 = templ.NopComponent
9431027 }
9441028 ctx = templ.ClearChildren(ctx)
945- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<details class=\"dfile\" id=\"")
1029+ var templ_7745c5c3_Var51 = []any{"dfile", "lnw-" + itoa(f.Digits)}
1030+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var51...)
1031+ if templ_7745c5c3_Err != nil {
1032+ return templ_7745c5c3_Err
1033+ }
1034+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<details class=\"")
1035+ if templ_7745c5c3_Err != nil {
1036+ return templ_7745c5c3_Err
1037+ }
1038+ var templ_7745c5c3_Var52 string
1039+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var51).String())
1040+ if templ_7745c5c3_Err != nil {
1041+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
1042+ }
1043+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
1044+ if templ_7745c5c3_Err != nil {
1045+ return templ_7745c5c3_Err
1046+ }
1047+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" id=\"")
9461048 if templ_7745c5c3_Err != nil {
9471049 return templ_7745c5c3_Err
9481050 }
949- var templ_7745c5c3_Var47 string
950- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID)
1051+ var templ_7745c5c3_Var53 string
1052+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID)
9511053 if templ_7745c5c3_Err != nil {
952- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 168, Col: 11}
1054+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 190, Col: 11}
9531055 }
954- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
1056+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
9551057 if templ_7745c5c3_Err != nil {
9561058 return templ_7745c5c3_Err
9571059 }
958- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" data-file")
1060+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" data-file")
9591061 if templ_7745c5c3_Err != nil {
9601062 return templ_7745c5c3_Err
9611063 }
9621064 if f.Open {
963- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " open")
1065+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " open")
9641066 if templ_7745c5c3_Err != nil {
9651067 return templ_7745c5c3_Err
9661068 }
9671069 }
968- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "><summary><span class=\"st\">")
1070+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "><summary><span class=\"st\">")
9691071 if templ_7745c5c3_Err != nil {
9701072 return templ_7745c5c3_Err
9711073 }
972- var templ_7745c5c3_Var48 string
973- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status)
1074+ var templ_7745c5c3_Var54 string
1075+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status)
9741076 if templ_7745c5c3_Err != nil {
975- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 30}
1077+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 30}
9761078 }
977- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
1079+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
9781080 if templ_7745c5c3_Err != nil {
9791081 return templ_7745c5c3_Err
9801082 }
981- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span>")
1083+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span>")
9821084 if templ_7745c5c3_Err != nil {
9831085 return templ_7745c5c3_Err
9841086 }
985- var templ_7745c5c3_Var49 string
986- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1087+ var templ_7745c5c3_Var55 string
1088+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
9871089 if templ_7745c5c3_Err != nil {
988- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 44}
1090+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 44}
9891091 }
990- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
1092+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
9911093 if templ_7745c5c3_Err != nil {
9921094 return templ_7745c5c3_Err
9931095 }
994- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span class=\"p\">")
1096+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<span class=\"p\">")
9951097 if templ_7745c5c3_Err != nil {
9961098 return templ_7745c5c3_Err
9971099 }
998- var templ_7745c5c3_Var50 string
999- templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path)
1100+ var templ_7745c5c3_Var56 string
1101+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path)
10001102 if templ_7745c5c3_Err != nil {
1001- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 70}
1103+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 70}
10021104 }
1003- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
1105+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
10041106 if templ_7745c5c3_Err != nil {
10051107 return templ_7745c5c3_Err
10061108 }
1007- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span>")
1109+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</span>")
10081110 if templ_7745c5c3_Err != nil {
10091111 return templ_7745c5c3_Err
10101112 }
1011- var templ_7745c5c3_Var51 string
1012- templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1113+ var templ_7745c5c3_Var57 string
1114+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
10131115 if templ_7745c5c3_Err != nil {
1014- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 84}
1116+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 84}
10151117 }
1016- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
1118+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
10171119 if templ_7745c5c3_Err != nil {
10181120 return templ_7745c5c3_Err
10191121 }
1020- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<span class=\"plus\">+")
1122+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<span class=\"plus\">+")
10211123 if templ_7745c5c3_Err != nil {
10221124 return templ_7745c5c3_Err
10231125 }
1024- var templ_7745c5c3_Var52 string
1025- templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add))
1126+ var templ_7745c5c3_Var58 string
1127+ templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add))
10261128 if templ_7745c5c3_Err != nil {
1027- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 119}
1129+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 119}
10281130 }
1029- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
1131+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
10301132 if templ_7745c5c3_Err != nil {
10311133 return templ_7745c5c3_Err
10321134 }
1033- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</span>")
1135+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</span>")
10341136 if templ_7745c5c3_Err != nil {
10351137 return templ_7745c5c3_Err
10361138 }
1037- var templ_7745c5c3_Var53 string
1038- templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1139+ var templ_7745c5c3_Var59 string
1140+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
10391141 if templ_7745c5c3_Err != nil {
1040- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 133}
1142+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 133}
10411143 }
1042- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
1144+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
10431145 if templ_7745c5c3_Err != nil {
10441146 return templ_7745c5c3_Err
10451147 }
1046- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<span class=\"minus\">-")
1148+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<span class=\"minus\">-")
10471149 if templ_7745c5c3_Err != nil {
10481150 return templ_7745c5c3_Err
10491151 }
1050- var templ_7745c5c3_Var54 string
1051- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del))
1152+ var templ_7745c5c3_Var60 string
1153+ templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del))
10521154 if templ_7745c5c3_Err != nil {
1053- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 169}
1155+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 169}
10541156 }
1055- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
1157+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
10561158 if templ_7745c5c3_Err != nil {
10571159 return templ_7745c5c3_Err
10581160 }
1059- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</span> ")
1161+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</span> ")
10601162 if templ_7745c5c3_Err != nil {
10611163 return templ_7745c5c3_Err
10621164 }
10631165 if f.Note != "" {
1064- var templ_7745c5c3_Var55 string
1065- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1166+ var templ_7745c5c3_Var61 string
1167+ templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
10661168 if templ_7745c5c3_Err != nil {
1067- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 177, Col: 9}
1169+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 199, Col: 9}
10681170 }
1069- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1171+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
10701172 if templ_7745c5c3_Err != nil {
10711173 return templ_7745c5c3_Err
10721174 }
1073- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<span class=\"note\">")
1175+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "<span class=\"note\">")
10741176 if templ_7745c5c3_Err != nil {
10751177 return templ_7745c5c3_Err
10761178 }
1077- var templ_7745c5c3_Var56 string
1078- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1179+ var templ_7745c5c3_Var62 string
1180+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
10791181 if templ_7745c5c3_Err != nil {
1080- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 177, Col: 38}
1182+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 199, Col: 38}
10811183 }
1082- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1184+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
10831185 if templ_7745c5c3_Err != nil {
10841186 return templ_7745c5c3_Err
10851187 }
1086- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</span>")
1188+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</span>")
10871189 if templ_7745c5c3_Err != nil {
10881190 return templ_7745c5c3_Err
10891191 }
10901192 }
1091- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</summary> ")
1193+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</summary> ")
10921194 if templ_7745c5c3_Err != nil {
10931195 return templ_7745c5c3_Err
10941196 }
1095- if len(f.Hunks) > 0 {
1096- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<div class=\"dscroll\">")
1197+ if f.Lazy {
1198+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, " ")
1199+ if templ_7745c5c3_Err != nil {
1200+ return templ_7745c5c3_Err
1201+ }
1202+ var templ_7745c5c3_Var63 = []any{"dlazy", "dlazy-" + itoa(f.Height)}
1203+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var63...)
1204+ if templ_7745c5c3_Err != nil {
1205+ return templ_7745c5c3_Err
1206+ }
1207+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "<div class=\"")
1208+ if templ_7745c5c3_Err != nil {
1209+ return templ_7745c5c3_Err
1210+ }
1211+ var templ_7745c5c3_Var64 string
1212+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var63).String())
1213+ if templ_7745c5c3_Err != nil {
1214+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
1215+ }
1216+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
1217+ if templ_7745c5c3_Err != nil {
1218+ return templ_7745c5c3_Err
1219+ }
1220+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\" data-src=\"")
1221+ if templ_7745c5c3_Err != nil {
1222+ return templ_7745c5c3_Err
1223+ }
1224+ var templ_7745c5c3_Var65 string
1225+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Src)
1226+ if templ_7745c5c3_Err != nil {
1227+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 206, Col: 69}
1228+ }
1229+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)
1230+ if templ_7745c5c3_Err != nil {
1231+ return templ_7745c5c3_Err
1232+ }
1233+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\" data-notes=\"")
1234+ if templ_7745c5c3_Err != nil {
1235+ return templ_7745c5c3_Err
1236+ }
1237+ var templ_7745c5c3_Var66 string
1238+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Comments)
1239+ if templ_7745c5c3_Err != nil {
1240+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 206, Col: 95}
1241+ }
1242+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
1243+ if templ_7745c5c3_Err != nil {
1244+ return templ_7745c5c3_Err
1245+ }
1246+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\"><a href=\"")
1247+ if templ_7745c5c3_Err != nil {
1248+ return templ_7745c5c3_Err
1249+ }
1250+ var templ_7745c5c3_Var67 templ.SafeURL
1251+ templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID))
1252+ if templ_7745c5c3_Err != nil {
1253+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 207, Col: 65}
1254+ }
1255+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
1256+ if templ_7745c5c3_Err != nil {
1257+ return templ_7745c5c3_Err
1258+ }
1259+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\">show this file</a></div>")
1260+ if templ_7745c5c3_Err != nil {
1261+ return templ_7745c5c3_Err
1262+ }
1263+ } else if len(f.Hunks) > 0 {
1264+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<div class=\"dscroll\">")
10971265 if templ_7745c5c3_Err != nil {
10981266 return templ_7745c5c3_Err
10991267 }
11001268 if p.ShowUnified {
1101- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<table class=\"dt uni\" aria-label=\"")
1269+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<table class=\"dt uni\" aria-label=\"")
11021270 if templ_7745c5c3_Err != nil {
11031271 return templ_7745c5c3_Err
11041272 }
1105- var templ_7745c5c3_Var57 string
1106- templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
1273+ var templ_7745c5c3_Var68 string
1274+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
11071275 if templ_7745c5c3_Err != nil {
1108- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 183, Col: 60}
1276+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 212, Col: 60}
11091277 }
1110- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
1278+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
11111279 if templ_7745c5c3_Err != nil {
11121280 return templ_7745c5c3_Err
11131281 }
1114- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\">")
1282+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\">")
11151283 if templ_7745c5c3_Err != nil {
11161284 return templ_7745c5c3_Err
11171285 }
11181286 for _, h := range f.Hunks {
1119- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<tbody><tr class=\"hunk\"><td colspan=\"4\">")
1287+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<tbody><tr class=\"hunk\"><td colspan=\"4\">")
11201288 if templ_7745c5c3_Err != nil {
11211289 return templ_7745c5c3_Err
11221290 }
1123- var templ_7745c5c3_Var58 string
1124- templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1291+ var templ_7745c5c3_Var69 string
1292+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
11251293 if templ_7745c5c3_Err != nil {
1126- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 186, Col: 51}
1294+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 215, Col: 51}
11271295 }
1128- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
1296+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
11291297 if templ_7745c5c3_Err != nil {
11301298 return templ_7745c5c3_Err
11311299 }
1132- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</td></tr>")
1300+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</td></tr>")
11331301 if templ_7745c5c3_Err != nil {
11341302 return templ_7745c5c3_Err
11351303 }
11361304 for _, ln := range h.Lines {
1137- var templ_7745c5c3_Var59 = []any{ln.Kind}
1138- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var59...)
1305+ var templ_7745c5c3_Var70 = []any{ln.Kind}
1306+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var70...)
11391307 if templ_7745c5c3_Err != nil {
11401308 return templ_7745c5c3_Err
11411309 }
1142- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<tr class=\"")
1310+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<tr class=\"")
11431311 if templ_7745c5c3_Err != nil {
11441312 return templ_7745c5c3_Err
11451313 }
1146- var templ_7745c5c3_Var60 string
1147- templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var59).String())
1314+ var templ_7745c5c3_Var71 string
1315+ templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var70).String())
11481316 if templ_7745c5c3_Err != nil {
11491317 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
11501318 }
1151- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
1319+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
11521320 if templ_7745c5c3_Err != nil {
11531321 return templ_7745c5c3_Err
11541322 }
1155- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\"><td class=\"ln\">")
1323+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "\"><td class=\"ln\">")
11561324 if templ_7745c5c3_Err != nil {
11571325 return templ_7745c5c3_Err
11581326 }
@@ -1160,7 +1328,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
11601328 if templ_7745c5c3_Err != nil {
11611329 return templ_7745c5c3_Err
11621330 }
1163- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</td><td class=\"ln\">")
1331+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</td><td class=\"ln\">")
11641332 if templ_7745c5c3_Err != nil {
11651333 return templ_7745c5c3_Err
11661334 }
@@ -1168,20 +1336,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
11681336 if templ_7745c5c3_Err != nil {
11691337 return templ_7745c5c3_Err
11701338 }
1171- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</td><td class=\"m\">")
1339+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</td><td class=\"m\">")
11721340 if templ_7745c5c3_Err != nil {
11731341 return templ_7745c5c3_Err
11741342 }
1175- var templ_7745c5c3_Var61 string
1176- templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1343+ var templ_7745c5c3_Var72 string
1344+ templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
11771345 if templ_7745c5c3_Err != nil {
1178- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 191, Col: 33}
1346+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 220, Col: 33}
11791347 }
1180- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
1348+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
11811349 if templ_7745c5c3_Err != nil {
11821350 return templ_7745c5c3_Err
11831351 }
1184- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</td><td class=\"tx\">")
1352+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</td><td class=\"tx\">")
11851353 if templ_7745c5c3_Err != nil {
11861354 return templ_7745c5c3_Err
11871355 }
@@ -1189,12 +1357,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
11891357 if templ_7745c5c3_Err != nil {
11901358 return templ_7745c5c3_Err
11911359 }
1192- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</td></tr>")
1360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</td></tr>")
11931361 if templ_7745c5c3_Err != nil {
11941362 return templ_7745c5c3_Err
11951363 }
11961364 if len(ln.Notes) > 0 || ln.Form != nil {
1197- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
1365+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
11981366 if templ_7745c5c3_Err != nil {
11991367 return templ_7745c5c3_Err
12001368 }
@@ -1202,60 +1370,60 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
12021370 if templ_7745c5c3_Err != nil {
12031371 return templ_7745c5c3_Err
12041372 }
1205- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</td></tr>")
1373+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</td></tr>")
12061374 if templ_7745c5c3_Err != nil {
12071375 return templ_7745c5c3_Err
12081376 }
12091377 }
12101378 }
1211- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</tbody>")
1379+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "</tbody>")
12121380 if templ_7745c5c3_Err != nil {
12131381 return templ_7745c5c3_Err
12141382 }
12151383 }
1216- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</table>")
1384+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</table>")
12171385 if templ_7745c5c3_Err != nil {
12181386 return templ_7745c5c3_Err
12191387 }
12201388 }
12211389 if p.ShowSplit {
1222- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<table class=\"dt spl\" aria-label=\"")
1390+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<table class=\"dt spl\" aria-label=\"")
12231391 if templ_7745c5c3_Err != nil {
12241392 return templ_7745c5c3_Err
12251393 }
1226- var templ_7745c5c3_Var62 string
1227- templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
1394+ var templ_7745c5c3_Var73 string
1395+ templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
12281396 if templ_7745c5c3_Err != nil {
1229- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 206, Col: 58}
1397+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 235, Col: 58}
12301398 }
1231- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
1399+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)
12321400 if templ_7745c5c3_Err != nil {
12331401 return templ_7745c5c3_Err
12341402 }
1235- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
1403+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
12361404 if templ_7745c5c3_Err != nil {
12371405 return templ_7745c5c3_Err
12381406 }
12391407 for _, h := range f.Hunks {
1240- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<tbody><tr class=\"hunk\"><td colspan=\"6\">")
1408+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<tbody><tr class=\"hunk\"><td colspan=\"6\">")
12411409 if templ_7745c5c3_Err != nil {
12421410 return templ_7745c5c3_Err
12431411 }
1244- var templ_7745c5c3_Var63 string
1245- templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1412+ var templ_7745c5c3_Var74 string
1413+ templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
12461414 if templ_7745c5c3_Err != nil {
1247- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 210, Col: 51}
1415+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 239, Col: 51}
12481416 }
1249- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
1417+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
12501418 if templ_7745c5c3_Err != nil {
12511419 return templ_7745c5c3_Err
12521420 }
1253- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</td></tr>")
1421+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "</td></tr>")
12541422 if templ_7745c5c3_Err != nil {
12551423 return templ_7745c5c3_Err
12561424 }
12571425 for _, row := range h.Split {
1258- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "<tr>")
1426+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<tr>")
12591427 if templ_7745c5c3_Err != nil {
12601428 return templ_7745c5c3_Err
12611429 }
@@ -1267,12 +1435,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
12671435 if templ_7745c5c3_Err != nil {
12681436 return templ_7745c5c3_Err
12691437 }
1270- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</tr>")
1438+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</tr>")
12711439 if templ_7745c5c3_Err != nil {
12721440 return templ_7745c5c3_Err
12731441 }
12741442 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
1275- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "<tr class=\"fnote\"><td colspan=\"6\">")
1443+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tr class=\"fnote\"><td colspan=\"6\">")
12761444 if templ_7745c5c3_Err != nil {
12771445 return templ_7745c5c3_Err
12781446 }
@@ -1280,53 +1448,133 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
12801448 if templ_7745c5c3_Err != nil {
12811449 return templ_7745c5c3_Err
12821450 }
1283- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</td></tr>")
1451+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td></tr>")
12841452 if templ_7745c5c3_Err != nil {
12851453 return templ_7745c5c3_Err
12861454 }
12871455 }
12881456 }
1289- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</tbody>")
1457+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</tbody>")
12901458 if templ_7745c5c3_Err != nil {
12911459 return templ_7745c5c3_Err
12921460 }
12931461 }
1294- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</table>")
1462+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</table>")
12951463 if templ_7745c5c3_Err != nil {
12961464 return templ_7745c5c3_Err
12971465 }
12981466 }
1299- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</div>")
1467+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</div>")
13001468 if templ_7745c5c3_Err != nil {
13011469 return templ_7745c5c3_Err
13021470 }
13031471 } else {
1304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<p class=\"note pad\">")
1472+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "<p class=\"note pad\">")
13051473 if templ_7745c5c3_Err != nil {
13061474 return templ_7745c5c3_Err
13071475 }
13081476 if f.Note != "" {
1309- var templ_7745c5c3_Var64 string
1310- templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1477+ var templ_7745c5c3_Var75 string
1478+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
13111479 if templ_7745c5c3_Err != nil {
1312- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 13}
1480+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 259, Col: 13}
13131481 }
1314- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
1482+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
13151483 if templ_7745c5c3_Err != nil {
13161484 return templ_7745c5c3_Err
13171485 }
13181486 } else {
1319- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "no textual change.")
1487+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "no textual change.")
1488+ if templ_7745c5c3_Err != nil {
1489+ return templ_7745c5c3_Err
1490+ }
1491+ }
1492+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</p>")
1493+ if templ_7745c5c3_Err != nil {
1494+ return templ_7745c5c3_Err
1495+ }
1496+ }
1497+ if len(f.Loose) > 0 {
1498+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<div class=\"dloose\">")
1499+ if templ_7745c5c3_Err != nil {
1500+ return templ_7745c5c3_Err
1501+ }
1502+ for _, c := range f.Loose {
1503+ templ_7745c5c3_Err = commentView(c, "").Render(ctx, templ_7745c5c3_Buffer)
13201504 if templ_7745c5c3_Err != nil {
13211505 return templ_7745c5c3_Err
13221506 }
13231507 }
1324- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</p>")
1508+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</div>")
13251509 if templ_7745c5c3_Err != nil {
13261510 return templ_7745c5c3_Err
13271511 }
13281512 }
1329- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</details>")
1513+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</details>")
1514+ if templ_7745c5c3_Err != nil {
1515+ return templ_7745c5c3_Err
1516+ }
1517+ return nil
1518+ })
1519+}
1520+
1521+// MRFiles is a window of files of a merge request, for the placeholders of
1522+// the page: the files only, which replace the placeholders of the same ids.
1523+func MRFiles(p MRFilesPage) templ.Component {
1524+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1525+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1526+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1527+ return templ_7745c5c3_CtxErr
1528+ }
1529+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1530+ if !templ_7745c5c3_IsBuffer {
1531+ defer func() {
1532+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1533+ if templ_7745c5c3_Err == nil {
1534+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1535+ }
1536+ }()
1537+ }
1538+ ctx = templ.InitializeContext(ctx)
1539+ templ_7745c5c3_Var76 := templ.GetChildren(ctx)
1540+ if templ_7745c5c3_Var76 == nil {
1541+ templ_7745c5c3_Var76 = templ.NopComponent
1542+ }
1543+ ctx = templ.ClearChildren(ctx)
1544+ for _, f := range p.Files {
1545+ templ_7745c5c3_Err = DiffFileView(f, p.DiffView).Render(ctx, templ_7745c5c3_Buffer)
1546+ if templ_7745c5c3_Err != nil {
1547+ return templ_7745c5c3_Err
1548+ }
1549+ }
1550+ return nil
1551+ })
1552+}
1553+
1554+// MRFilesMoved answers a window of a diff that is not the one of the page any
1555+// more: the placeholder shows it instead of a file of another diff.
1556+func MRFilesMoved() templ.Component {
1557+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1558+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1559+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1560+ return templ_7745c5c3_CtxErr
1561+ }
1562+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1563+ if !templ_7745c5c3_IsBuffer {
1564+ defer func() {
1565+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1566+ if templ_7745c5c3_Err == nil {
1567+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1568+ }
1569+ }()
1570+ }
1571+ ctx = templ.InitializeContext(ctx)
1572+ templ_7745c5c3_Var77 := templ.GetChildren(ctx)
1573+ if templ_7745c5c3_Var77 == nil {
1574+ templ_7745c5c3_Var77 = templ.NopComponent
1575+ }
1576+ ctx = templ.ClearChildren(ctx)
1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
13301578 if templ_7745c5c3_Err != nil {
13311579 return templ_7745c5c3_Err
13321580 }
@@ -1352,36 +1600,36 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
13521600 }()
13531601 }
13541602 ctx = templ.InitializeContext(ctx)
1355- templ_7745c5c3_Var65 := templ.GetChildren(ctx)
1356- if templ_7745c5c3_Var65 == nil {
1357- templ_7745c5c3_Var65 = templ.NopComponent
1603+ templ_7745c5c3_Var78 := templ.GetChildren(ctx)
1604+ if templ_7745c5c3_Var78 == nil {
1605+ templ_7745c5c3_Var78 = templ.NopComponent
13581606 }
13591607 ctx = templ.ClearChildren(ctx)
13601608 if ln == nil {
1361- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
1609+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
13621610 if templ_7745c5c3_Err != nil {
13631611 return templ_7745c5c3_Err
13641612 }
13651613 } else {
1366- var templ_7745c5c3_Var66 = []any{"ln", ln.Kind}
1367- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var66...)
1614+ var templ_7745c5c3_Var79 = []any{"ln", ln.Kind}
1615+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var79...)
13681616 if templ_7745c5c3_Err != nil {
13691617 return templ_7745c5c3_Err
13701618 }
1371- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<td class=\"")
1619+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<td class=\"")
13721620 if templ_7745c5c3_Err != nil {
13731621 return templ_7745c5c3_Err
13741622 }
1375- var templ_7745c5c3_Var67 string
1376- templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var66).String())
1623+ var templ_7745c5c3_Var80 string
1624+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var79).String())
13771625 if templ_7745c5c3_Err != nil {
13781626 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
13791627 }
1380- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
1628+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
13811629 if templ_7745c5c3_Err != nil {
13821630 return templ_7745c5c3_Err
13831631 }
1384- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\">")
1632+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "\">")
13851633 if templ_7745c5c3_Err != nil {
13861634 return templ_7745c5c3_Err
13871635 }
@@ -1396,64 +1644,64 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
13961644 return templ_7745c5c3_Err
13971645 }
13981646 }
1399- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</td>")
1647+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "</td>")
14001648 if templ_7745c5c3_Err != nil {
14011649 return templ_7745c5c3_Err
14021650 }
1403- var templ_7745c5c3_Var68 = []any{"m", ln.Kind}
1404- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var68...)
1651+ var templ_7745c5c3_Var81 = []any{"m", ln.Kind}
1652+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var81...)
14051653 if templ_7745c5c3_Err != nil {
14061654 return templ_7745c5c3_Err
14071655 }
1408- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<td class=\"")
1656+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<td class=\"")
14091657 if templ_7745c5c3_Err != nil {
14101658 return templ_7745c5c3_Err
14111659 }
1412- var templ_7745c5c3_Var69 string
1413- templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var68).String())
1660+ var templ_7745c5c3_Var82 string
1661+ templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var81).String())
14141662 if templ_7745c5c3_Err != nil {
14151663 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
14161664 }
1417- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
1665+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
14181666 if templ_7745c5c3_Err != nil {
14191667 return templ_7745c5c3_Err
14201668 }
1421- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\">")
1669+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "\">")
14221670 if templ_7745c5c3_Err != nil {
14231671 return templ_7745c5c3_Err
14241672 }
1425- var templ_7745c5c3_Var70 string
1426- templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1673+ var templ_7745c5c3_Var83 string
1674+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
14271675 if templ_7745c5c3_Err != nil {
1428- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 254, Col: 38}
1676+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 304, Col: 38}
14291677 }
1430- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
1678+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
14311679 if templ_7745c5c3_Err != nil {
14321680 return templ_7745c5c3_Err
14331681 }
1434- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</td>")
1682+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</td>")
14351683 if templ_7745c5c3_Err != nil {
14361684 return templ_7745c5c3_Err
14371685 }
1438- var templ_7745c5c3_Var71 = []any{"tx", ln.Kind}
1439- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var71...)
1686+ var templ_7745c5c3_Var84 = []any{"tx", ln.Kind}
1687+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var84...)
14401688 if templ_7745c5c3_Err != nil {
14411689 return templ_7745c5c3_Err
14421690 }
1443- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<td class=\"")
1691+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<td class=\"")
14441692 if templ_7745c5c3_Err != nil {
14451693 return templ_7745c5c3_Err
14461694 }
1447- var templ_7745c5c3_Var72 string
1448- templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var71).String())
1695+ var templ_7745c5c3_Var85 string
1696+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var84).String())
14491697 if templ_7745c5c3_Err != nil {
14501698 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
14511699 }
1452- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
1700+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var85)
14531701 if templ_7745c5c3_Err != nil {
14541702 return templ_7745c5c3_Err
14551703 }
1456- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "\">")
1704+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "\">")
14571705 if templ_7745c5c3_Err != nil {
14581706 return templ_7745c5c3_Err
14591707 }
@@ -1461,7 +1709,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
14611709 if templ_7745c5c3_Err != nil {
14621710 return templ_7745c5c3_Err
14631711 }
1464- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td>")
1712+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</td>")
14651713 if templ_7745c5c3_Err != nil {
14661714 return templ_7745c5c3_Err
14671715 }
@@ -1488,49 +1736,49 @@ func gutter(n, href string) templ.Component {
14881736 }()
14891737 }
14901738 ctx = templ.InitializeContext(ctx)
1491- templ_7745c5c3_Var73 := templ.GetChildren(ctx)
1492- if templ_7745c5c3_Var73 == nil {
1493- templ_7745c5c3_Var73 = templ.NopComponent
1739+ templ_7745c5c3_Var86 := templ.GetChildren(ctx)
1740+ if templ_7745c5c3_Var86 == nil {
1741+ templ_7745c5c3_Var86 = templ.NopComponent
14941742 }
14951743 ctx = templ.ClearChildren(ctx)
14961744 if href != "" {
1497- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<a href=\"")
1745+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<a href=\"")
14981746 if templ_7745c5c3_Err != nil {
14991747 return templ_7745c5c3_Err
15001748 }
1501- var templ_7745c5c3_Var74 templ.SafeURL
1502- templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
1749+ var templ_7745c5c3_Var87 templ.SafeURL
1750+ templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
15031751 if templ_7745c5c3_Err != nil {
1504- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 263, Col: 31}
1752+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 313, Col: 31}
15051753 }
1506- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
1754+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
15071755 if templ_7745c5c3_Err != nil {
15081756 return templ_7745c5c3_Err
15091757 }
1510- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\" title=\"comment on this line\">")
1758+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "\" title=\"comment on this line\">")
15111759 if templ_7745c5c3_Err != nil {
15121760 return templ_7745c5c3_Err
15131761 }
1514- var templ_7745c5c3_Var75 string
1515- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(n)
1762+ var templ_7745c5c3_Var88 string
1763+ templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(n)
15161764 if templ_7745c5c3_Err != nil {
1517- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 263, Col: 66}
1765+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 313, Col: 66}
15181766 }
1519- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
1767+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88))
15201768 if templ_7745c5c3_Err != nil {
15211769 return templ_7745c5c3_Err
15221770 }
1523- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</a>")
1771+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</a>")
15241772 if templ_7745c5c3_Err != nil {
15251773 return templ_7745c5c3_Err
15261774 }
15271775 } else {
1528- var templ_7745c5c3_Var76 string
1529- templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(n)
1776+ var templ_7745c5c3_Var89 string
1777+ templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(n)
15301778 if templ_7745c5c3_Err != nil {
1531- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 265, Col: 5}
1779+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 315, Col: 5}
15321780 }
1533- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1781+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
15341782 if templ_7745c5c3_Err != nil {
15351783 return templ_7745c5c3_Err
15361784 }
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 generated file: collapsed
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