detached 3c3845e

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

commit
3c3845e5d8be66bcbd3b3c37e910ff00f2dda13c
author
brice · · 2026-10-17 22:27 UTC
committer
bnema · · 2026-10-17 22:27 UTC
parent
e08f000
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
72 files · +3495 -1450
M internal/adapters/gitcli/diff.go +10 -3
@@ -107,16 +107,22 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
107107 stats.Deletions += entries[i].del
108108 }
109109 var diff domain.Diff
110- if len(entries) > limits.MaxFiles {
110+ if limits.MaxFiles > 0 && len(entries) > limits.MaxFiles {
111111 entries = entries[:limits.MaxFiles]
112112 diff.Truncated = true
113113 }
114+ // Only the files of the window [from, to) get their hunks; the others are
115+ // listed as they are, neither truncated nor counted in the budgets.
116+ from, to := max(limits.From, 0), len(entries)
117+ if limits.To > 0 {
118+ to = min(limits.To, len(entries))
119+ }
114120
115121 // The patch holds one section per file, two for a type change, in the
116122 // order of the entries. Entries without lines to show (binary, pure
117123 // rename, mode change) have a section without hunks.
118124 lastRead := -1
119- for i := range entries {
125+ for i := from; i < to; i++ {
120126 if wantsHunks(&entries[i], limits, limits.MaxTotalLines, limits.MaxTotalBytes) {
121127 lastRead = i
122128 }
@@ -136,8 +142,9 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
136142 if e.typeChange {
137143 sections = 2
138144 }
145+ inWindow := i >= from && i < to
139146 switch {
140- case !changedLines(e):
147+ case !inWindow, !changedLines(e):
141148 // Nothing to show.
142149 case !wantsHunks(e, limits, budget, pr.bytesLeft):
143150 e.file.Truncated = true
M internal/adapters/gitcli/diff_test.go +176 -0
@@ -484,3 +484,179 @@ func TestGetCommitStopsGitEarly(t *testing.T) {
484484 assert.True(t, d.Diff.Files[1].Truncated)
485485 assert.Equal(t, 100005, d.Stats.Additions)
486486 }
487+
488+// windowFixture commits a diff that mixes what the patch reader must step over
489+// in a window: text, binary, a rename, a type change (two sections), a mode
490+// change (no hunks) and more text. It returns the id of the second commit.
491+func windowFixture(t *testing.T, data, name string) (*readertest.Fixture, string) {
492+ t.Helper()
493+ f := readertest.NewFixture(t, data, name)
494+ f.Lines("a-text", "a", 20).WriteBytes("b-bin", []byte{0, 1, 2}).Lines("c-old", "c", 30).
495+ Lines("d-type", "d", 5).Lines("e-mode", "e", 3).Lines("f-text", "f", 20).Lines("g-text", "g", 20)
496+ f.Commit("v1")
497+ f.Lines("a-text", "A", 21).WriteBytes("b-bin", []byte{0, 3}).Mv("c-old", "c-new").
498+ Remove("d-type").Symlink("a-text", "d-type").Chmod("e-mode", 0o755).Lines("f-text", "F", 20).Lines("g-text", "G", 2)
499+ f.Write("c-new", readLines("c", 30)+"extra\n") // a rename with one line added
500+ id := f.Commit("v2")
501+ return f, id
502+}
503+
504+func readLines(prefix string, n int) string {
505+ var b strings.Builder
506+ for i := range n {
507+ fmt.Fprintf(&b, "%s %d\n", prefix, i)
508+ }
509+ return b.String()
510+}
511+
512+func TestDiffLimitsWindow(t *testing.T) {
513+ ctx := context.Background()
514+ g, data := newReader(t, nil)
515+
516+ t.Run("a window reads the hunks of its files only, whatever the files around it", func(t *testing.T) {
517+ f, id := windowFixture(t, data, "win-mixed")
518+ full, err := g.GetCommit(ctx, f.Name, readertest.Rev(t, id), domain.DefaultDiffLimits())
519+ require.NoError(t, err)
520+ assertMatchesGitShow(t, f, id, full)
521+ n := len(full.Diff.Files)
522+ require.GreaterOrEqual(t, n, 7)
523+ status := map[domain.FileStatus]bool{}
524+ for _, file := range full.Diff.Files {
525+ status[file.Status] = true
526+ }
527+ assert.True(t, status[domain.FileTypeChanged], "the fixture has a type change")
528+ assert.True(t, status[domain.FileRenamed], "the fixture has a rename")
529+ assert.False(t, full.Diff.Truncated)
530+
531+ tests := []struct {
532+ name string
533+ from, to int
534+ }{
535+ {"first file", 0, 1},
536+ {"middle", 2, 5},
537+ {"type change alone", 3, 4},
538+ {"binary alone", 1, 2},
539+ {"mode change alone", 4, 5},
540+ {"rename alone", 2, 3},
541+ {"through the end, to 0", 3, 0},
542+ {"to past the end", 5, 100},
543+ {"last file", n - 1, n},
544+ {"from past the end", n, n + 5},
545+ {"far past the end", 1000, 0},
546+ {"empty window", 3, 3},
547+ {"whole diff", 0, 0},
548+ }
549+ for _, tc := range tests {
550+ t.Run(tc.name, func(t *testing.T) {
551+ limits := domain.DefaultDiffLimits()
552+ limits.From, limits.To = tc.from, tc.to
553+ d, err := g.GetCommit(ctx, f.Name, readertest.Rev(t, id), limits)
554+ require.NoError(t, err)
555+ require.Len(t, d.Diff.Files, n)
556+ assert.Equal(t, full.Stats, d.Stats)
557+ assert.False(t, d.Diff.Truncated, "files outside the window do not truncate the diff")
558+ to := tc.to
559+ if to == 0 {
560+ to = n
561+ }
562+ for i, got := range d.Diff.Files {
563+ want := full.Diff.Files[i]
564+ if i < tc.from || i >= to {
565+ want.Hunks = nil
566+ }
567+ assert.Equal(t, want, got, "file %d (%s)", i, want.NewPath)
568+ }
569+ })
570+ }
571+ })
572+
573+ t.Run("files outside the window use none of the total budget", func(t *testing.T) {
574+ f := readertest.NewFixture(t, data, "win-budget")
575+ commitFiles(f, "five", map[string]int{"a": 10, "b": 10, "c": 10, "d": 10, "e": 10})
576+ limits := domain.DiffLimits{MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20, From: 3, To: 5}
577+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
578+ require.NoError(t, err)
579+ require.Len(t, d.Diff.Files, 5)
580+ for i, file := range d.Diff.Files {
581+ switch i {
582+ case 3:
583+ assert.False(t, file.Truncated)
584+ assert.Len(t, file.Hunks, 1, "10 lines fit in 15")
585+ case 4:
586+ assert.True(t, file.Truncated, "the second file of the window does not fit in the 5 lines left")
587+ default:
588+ assert.False(t, file.Truncated)
589+ assert.Empty(t, file.Hunks)
590+ assert.Equal(t, 10, file.Additions, "counts stay accurate")
591+ }
592+ }
593+ assert.True(t, d.Diff.Truncated, "a file of the window is truncated")
594+
595+ limits.From, limits.To = 0, 0
596+ limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file
597+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
598+ require.NoError(t, err)
599+ assert.True(t, d.Diff.Files[0].Truncated)
600+ })
601+
602+ t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) {
603+ f := readertest.NewFixture(t, data, "win-huge")
604+ f.Lines("a", "x", 5).Lines("z-huge", "y", 5000).Commit("c")
605+ limits := domain.DefaultDiffLimits()
606+ limits.From, limits.To = 0, 1
607+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
608+ require.NoError(t, err)
609+ require.Len(t, d.Diff.Files, 2)
610+ assert.Len(t, d.Diff.Files[0].Hunks, 1)
611+ assert.False(t, d.Diff.Files[1].Truncated)
612+ assert.Empty(t, d.Diff.Files[1].Hunks)
613+ assert.False(t, d.Diff.Truncated)
614+ limits.From, limits.To = 1, 2
615+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
616+ require.NoError(t, err)
617+ assert.True(t, d.Diff.Files[1].Truncated, "in the window, over the per-file limit")
618+ assert.True(t, d.Diff.Truncated)
619+ })
620+
621+ t.Run("MaxFiles 0 lists every file", func(t *testing.T) {
622+ f := readertest.NewFixture(t, data, "win-many")
623+ const total = 350
624+ for i := range total {
625+ f.Write(fmt.Sprintf("many/f%03d", i), fmt.Sprintf("x%d\n", i))
626+ }
627+ f.Commit("many")
628+ limits := domain.DefaultDiffLimits()
629+ limits.MaxFiles = 0
630+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
631+ require.NoError(t, err)
632+ require.Len(t, d.Diff.Files, total)
633+ assert.False(t, d.Diff.Truncated)
634+ assert.Equal(t, domain.DiffStats{FilesChanged: total, Additions: total}, d.Stats)
635+ for i, file := range d.Diff.Files {
636+ assert.Equal(t, fmt.Sprintf("many/f%03d", i), file.NewPath)
637+ require.Len(t, file.Hunks, 1, file.NewPath)
638+ }
639+
640+ // A window past the 300th file reads its own hunks.
641+ limits.From, limits.To = 320, 330
642+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
643+ require.NoError(t, err)
644+ require.Len(t, d.Diff.Files, total)
645+ assert.False(t, d.Diff.Truncated)
646+ for i, file := range d.Diff.Files {
647+ if i >= 320 && i < 330 {
648+ require.Len(t, file.Hunks, 1)
649+ assert.Equal(t, fmt.Sprintf("x%d", i), file.Hunks[0].Lines[0].Text)
650+ } else {
651+ assert.Empty(t, file.Hunks)
652+ }
653+ assert.False(t, file.Truncated)
654+ }
655+
656+ // The default limits still cut at 300.
657+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
658+ require.NoError(t, err)
659+ assert.Len(t, d.Diff.Files, 300)
660+ assert.True(t, d.Diff.Truncated)
661+ })
662+}
M internal/adapters/rpc/mergerequest.go +1 -1
@@ -166,7 +166,7 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
166166 if err != nil {
167167 return nil, connectError(ctx, err)
168168 }
169- d, err := s.mrs.Get(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetSinceLastRead())
169+ d, err := s.mrs.Get(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.GetMergeRequestOptions{SinceLastRead: req.Msg.GetSinceLastRead()})
170170 if err != nil {
171171 return nil, connectError(ctx, err)
172172 }
M internal/adapters/rpc/mergerequest_test.go +1 -1
@@ -212,7 +212,7 @@ func TestMergeRequestServiceGet(t *testing.T) {
212212 t.Run(tt.name, func(t *testing.T) {
213213 t.Parallel()
214214 mrs := mocks.NewMergeRequestService(t)
215- mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), true).
215+ mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}).
216216 Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once()
217217 resp, err := (&mergeRequestService{mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))
218218 if tt.wantCode != 0 {
M internal/adapters/web/actions.templ +14 -0
@@ -423,6 +423,20 @@ templ CommitChecks(c ChecksSummary) {
423423 }
424424 }
425425
426+// MRChecksSum is the state of the checks of a merge request in the bar above
427+// the diff, a link to the checks of the rail. The event stream replaces it
428+// with the checks section (same event, fragments.go).
429+templ MRChecksSum(c ChecksSummary) {
430+ <a class="ck-sum mr-ck" id="mr-ck" href="#mr-checks" title={ c.Title }>
431+ if c.Total > 0 {
432+ @CheckIcon(c.Kind, c.State)
433+ <span>{ c.Label }</span>
434+ } else {
435+ <span class="mute">no checks</span>
436+ }
437+ </a>
438+}
439+
426440 // MRChecks is the checks section of the rail of a merge request.
427441 templ MRChecks(c ChecksSection) {
428442 <section aria-labelledby="r-checks" id="mr-checks">
M internal/adapters/web/actions_templ.go +120 -43 generated file: collapsed
M internal/adapters/web/assets.go +2 -1
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
2424 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2525 // the content, so they can be cached for ever.
2626 type Assets struct {
27- CSS, HTMX, Live, SSE, App, Files, Passkey, Log, Icon string
27+ CSS, HTMX, Live, SSE, App, Files, Passkey, Log, MR, Icon string
2828 // Manifest is the web app manifest (install on a phone's home screen),
2929 // AppleIcon the home screen icon iOS reads from a link instead.
3030 Manifest, AppleIcon string
@@ -62,6 +62,7 @@ var scripts = []struct {
6262 {"files.js", func(a *Assets, u string) { a.Files = u }},
6363 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
6464 {"log.js", func(a *Assets, u string) { a.Log = u }},
65+ {"mr.js", func(a *Assets, u string) { a.MR = u }},
6566 }
6667
6768 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):
M internal/adapters/web/diff.go +61 -34
@@ -80,10 +80,6 @@ type DiffHunk struct {
8080 Split []SplitRow
8181 }
8282
83-// Bar is the change bar of the file list, "+++--·····", in three runs so each
84-// can carry its colour.
85-type Bar struct{ Add, Del, Rest string }
86-
8783 // DiffFile is one changed file.
8884 type DiffFile struct {
8985 ID string
@@ -93,12 +89,48 @@ type DiffFile struct {
9389 Key string
9490 Path string // display path, "old → new" for a rename
9591 Add, Del int
96- Bar Bar
92+ // Bin is true for a binary file: no lines to count.
93+ Bin bool
9794 // Note is why there is no text to show, or what else changed: a binary
9895 // file, a mode change, a truncation, a similarity.
9996 Note string
10097 Open bool
10198 Hunks []DiffHunk
99+ // Digits is the width of the largest line number of the file, at least 2
100+ // and at most 7: the gutter is as wide as the file needs (class lnw-N).
101+ Digits int
102+ // Name and Dir are the last element of the path and the directory before
103+ // it ("" at the root), for the compact file list.
104+ Name, Dir string
105+ // Notes is the number of comments on lines of the file, Open those not
106+ // resolved; the file list shows them.
107+ Notes, OpenNotes int
108+ // Lazy is true when the hunks of the file were not read: the page shows a
109+ // placeholder that loads them (Src, the window of files it belongs to).
110+ Lazy bool
111+ Src string
112+ // From is the index of the first file of the window of a lazy file: the
113+ // page without scripts links to ?from=From.
114+ From int
115+ // Height is the size class of a lazy file (1 to 4, by its changed lines),
116+ // so that the placeholder takes about the room of the diff.
117+ Height int
118+ // Comments are the ids of the comments on the file ("c12"): a link to a
119+ // comment of a lazy file loads that file first.
120+ Comments string
121+ // Loose are the comments of a file loaded with its window that are on a
122+ // line its diff does not show: listed at the end of the file.
123+ Loose []CommentView
124+}
125+
126+// gutterDigits is the number of digits of the largest line number of the
127+// hunks, clamped to the widths the style sheet has a class for.
128+func gutterDigits(hunks []domain.Hunk) int {
129+ most := 0
130+ for _, h := range hunks {
131+ most = max(most, h.OldStart+h.OldLines, h.NewStart+h.NewLines)
132+ }
133+ return min(max(len(itoa(most)), 2), 7)
102134 }
103135
104136 func lineNumber(n int) string {
@@ -176,26 +208,6 @@ func pairLines(ls []DiffLine) []SplitRow {
176208 return out
177209 }
178210
179-// changeBar draws the change bar of a file, scaled to the largest file.
180-func changeBar(add, del, most int) Bar {
181- const cells = 10
182- if most == 0 {
183- return Bar{Rest: strings.Repeat("·", cells)}
184- }
185- scale := func(n int) int {
186- c := (n*cells + most - 1) / most
187- if n > 0 && c == 0 {
188- c = 1
189- }
190- return c
191- }
192- a, d := scale(add), scale(del)
193- if a+d > cells {
194- d = cells - a
195- }
196- return Bar{strings.Repeat("+", a), strings.Repeat("-", d), strings.Repeat("·", cells-a-d)}
197-}
198-
199211 // statusLetter is the one-letter status of a file.
200212 func statusLetter(s domain.FileStatus) string {
201213 switch s {
@@ -213,6 +225,27 @@ func statusLetter(s domain.FileStatus) string {
213225 return "M"
214226 }
215227
228+// fileFilterMin is the number of files above which the file list has a
229+// filter.
230+const fileFilterMin = 10
231+
232+// statusName says what a status letter means, for its tooltip.
233+func statusName(letter string) string {
234+ switch letter {
235+ case "A":
236+ return "added"
237+ case "D":
238+ return "deleted"
239+ case "R":
240+ return "renamed"
241+ case "C":
242+ return "copied"
243+ case "T":
244+ return "type changed"
245+ }
246+ return "modified"
247+}
248+
216249 // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk.
217250 func hunkHeader(h domain.Hunk) string {
218251 s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines)
@@ -239,16 +272,11 @@ func diffLine(l domain.Line) DiffLine {
239272
240273 // diffFiles converts the files of a diff for the page.
241274 func diffFiles(files []domain.FileDiff) []DiffFile {
242- most := 0
243- for _, f := range files {
244- most = max(most, f.Additions+f.Deletions)
245- }
246275 out := make([]DiffFile, 0, len(files))
247276 for i, f := range files {
248277 df := DiffFile{
249278 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),
250- Add: f.Additions, Del: f.Deletions,
251- Bar: changeBar(f.Additions, f.Deletions, most),
279+ Add: f.Additions, Del: f.Deletions, Bin: f.Binary,
252280 // A file that is shown in full opens; a file with nothing to read
253281 // (binary, truncated) stays closed under its note.
254282 Open: len(f.Hunks) > 0 && !f.Truncated,
@@ -264,9 +292,8 @@ func diffFiles(files []domain.FileDiff) []DiffFile {
264292 df.Path = visibleText(f.NewPath)
265293 }
266294 df.Note = fileNote(f)
267- if f.Binary {
268- df.Bar = Bar{Rest: "bin"}
269- }
295+ df.Digits = gutterDigits(f.Hunks)
296+ df.Name, df.Dir = splitPath(df.Key)
270297 for _, h := range f.Hunks {
271298 dh := DiffHunk{Header: hunkHeader(h)}
272299 for _, l := range h.Lines {
M internal/adapters/web/diff.templ +57 -7
@@ -141,17 +141,39 @@ templ commitRail(p CommitPage) {
141141 // FileList is the table of contents of a diff: status letter, path, +n -m
142142 // and an ASCII bar. The row of the file in view is marked.
143143 templ FileList(files []DiffFile) {
144+ if len(files) > fileFilterMin {
145+ <div class="ffilter">
146+ <label for="files-filter" class="sr">filter the changed files</label>
147+ <input
148+ id="files-filter"
149+ type="search"
150+ placeholder="Filter files…"
151+ autocomplete="off"
152+ data-pick=".files > li"
153+ hx-on:input="ermite.filter(this)"
154+ />
155+ </div>
156+ }
144157 <ol class="files" aria-label="Changed files">
145158 for _, f := range files {
146159 <li>
147160 <a
148161 href={ templ.SafeURL("#" + f.ID) }
149- hx-on:click="document.getElementById(this.hash.slice(1)).open = true"
162+ title={ f.Path }
163+ hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"
150164 >
151- <span class="st" title={ f.Status }>{ f.Status }</span>
152- <span class="p" title={ f.Path }>{ f.Path }</span>
153- <span class="n"><span class="plus">+{ itoa(f.Add) }</span> <span class="minus">-{ itoa(f.Del) }</span></span>
154- <span class="b" aria-hidden="true"><span class="plus">{ f.Bar.Add }</span><span class="minus">{ f.Bar.Del }</span>{ f.Bar.Rest }</span>
165+ <span class="st" data-st={ f.Status } title={ statusName(f.Status) }>{ f.Status }</span>
166+ <span class="nm"><span class="p">{ f.Name }</span>{ " " }<span class="d"><bdi>{ f.Dir }</bdi></span></span>
167+ if f.OpenNotes > 0 {
168+ <span class="cn" title={ plural(f.OpenNotes, "open comment", "open comments") }>{ itoa(f.OpenNotes) }</span>
169+ } else if f.Notes > 0 {
170+ <span class="cn done" title={ plural(f.Notes, "resolved comment", "resolved comments") }>{ itoa(f.Notes) }</span>
171+ }
172+ if f.Bin {
173+ <span class="n mute">bin</span>
174+ } else {
175+ <span class="n"><span class="plus">+{ itoa(f.Add) }</span> <span class="minus">-{ itoa(f.Del) }</span></span>
176+ }
155177 </a>
156178 </li>
157179 }
@@ -164,7 +186,7 @@ templ FileList(files []DiffFile) {
164186 // colour alone.
165187 templ DiffFileView(f DiffFile, p DiffView) {
166188 <details
167- class="dfile"
189+ class={ "dfile", "lnw-" + itoa(f.Digits) }
168190 id={ f.ID }
169191 data-file
170192 if f.Open {
@@ -177,7 +199,14 @@ templ DiffFileView(f DiffFile, p DiffView) {
177199 { " " }<span class="note">{ f.Note }</span>
178200 }
179201 </summary>
180- if len(f.Hunks) > 0 {
202+ if f.Lazy {
203+ // A placeholder: mr.js fetches the window of the file when it comes
204+ // near the screen and swaps the files of the window in. Without
205+ // scripts the link opens the page of that window.
206+ <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
207+ <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
208+ </div>
209+ } else if len(f.Hunks) > 0 {
181210 <div class="dscroll">
182211 if p.ShowUnified {
183212 <table class="dt uni" aria-label={ f.Path + ", unified" }>
@@ -233,9 +262,30 @@ templ DiffFileView(f DiffFile, p DiffView) {
233262 }
234263 </p>
235264 }
265+ if len(f.Loose) > 0 {
266+ <div class="dloose">
267+ for _, c := range f.Loose {
268+ @commentView(c, "")
269+ }
270+ </div>
271+ }
236272 </details>
237273 }
238274
275+// MRFiles is a window of files of a merge request, for the placeholders of
276+// the page: the files only, which replace the placeholders of the same ids.
277+templ MRFiles(p MRFilesPage) {
278+ for _, f := range p.Files {
279+ @DiffFileView(f, p.DiffView)
280+ }
281+}
282+
283+// MRFilesMoved answers a window of a diff that is not the one of the page any
284+// more: the placeholder shows it instead of a file of another diff.
285+templ MRFilesMoved() {
286+ <p class="notice" data-moved>this merge request changed since the page was loaded — <a href="">reload</a></p>
287+}
288+
239289 // splitSide is one half of a row of the split view; a side without a line is
240290 // an empty cell.
241291 templ splitSide(ln *DiffLine, old bool) {
M internal/adapters/web/diff_templ.go +463 -215 generated file: collapsed
M internal/adapters/web/diff_test.go +0 -9
@@ -85,15 +85,6 @@ func TestPairLines(t *testing.T) {
8585 assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}, newN: 2}, ls[4])
8686 }
8787
88-func TestChangeBar(t *testing.T) {
89- t.Parallel()
90- assert.Equal(t, Bar{Rest: "··········"}, changeBar(0, 0, 0))
91- assert.Equal(t, Bar{Add: "++++++++++"}, changeBar(100, 0, 100))
92- assert.Equal(t, Bar{Add: "+++++", Del: "-----", Rest: ""}, changeBar(50, 50, 100))
93- assert.Equal(t, Bar{Add: "+", Rest: "·········"}, changeBar(1, 0, 1000), "a change is never rounded away")
94- assert.Equal(t, 10, len(changeBar(7, 7, 8).Add+changeBar(7, 7, 8).Del+changeBar(7, 7, 8).Rest), "never wider than the bar")
95-}
96-
9788 func TestFileNotes(t *testing.T) {
9889 t.Parallel()
9990 tests := []struct {
M internal/adapters/web/fragments.go +7 -2
@@ -496,8 +496,13 @@ func (l *liveStream) mrChecks(ctx context.Context) ([]fragment, error) {
496496 case err != nil:
497497 return nil, err
498498 }
499- f, err := renderFragment(ctx, "ci-run", MRChecks(checksSection(repo, l.s.checksFor(ctx, name, l.head))))
500- return []fragment{f}, err
499+ checks := l.s.checksFor(ctx, name, l.head)
500+ f, err := renderFragment(ctx, "ci-run", MRChecks(checksSection(repo, checks)))
501+ if err != nil {
502+ return nil, err
503+ }
504+ sum, err := renderFragment(ctx, "ci-run", MRChecksSum(checksSummary(name, l.head, checks)))
505+ return []fragment{f, sum}, err
501506 }
502507
503508 // recentActions is the "Recent actions" block of the summary rail, read again
A internal/adapters/web/jstest/mr.test.mjs +38 -0
@@ -0,0 +1,38 @@
1+// Tests of the pure core of the merge request page (static/js/mr.js). Run with
2+// node --test 'internal/adapters/web/jstest/*.test.mjs'
3+import assert from 'node:assert/strict';
4+import { test } from 'node:test';
5+
6+globalThis.window = globalThis;
7+globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };
8+await import('../static/js/mr.js');
9+const C = window.ermiteMR.core;
10+
11+test('windowOf: the start of the window of a file', () => {
12+ assert.equal(C.windowOf('f1', 25), 0);
13+ assert.equal(C.windowOf('f25', 25), 0);
14+ assert.equal(C.windowOf('f26', 25), 25);
15+ assert.equal(C.windowOf('f84', 25), 75);
16+ assert.equal(C.windowOf('c3', 25), -1, 'not a file');
17+ assert.equal(C.windowOf('f0', 25), -1);
18+});
19+
20+test('menuKey: modes by number, D toggles the deletion, nothing else', () => {
21+ assert.deepEqual(C.menuKey('1', 2), { mode: 0 });
22+ assert.deepEqual(C.menuKey('2', 2), { mode: 1 });
23+ assert.equal(C.menuKey('3', 2), null, 'no third mode');
24+ assert.equal(C.menuKey('0', 2), null);
25+ assert.deepEqual(C.menuKey('D', 2), { toggle: true });
26+ assert.equal(C.menuKey('d', 2), null, 'd is the split layout');
27+ assert.equal(C.menuKey('Enter', 2), null, 'the browser submits: the focus is on the button');
28+ assert.equal(C.menuKey('M', 2), null);
29+});
30+
31+test('step: stays in the list', () => {
32+ assert.equal(C.step(0, 1, 3), 1);
33+ assert.equal(C.step(2, 1, 3), 2);
34+ assert.equal(C.step(0, -1, 3), 0);
35+ assert.equal(C.step(-1, 1, 3), 0);
36+ assert.equal(C.step(-1, -1, 3), 2);
37+ assert.equal(C.step(0, 1, 0), -1);
38+});
M internal/adapters/web/keys.templ +4 -1
@@ -29,6 +29,9 @@ templ Keys() {
2929 k-split from:body -> ermite.layout('split');
3030 k-unified from:body -> ermite.layout('unified');
3131 k-collapse from:body -> ermite.collapse();
32- k-permalink from:body -> ermite.permalink()"
32+ k-permalink from:body -> ermite.permalink();
33+ k-merge from:body -> window.ermiteMR?.openMerge();
34+ k-read from:body -> window.ermiteMR?.markRead();
35+ k-gf from:body -> window.ermiteMR?.focusFiles()"
3336 ></div>
3437 }
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/mr.go +70 -13
@@ -28,6 +28,13 @@ import (
2828 // GET /<repo>/merge-requests/<n> the request with its diff
2929 // GET /<repo>/merge-requests/<n>/since the diff since the last read
3030 // GET /<repo>/compare/<branch> a branch against the default branch
31+// GET /<repo>/merge-requests/<n>/files a window of files of the diff
32+//
33+// The page lists every changed file and reads the hunks of the first window
34+// of files only (mrWindow); the files after it are placeholders that the
35+// browser fills in, a window per request, as they come near the screen
36+// (/files?from=&base=&head=). Without scripts each placeholder links to the
37+// page of its window (?from=), which shows the hunks of that window.
3138 //
3239 // Reading marks nothing as read. MarkRead stores state, and a GET must stay
3340 // safe: a prefetcher, a link preview or a reload of the owner's tab would move
@@ -43,6 +50,9 @@ import (
4350 // forms and the CLI lines exist only in the owner's copy.
4451
4552 const (
53+ // mrWindow is how many files of a merge request one read gets the hunks
54+ // of: the page shows the first window and loads the others.
55+ mrWindow = 25
4656 // mrPerPage is how many decided merge requests a page of the list shows,
4757 // at least; pages of the use case are read whole.
4858 mrPerPage = 50
@@ -114,6 +124,8 @@ type ResolveForm struct {
114124 // Value is the state the form sets.
115125 Value bool
116126 Label string
127+ // From is the window of the page the comment is on (CommentForm.From).
128+ From int
117129 }
118130
119131 // CommentForm is the POST that adds a comment.
@@ -125,6 +137,9 @@ type CommentForm struct {
125137 Path, Line, Side string
126138 Cancel string
127139 Focus bool
140+ // From is the window of the page the form is on (see mrWindow), sent
141+ // back so that the answer returns to it; 0 for the first.
142+ From int
128143 }
129144
130145 // MRCommit is a commit of the request.
@@ -198,8 +213,11 @@ type MRPage struct {
198213 Invisible bool
199214
200215 // General are the comments that are not on a line, Loose those on a line
201- // that the diff shown does not have.
216+ // that the diff shown does not have. With LooseInFiles (a window of files
217+ // loaded later) the loose comments of a listed file go under the file
218+ // instead (DiffFile.Loose).
202219 General, Loose []CommentView
220+ LooseInFiles bool
203221 NComments int
204222 NUnresolved int
205223 GeneralForm *CommentForm
@@ -208,12 +226,16 @@ type MRPage struct {
208226 CommitsNote string
209227 Modes []string
210228 ChecksBlock bool
211- // Checks are the CI checks of the head of the request.
212- Checks ChecksSection
229+ // Checks are the CI checks of the head of the request, ChecksSum their
230+ // state in the bar above the diff.
231+ Checks ChecksSection
232+ ChecksSum ChecksSummary
213233
214234 Box *MergeBox
215235 // Live is true when the page announces changes.
216236 Live bool
237+ // From is the first file of the window the page read the hunks of (?from=).
238+ From int
217239 }
218240
219241 // CompareCommit is a commit of a branch that the target lacks.
@@ -419,10 +441,19 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
419441 if err != nil {
420442 return nil, 0, err
421443 }
422- res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, since)
444+ from, err := parseFrom(r)
423445 if err != nil {
424446 return nil, 0, err
425447 }
448+ res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
449+ SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow},
450+ })
451+ if err != nil {
452+ return nil, 0, err
453+ }
454+ if from > 0 && from >= len(res.Diff.Files) {
455+ return nil, 0, errNotFound
456+ }
426457 m := res.MergeRequest
427458 now := s.cfg.Now()
428459 signed := SignedIn(ctx)
@@ -470,13 +501,20 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
470501 }
471502 }
472503 p.ChecksBlock = d.repo.Settings.FailingChecksBlock
473- if open && head != "" {
474- p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head))
475- } else if head != "" {
476- p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head))
477- p.Checks.Note = ""
504+ if head != "" {
505+ checks := s.checksFor(ctx, d.repo.Name, head)
506+ p.Checks = checksSection(d.repo.Repo, checks)
507+ p.ChecksSum = checksSummary(d.repo.Name, head, checks)
508+ if !open {
509+ p.Checks.Note = ""
510+ }
478511 }
479512 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
513+ countNotes(p.Files, res.Comments)
514+ if !res.DiffBaseID.IsZero() {
515+ lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
516+ }
517+ p.From = from
480518
481519 // The commits of the request, newest first.
482520 for _, c := range res.Commits {
@@ -538,7 +576,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
538576 s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
539577 lines, invisible := diffLines(p.Files)
540578 p.Invisible = invisible
541- p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
579+ p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
542580 if signed && open {
543581 p.Box = s.mergeBox(d, n, m.Source, head, lastRead)
544582 if m.IsDraft() {
@@ -570,6 +608,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
570608 p.Live = true
571609 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head))
572610 l.HasRail = true
611+ l.MR = true
573612 return MergeRequest(l, p), http.StatusOK, nil
574613 }
575614
@@ -666,23 +705,30 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
666705 }
667706 index := map[lineKey]*DiffLine{}
668707 fileIDs := map[string]string{}
708+ fileAt := map[string]int{}
669709 for fi := range p.Files {
670710 f := &p.Files[fi]
671711 fileIDs[f.Key] = f.ID
712+ fileAt[f.Key] = fi
672713 linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
714+ // The form of a line shows on the page of the window of its file.
715+ on := self + "?on="
716+ if w := windowStart(fi); w > 0 {
717+ on = self + "?from=" + itoa(w) + "&on="
718+ }
673719 for hi := range f.Hunks {
674720 for li := range f.Hunks[hi].Lines {
675721 ln := &f.Hunks[hi].Lines[li]
676722 if ln.oldN > 0 && ln.Kind != "add" {
677723 index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln
678724 if linkable {
679- ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
725+ ln.OldHref = on + "old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
680726 }
681727 }
682728 if ln.newN > 0 && ln.Kind != "del" {
683729 index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln
684730 if linkable {
685- ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
731+ ln.NewHref = on + "new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
686732 }
687733 }
688734 }
@@ -711,6 +757,11 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
711757 p.NUnresolved++
712758 }
713759 general := c.Path == ""
760+ fi, listed := fileAt[c.Path]
761+ if !general && listed && p.Files[fi].Lazy {
762+ // Its file comes with its window, and the comment with it.
763+ continue
764+ }
714765 var ln *DiffLine
715766 if !general {
716767 ln = find(c.Path, c.Side, c.Line)
@@ -724,6 +775,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
724775 }
725776 if canWrite {
726777 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
778+ if !general && listed {
779+ v.Resolve.From = windowStart(fi)
780+ }
727781 if c.Resolved {
728782 v.Resolve.Label = "Unresolve"
729783 }
@@ -733,6 +787,8 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
733787 ln.Notes = append(ln.Notes, v)
734788 case general:
735789 p.General = append(p.General, v)
790+ case p.LooseInFiles && listed:
791+ p.Files[fi].Loose = append(p.Files[fi].Loose, v)
736792 default:
737793 p.Loose = append(p.Loose, v)
738794 }
@@ -747,7 +803,8 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
747803 ln.Form = &CommentForm{
748804 ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
749805 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
750- Cancel: self + "#" + fileIDs[on.path], Focus: true,
806+ Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
807+ From: windowStart(fileAt[on.path]),
751808 }
752809 }
753810 }
M internal/adapters/web/mr.templ +113 -75
@@ -162,13 +162,7 @@ templ MergeRequest(l Layout, p MRPage) {
162162 if p.SinceNote != "" {
163163 <p class="notice">{ p.SinceNote }</p>
164164 }
165- <div class="diff-tools" role="toolbar" aria-label="Diff view">
166- <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
167- <span class="grow"></span>
168- @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
169- @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
170- @collapseButton()
171- </div>
165+ @mrBar(p)
172166 if p.Invisible {
173167 <p class="notice">
174168 this diff holds invisible characters (direction controls, zero-width characters). They are shown as
@@ -180,6 +174,7 @@ templ MergeRequest(l Layout, p MRPage) {
180174 }
181175 <div
182176 id="diffs"
177+ data-mr
183178 if p.Both {
184179 data-diff={ p.View }
185180 } else {
@@ -256,6 +251,9 @@ templ commentView(c CommentView, suffix string) {
256251 if c.Resolve != nil {
257252 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
258253 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
254+ if c.Resolve.From > 0 {
255+ <input type="hidden" name="from" value={ itoa(c.Resolve.From) }/>
256+ }
259257 <button type="submit" class="btn">{ c.Resolve.Label }</button>
260258 </form>
261259 }
@@ -289,6 +287,9 @@ templ commentForm(f CommentForm, suffix string) {
289287 <input type="hidden" name="line" value={ f.Line }/>
290288 <input type="hidden" name="side" value={ f.Side }/>
291289 }
290+ if f.From > 0 {
291+ <input type="hidden" name="from" value={ itoa(f.From) }/>
292+ }
292293 <div class="f-btns">
293294 <button type="submit" class="btn primary">Comment</button>
294295 if f.Cancel != "" {
@@ -299,25 +300,11 @@ templ commentForm(f CommentForm, suffix string) {
299300 }
300301
301302 templ mrRail(p MRPage) {
302- if p.Box != nil {
303- @mergeBox(p)
304- }
305- @MRChecks(p.Checks)
306- <section aria-labelledby="r-settings">
307- <h2 id="r-settings">Settings</h2>
308- <dl class="kv">
309- <dt>modes</dt>
310- <dd>{ strings.Join(p.Modes, ", ") }</dd>
311- <dt>checks</dt>
312- <dd>
313- if p.ChecksBlock {
314- failing ones block the merge
315- } else {
316- do not block the merge
317- }
318- </dd>
319- </dl>
303+ <section aria-labelledby="r-files" class="r-files">
304+ <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd></h2>
305+ @FileList(p.Files)
320306 </section>
307+ @MRChecks(p.Checks)
321308 <section aria-labelledby="r-commits">
322309 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
323310 <ul class="pcommits">
@@ -344,74 +331,125 @@ templ mrRail(p MRPage) {
344331 <p class="note">{ p.CommitsNote }</p>
345332 }
346333 </section>
347- <section aria-labelledby="r-files">
348- <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
349- @FileList(p.Files)
350- </section>
351-}
352-
353-// mergeBox is the first block of the rail of an open request, for the owner:
354-// the merge form (it carries the head that was read), the terminal lines, and
355-// the other things one does with the request.
356-templ mergeBox(p MRPage) {
357- <section class="decide" aria-labelledby="r-merge" data-state="open">
358- <h2 id="r-merge">Merge</h2>
334+ <section aria-labelledby="r-settings">
335+ <h2 id="r-settings">Settings</h2>
359336 <dl class="kv">
360- <dt>head</dt>
337+ <dt>modes</dt>
338+ <dd>{ strings.Join(p.Modes, ", ") }</dd>
339+ <dt>checks</dt>
361340 <dd>
362- if p.Box.HeadGone {
363- gone: the source branch was deleted
341+ if p.ChecksBlock {
342+ failing ones block the merge
364343 } else {
365- { p.HeadShort }
344+ do not block the merge
366345 }
367346 </dd>
368- <dt>into</dt>
369- <dd>{ p.Target }</dd>
370347 </dl>
371- if p.Box.ReadyAction != "" {
372- <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
373- <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
374- <button type="submit" class="btn primary" title="remove the draft prefix from the title">Ready for review</button>
375- </form>
376- } else if !p.Box.HeadGone {
377- <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
378- <input type="hidden" name="head_id" value={ p.Box.Head }/>
379- <label class="sr" for="merge-mode">merge mode</label>
380- <select id="merge-mode" name="mode">
381- for _, m := range p.Box.Modes {
382- <option value={ m.Value }>{ m.Label }</option>
383- }
384- </select>
385- if p.Box.CanDeleteBranch {
386- <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge</label>
387- }
388- <div class="d-btns">
389- <button type="submit" class="btn primary">Merge</button>
390- </div>
391- </form>
348+ </section>
349+}
350+
351+// mrBar is the bar above the diff, which follows the review: the totals, the
352+// layout of the diff, the state of the checks and, for the owner of an open
353+// request, the merge menu and the other actions. Each menu is a <details>: it
354+// works without scripts; with them, M and the keys of the menu drive it.
355+templ mrBar(p MRPage) {
356+ <div class="diff-tools mr-bar" role="toolbar" aria-label="Diff view and merge">
357+ <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
358+ @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
359+ @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
360+ @collapseButton()
361+ <span class="grow"></span>
362+ @MRChecksSum(p.ChecksSum)
363+ if p.Box != nil {
364+ @mergeMenu(p)
365+ @moreMenu(p)
392366 }
393- <div class="d-btns">
367+ </div>
368+}
369+
370+// mergeMenu is the merge of an open request, for the owner: the form (it
371+// carries the head that was read), or "Ready for review" for a draft. The
372+// mode and the branch deletion have keys (1-3, D) while it is open.
373+templ mergeMenu(p MRPage) {
374+ <details class="menu mmenu" id="merge-menu" data-state="open">
375+ <summary class="btn primary" aria-keyshortcuts="Shift+M">
376+ if p.Box.ReadyAction != "" {
377+ Draft
378+ } else {
379+ Merge
380+ }
381+ <kbd aria-hidden="true">M</kbd>
382+ </summary>
383+ <div class="menu-pop" role="group" aria-label="Merge">
384+ <dl class="kv">
385+ <dt>head</dt>
386+ <dd>
387+ if p.Box.HeadGone {
388+ gone: the source branch was deleted
389+ } else {
390+ { p.HeadShort }
391+ }
392+ </dd>
393+ <dt>into</dt>
394+ <dd>{ p.Target }</dd>
395+ </dl>
396+ if p.Box.ReadyAction != "" {
397+ <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
398+ <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
399+ <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>
400+ </form>
401+ } else if !p.Box.HeadGone {
402+ <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
403+ <input type="hidden" name="head_id" value={ p.Box.Head }/>
404+ <fieldset class="d-modes">
405+ <legend class="sr">merge mode</legend>
406+ for i, m := range p.Box.Modes {
407+ <label>
408+ <input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/>
409+ { m.Label }
410+ if i < 9 {
411+ <kbd aria-hidden="true">{ itoa(i + 1) }</kbd>
412+ }
413+ </label>
414+ }
415+ </fieldset>
416+ if p.Box.CanDeleteBranch {
417+ <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>
418+ }
419+ <div class="d-btns">
420+ <button type="submit" class="btn primary" data-default>Merge into { p.Target }</button>
421+ </div>
422+ </form>
423+ }
424+ </div>
425+ </details>
426+}
427+
428+// moreMenu is what else the owner does with an open request: mark the head
429+// read, close it, merge it from a terminal.
430+templ moreMenu(p MRPage) {
431+ <details class="menu omenu">
432+ <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>
433+ <div class="menu-pop" role="group" aria-label="More actions">
394434 if p.Box.CanMarkRead {
395435 <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
396436 <input type="hidden" name="head_id" value={ p.Box.Head }/>
397- <button type="submit" title="the next visit can show only what changed after this head" class="btn">Mark read</button>
437+ <button type="submit" title="the next visit can show only what changed after this head" class="btn" aria-keyshortcuts="Shift+R" data-read>Mark read <kbd aria-hidden="true">R</kbd></button>
398438 </form>
399439 }
400440 <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
401441 <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
402442 </form>
443+ if !p.Box.HeadGone && p.Box.ReadyAction == "" {
444+ <details class="d-or">
445+ <summary class="mute">or from a terminal</summary>
446+ @CloneBlock(p.Box.CLI)
447+ </details>
448+ }
403449 </div>
404- if !p.Box.HeadGone && p.Box.ReadyAction == "" {
405- <details class="d-or">
406- <summary class="mute">or from a terminal</summary>
407- @CloneBlock(p.Box.CLI)
408- </details>
409- }
410- </section>
450+ </details>
411451 }
412452
413-// Compare is a branch against the default branch: the commits it is ahead by,
414-// its diff when the API can give one, and the form that opens a merge request.
415453 templ Compare(l Layout, p ComparePage) {
416454 @Shell(l, compareRail(p)) {
417455 <header class="show mr-h">
A internal/adapters/web/mr_files.go +219 -0
@@ -0,0 +1,219 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "net/url"
10+ "strconv"
11+ "strings"
12+
13+ "github.com/a-h/templ"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// The diff of a merge request is read a window of files at a time. The page
20+// reads the files of one window (the first, or the one ?from= names) with
21+// their hunks and lists the others as placeholders; a placeholder holds the
22+// address of its window, which the browser fetches when it comes near the
23+// screen and swaps in (mr.js). The window carries the base and the head the
24+// page shows: when they moved, the use case refuses (ErrConflict) and the
25+// placeholder offers to reload instead of mixing two diffs in one page.
26+
27+// MRFilesPage is a window of files of a merge request, for /files: the files
28+// of the window, rendered like in the page, and nothing else.
29+type MRFilesPage struct {
30+ Files []DiffFile
31+ DiffView
32+}
33+
34+// windowStart is the first file of the window that holds file i.
35+func windowStart(i int) int { return i - i%mrWindow }
36+
37+// withFrom adds the window of a page to its address: the page of the files
38+// [from, from+mrWindow) when from is not the first window.
39+func withFrom(href string, from int) string {
40+ if from <= 0 {
41+ return href
42+ }
43+ sep := "?"
44+ if strings.Contains(href, "?") {
45+ sep = "&"
46+ }
47+ return href + sep + "from=" + itoa(from)
48+}
49+
50+// formFrom reads the window a form was posted from (a hidden field "from"):
51+// 0 when absent or not a window start, so that a redirect never carries
52+// anything else.
53+func formFrom(r *http.Request) int {
54+ s := r.PostFormValue("from")
55+ n, err := strconv.Atoi(s)
56+ if err != nil || n <= 0 || n%mrWindow != 0 || strconv.Itoa(n) != s {
57+ return 0
58+ }
59+ return n
60+}
61+
62+// parseFrom reads ?from=: the start of a window, 0 when absent. Anything else
63+// (not a number, negative, not a window start) is not a page.
64+func parseFrom(r *http.Request) (int, error) {
65+ s := r.URL.Query().Get("from")
66+ if s == "" {
67+ return 0, nil
68+ }
69+ n, err := strconv.Atoi(s)
70+ if err != nil || n < 0 || n%mrWindow != 0 || strconv.Itoa(n) != s {
71+ return 0, errNotFound
72+ }
73+ return n, nil
74+}
75+
76+// lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows
77+// a placeholder that loads their window. src is the URL of /files with the
78+// base and the head of the diff; self the page, for the links of a page
79+// without scripts.
80+func lazyFiles(files []DiffFile, from int, src string) {
81+ for i := range files {
82+ f := &files[i]
83+ if i >= from && i < from+mrWindow {
84+ continue
85+ }
86+ f.Lazy = true
87+ f.From = windowStart(i)
88+ f.Src = src + "&from=" + itoa(f.From)
89+ f.Height = lazyHeight(f.Add + f.Del)
90+ f.Open = !f.Bin && f.Add+f.Del > 0
91+ }
92+}
93+
94+// lazyHeight is the size class of the placeholder of a file with n changed
95+// lines: about the room its diff takes, so that the page does not jump much
96+// when it comes in.
97+func lazyHeight(n int) int {
98+ switch {
99+ case n <= 10:
100+ return 1
101+ case n <= 50:
102+ return 2
103+ case n <= 200:
104+ return 3
105+ default:
106+ return 4
107+ }
108+}
109+
110+// filesSrc is the address of the windows of a diff.
111+func filesSrc(repo string, n int64, since bool, base, head string) string {
112+ q := url.Values{}
113+ if since {
114+ q.Set("since", "1")
115+ }
116+ q.Set("base", base)
117+ q.Set("head", head)
118+ return mrHref(repo, n, "files") + "?" + q.Encode()
119+}
120+
121+// countNotes sets, on each file, the number of comments on its lines and the
122+// ids of those comments.
123+func countNotes(files []DiffFile, comments []domain.Comment) {
124+ at := make(map[string]int, len(files))
125+ for i, f := range files {
126+ at[f.Key] = i
127+ }
128+ ids := make([][]string, len(files))
129+ for _, c := range comments {
130+ i, ok := at[c.Path]
131+ if c.Path == "" || !ok {
132+ continue
133+ }
134+ files[i].Notes++
135+ if !c.Resolved {
136+ files[i].OpenNotes++
137+ }
138+ ids[i] = append(ids[i], "c"+strconv.FormatInt(c.ID, 10))
139+ }
140+ for i := range files {
141+ files[i].Comments = strings.Join(ids[i], " ")
142+ }
143+}
144+
145+// splitPath is the last element of a path and the directory before it.
146+func splitPath(p string) (name, dir string) {
147+ i := strings.LastIndexByte(p, '/')
148+ if i < 0 {
149+ return visibleText(p), ""
150+ }
151+ return visibleText(p[i+1:]), visibleText(p[:i])
152+}
153+
154+// mrFiles is GET /<repo>/merge-requests/<n>/files?from=&base=&head=[&since=1]:
155+// the files of a window with their hunks and their comments, for a
156+// placeholder of the page. A base or a head that is not the one of the diff
157+// any more answers 409: the page says to reload.
158+//
159+//hexcheck:ignore suspicious-business-logic-in-adapter builds a window of the merge request page from the use case results; no rule is decided here
160+func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
161+ n, err := mrNumber(r)
162+ if err != nil {
163+ return nil, 0, err
164+ }
165+ q := r.URL.Query()
166+ since := q.Get("since") == "1"
167+ if since && !SignedIn(ctx) {
168+ return nil, 0, errNotFound
169+ }
170+ from, err := parseFrom(r)
171+ if err != nil {
172+ return nil, 0, err
173+ }
174+ base, errB := domain.ParseObjectID(q.Get("base"))
175+ head, errH := domain.ParseObjectID(q.Get("head"))
176+ if errB != nil || errH != nil {
177+ return nil, 0, errNotFound
178+ }
179+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
180+ if err != nil {
181+ return nil, 0, err
182+ }
183+ res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
184+ SinceLastRead: since, DiffOnly: true, Base: base, Head: head,
185+ Files: &ports.FileWindow{From: from, To: from + mrWindow},
186+ })
187+ if errors.Is(err, domain.ErrConflict) {
188+ return MRFilesMoved(), http.StatusConflict, nil
189+ }
190+ if err != nil {
191+ return nil, 0, err
192+ }
193+ if from >= len(res.Diff.Files) {
194+ return nil, 0, errNotFound
195+ }
196+ m := res.MergeRequest
197+ signed := SignedIn(ctx)
198+ canWrite := signed && m.State == domain.MRStateOpen && !since
199+ p := MRPage{Files: diffFiles(res.Diff.Files), LooseInFiles: true}
200+ s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
201+ window := p.Files[from:min(from+mrWindow, len(p.Files))]
202+ // The layouts follow the page: it sends ?view= (the layout shown) and
203+ // ?layouts=both when it holds both layouts and switches them itself, so
204+ // that every window of the page renders the same way.
205+ self := mrHref(d.name(), n)
206+ if since {
207+ self = mrHref(d.name(), n, "since")
208+ }
209+ lines, _ := diffLines(window)
210+ v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
211+ switch r.URL.Query().Get("layouts") {
212+ case "both":
213+ v.Both = true
214+ case "one":
215+ v.Both = false
216+ }
217+ v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
218+ return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil
219+}
M internal/adapters/web/mr_post.go +2 -0
@@ -181,6 +181,7 @@ func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string
181181 if err != nil {
182182 return "", back, err
183183 }
184+ back = withFrom(back, formFrom(r))
184185 c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params)
185186 if err != nil {
186187 return "", back, err
@@ -209,6 +210,7 @@ func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string
209210 // An id this server never issued is 0: the use case answers not found,
210211 // after the repository and the scope were checked, as for any unknown
211212 // comment.
213+ back = withFrom(back, formFrom(r))
212214 c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved)
213215 if err != nil {
214216 return "", back, err
M internal/adapters/web/mr_templ.go +767 -574 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
@@ -203,69 +203,92 @@ func Shell(l Layout, rail templ.Component) templ.Component {
203203 if templ_7745c5c3_Err != nil {
204204 return templ_7745c5c3_Err
205205 }
206- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" defer></script></head><body data-ref=\"")
206+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" defer></script>")
207207 if templ_7745c5c3_Err != nil {
208208 return templ_7745c5c3_Err
209209 }
210- var templ_7745c5c3_Var15 string
211- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath)
210+ if l.MR {
211+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<script src=\"")
212+ if templ_7745c5c3_Err != nil {
213+ return templ_7745c5c3_Err
214+ }
215+ var templ_7745c5c3_Var15 string
216+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.MR)
217+ if templ_7745c5c3_Err != nil {
218+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 33, Col: 29}
219+ }
220+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
221+ if templ_7745c5c3_Err != nil {
222+ return templ_7745c5c3_Err
223+ }
224+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" defer></script>")
225+ if templ_7745c5c3_Err != nil {
226+ return templ_7745c5c3_Err
227+ }
228+ }
229+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</head><body data-ref=\"")
230+ if templ_7745c5c3_Err != nil {
231+ return templ_7745c5c3_Err
232+ }
233+ var templ_7745c5c3_Var16 string
234+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath)
212235 if templ_7745c5c3_Err != nil {
213- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 34, Col: 23}
236+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 37, Col: 23}
214237 }
215- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
238+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
216239 if templ_7745c5c3_Err != nil {
217240 return templ_7745c5c3_Err
218241 }
219- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" data-sha=\"")
242+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" data-sha=\"")
220243 if templ_7745c5c3_Err != nil {
221244 return templ_7745c5c3_Err
222245 }
223- var templ_7745c5c3_Var16 string
224- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA)
246+ var templ_7745c5c3_Var17 string
247+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA)
225248 if templ_7745c5c3_Err != nil {
226- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 35, Col: 19}
249+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 38, Col: 19}
227250 }
228- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
251+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
229252 if templ_7745c5c3_Err != nil {
230253 return templ_7745c5c3_Err
231254 }
232- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" data-sel=\"\" data-nav=\"\"")
255+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-sel=\"\" data-nav=\"\"")
233256 if templ_7745c5c3_Err != nil {
234257 return templ_7745c5c3_Err
235258 }
236259 if l.PathsURL != "" {
237- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " data-paths=\"")
260+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " data-paths=\"")
238261 if templ_7745c5c3_Err != nil {
239262 return templ_7745c5c3_Err
240263 }
241- var templ_7745c5c3_Var17 string
242- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL)
264+ var templ_7745c5c3_Var18 string
265+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL)
243266 if templ_7745c5c3_Err != nil {
244- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 39, Col: 27}
267+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 42, Col: 27}
245268 }
246- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
269+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
247270 if templ_7745c5c3_Err != nil {
248271 return templ_7745c5c3_Err
249272 }
250- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" data-blob=\"")
273+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-blob=\"")
251274 if templ_7745c5c3_Err != nil {
252275 return templ_7745c5c3_Err
253276 }
254- var templ_7745c5c3_Var18 string
255- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase)
277+ var templ_7745c5c3_Var19 string
278+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase)
256279 if templ_7745c5c3_Err != nil {
257- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 40, Col: 26}
280+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 43, Col: 26}
258281 }
259- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
282+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
260283 if templ_7745c5c3_Err != nil {
261284 return templ_7745c5c3_Err
262285 }
263- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"")
286+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"")
264287 if templ_7745c5c3_Err != nil {
265288 return templ_7745c5c3_Err
266289 }
267290 }
268- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "><a class=\"skip\" href=\"#main\">skip to content</a>")
291+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "><a class=\"skip\" href=\"#main\">skip to content</a>")
269292 if templ_7745c5c3_Err != nil {
270293 return templ_7745c5c3_Err
271294 }
@@ -273,48 +296,48 @@ func Shell(l Layout, rail templ.Component) templ.Component {
273296 if templ_7745c5c3_Err != nil {
274297 return templ_7745c5c3_Err
275298 }
276- var templ_7745c5c3_Var19 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)}
277- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...)
299+ var templ_7745c5c3_Var20 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)}
300+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
278301 if templ_7745c5c3_Err != nil {
279302 return templ_7745c5c3_Err
280303 }
281- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"")
304+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"")
282305 if templ_7745c5c3_Err != nil {
283306 return templ_7745c5c3_Err
284307 }
285- var templ_7745c5c3_Var20 string
286- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
308+ var templ_7745c5c3_Var21 string
309+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var20).String())
287310 if templ_7745c5c3_Err != nil {
288311 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 1, Col: 0}
289312 }
290- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
313+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
291314 if templ_7745c5c3_Err != nil {
292315 return templ_7745c5c3_Err
293316 }
294- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">")
317+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
295318 if templ_7745c5c3_Err != nil {
296319 return templ_7745c5c3_Err
297320 }
298321 if l.Lede != "" {
299- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">")
322+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">")
300323 if templ_7745c5c3_Err != nil {
301324 return templ_7745c5c3_Err
302325 }
303- var templ_7745c5c3_Var21 string
304- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede)
326+ var templ_7745c5c3_Var22 string
327+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede)
305328 if templ_7745c5c3_Err != nil {
306- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 47, Col: 81}
329+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 50, Col: 81}
307330 }
308- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
331+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
309332 if templ_7745c5c3_Err != nil {
310333 return templ_7745c5c3_Err
311334 }
312- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p></section>")
335+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></section>")
313336 if templ_7745c5c3_Err != nil {
314337 return templ_7745c5c3_Err
315338 }
316339 }
317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<main id=\"main\" tabindex=\"-1\">")
340+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<main id=\"main\" tabindex=\"-1\">")
318341 if templ_7745c5c3_Err != nil {
319342 return templ_7745c5c3_Err
320343 }
@@ -322,12 +345,12 @@ func Shell(l Layout, rail templ.Component) templ.Component {
322345 if templ_7745c5c3_Err != nil {
323346 return templ_7745c5c3_Err
324347 }
325- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</main>")
348+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</main>")
326349 if templ_7745c5c3_Err != nil {
327350 return templ_7745c5c3_Err
328351 }
329352 if l.HasRail && rail != nil {
330- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<aside class=\"rail\" aria-label=\"Context\">")
353+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<aside class=\"rail\" aria-label=\"Context\">")
331354 if templ_7745c5c3_Err != nil {
332355 return templ_7745c5c3_Err
333356 }
@@ -335,12 +358,12 @@ func Shell(l Layout, rail templ.Component) templ.Component {
335358 if templ_7745c5c3_Err != nil {
336359 return templ_7745c5c3_Err
337360 }
338- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</aside>")
361+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</aside>")
339362 if templ_7745c5c3_Err != nil {
340363 return templ_7745c5c3_Err
341364 }
342365 }
343- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>")
366+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")
344367 if templ_7745c5c3_Err != nil {
345368 return templ_7745c5c3_Err
346369 }
@@ -352,7 +375,7 @@ func Shell(l Layout, rail templ.Component) templ.Component {
352375 if templ_7745c5c3_Err != nil {
353376 return templ_7745c5c3_Err
354377 }
355- templ_7745c5c3_Err = OverlayHelp().Render(ctx, templ_7745c5c3_Buffer)
378+ templ_7745c5c3_Err = OverlayHelp(l.MR).Render(ctx, templ_7745c5c3_Buffer)
356379 if templ_7745c5c3_Err != nil {
357380 return templ_7745c5c3_Err
358381 }
@@ -368,7 +391,7 @@ func Shell(l Layout, rail templ.Component) templ.Component {
368391 if templ_7745c5c3_Err != nil {
369392 return templ_7745c5c3_Err
370393 }
371- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</body></html>")
394+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</body></html>")
372395 if templ_7745c5c3_Err != nil {
373396 return templ_7745c5c3_Err
374397 }
@@ -394,324 +417,324 @@ func Header(l Layout) templ.Component {
394417 }()
395418 }
396419 ctx = templ.InitializeContext(ctx)
397- templ_7745c5c3_Var22 := templ.GetChildren(ctx)
398- if templ_7745c5c3_Var22 == nil {
399- templ_7745c5c3_Var22 = templ.NopComponent
420+ templ_7745c5c3_Var23 := templ.GetChildren(ctx)
421+ if templ_7745c5c3_Var23 == nil {
422+ templ_7745c5c3_Var23 = templ.NopComponent
400423 }
401424 ctx = templ.ClearChildren(ctx)
402- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")
425+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")
403426 if templ_7745c5c3_Err != nil {
404427 return templ_7745c5c3_Err
405428 }
406429 if len(l.Crumbs) > 0 {
407- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
430+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
408431 if templ_7745c5c3_Err != nil {
409432 return templ_7745c5c3_Err
410433 }
411434 for _, c := range l.Crumbs {
412- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<li>")
435+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<li>")
413436 if templ_7745c5c3_Err != nil {
414437 return templ_7745c5c3_Err
415438 }
416439 if c.Href != "" {
417- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<a href=\"")
440+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<a href=\"")
418441 if templ_7745c5c3_Err != nil {
419442 return templ_7745c5c3_Err
420443 }
421- var templ_7745c5c3_Var23 templ.SafeURL
422- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
444+ var templ_7745c5c3_Var24 templ.SafeURL
445+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
423446 if templ_7745c5c3_Err != nil {
424- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 83, Col: 38}
447+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 86, Col: 38}
425448 }
426- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
449+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
427450 if templ_7745c5c3_Err != nil {
428451 return templ_7745c5c3_Err
429452 }
430- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\"")
453+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"")
431454 if templ_7745c5c3_Err != nil {
432455 return templ_7745c5c3_Err
433456 }
434457 if c.Current {
435- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " aria-current=\"page\"")
458+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " aria-current=\"page\"")
436459 if templ_7745c5c3_Err != nil {
437460 return templ_7745c5c3_Err
438461 }
439462 }
440- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">")
463+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">")
441464 if templ_7745c5c3_Err != nil {
442465 return templ_7745c5c3_Err
443466 }
444- var templ_7745c5c3_Var24 string
445- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
467+ var templ_7745c5c3_Var25 string
468+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
446469 if templ_7745c5c3_Err != nil {
447- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 87, Col: 19}
470+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 90, Col: 19}
448471 }
449- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
472+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
450473 if templ_7745c5c3_Err != nil {
451474 return templ_7745c5c3_Err
452475 }
453- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</a>")
476+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</a>")
454477 if templ_7745c5c3_Err != nil {
455478 return templ_7745c5c3_Err
456479 }
457480 } else {
458- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span")
481+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span")
459482 if templ_7745c5c3_Err != nil {
460483 return templ_7745c5c3_Err
461484 }
462485 if c.Current {
463- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " aria-current=\"page\"")
486+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " aria-current=\"page\"")
464487 if templ_7745c5c3_Err != nil {
465488 return templ_7745c5c3_Err
466489 }
467490 }
468- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">")
491+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">")
469492 if templ_7745c5c3_Err != nil {
470493 return templ_7745c5c3_Err
471494 }
472- var templ_7745c5c3_Var25 string
473- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
495+ var templ_7745c5c3_Var26 string
496+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
474497 if templ_7745c5c3_Err != nil {
475- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 93, Col: 19}
498+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 96, Col: 19}
476499 }
477- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
500+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
478501 if templ_7745c5c3_Err != nil {
479502 return templ_7745c5c3_Err
480503 }
481- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span>")
504+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span>")
482505 if templ_7745c5c3_Err != nil {
483506 return templ_7745c5c3_Err
484507 }
485508 }
486- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</li>")
509+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</li>")
487510 if templ_7745c5c3_Err != nil {
488511 return templ_7745c5c3_Err
489512 }
490513 }
491- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</ol></nav>")
514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</ol></nav>")
492515 if templ_7745c5c3_Err != nil {
493516 return templ_7745c5c3_Err
494517 }
495518 }
496- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"hdr-acts\">")
519+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<div class=\"hdr-acts\">")
497520 if templ_7745c5c3_Err != nil {
498521 return templ_7745c5c3_Err
499522 }
500523 if l.Ref != "" {
501- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<a class=\"btn ref\" href=\"")
524+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<a class=\"btn ref\" href=\"")
502525 if templ_7745c5c3_Err != nil {
503526 return templ_7745c5c3_Err
504527 }
505- var templ_7745c5c3_Var26 templ.SafeURL
506- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
528+ var templ_7745c5c3_Var27 templ.SafeURL
529+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
507530 if templ_7745c5c3_Err != nil {
508- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 104, Col: 52}
531+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 107, Col: 52}
509532 }
510- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
533+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
511534 if templ_7745c5c3_Err != nil {
512535 return templ_7745c5c3_Err
513536 }
514- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
537+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
515538 if templ_7745c5c3_Err != nil {
516539 return templ_7745c5c3_Err
517540 }
518- var templ_7745c5c3_Var27 string
519- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
541+ var templ_7745c5c3_Var28 string
542+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
520543 if templ_7745c5c3_Err != nil {
521- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 110, Col: 36}
544+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 113, Col: 36}
522545 }
523- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
546+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
524547 if templ_7745c5c3_Err != nil {
525548 return templ_7745c5c3_Err
526549 }
527- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</span> <span class=\"sha\">")
550+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span> <span class=\"sha\">")
528551 if templ_7745c5c3_Err != nil {
529552 return templ_7745c5c3_Err
530553 }
531- var templ_7745c5c3_Var28 string
532- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
554+ var templ_7745c5c3_Var29 string
555+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
533556 if templ_7745c5c3_Err != nil {
534- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 111, Col: 33}
557+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 114, Col: 33}
535558 }
536- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
559+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
537560 if templ_7745c5c3_Err != nil {
538561 return templ_7745c5c3_Err
539562 }
540- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</span></a> ")
563+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span></a> ")
541564 if templ_7745c5c3_Err != nil {
542565 return templ_7745c5c3_Err
543566 }
544567 }
545- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
568+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
546569 if templ_7745c5c3_Err != nil {
547570 return templ_7745c5c3_Err
548571 }
549572 if SignedIn(ctx) {
550- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")
573+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")
551574 if templ_7745c5c3_Err != nil {
552575 return templ_7745c5c3_Err
553576 }
554- var templ_7745c5c3_Var29 string
555- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
577+ var templ_7745c5c3_Var30 string
578+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
556579 if templ_7745c5c3_Err != nil {
557- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 130, Col: 123}
580+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 133, Col: 123}
558581 }
559- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
582+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
560583 if templ_7745c5c3_Err != nil {
561584 return templ_7745c5c3_Err
562585 }
563- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span> ")
586+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</span> ")
564587 if templ_7745c5c3_Err != nil {
565588 return templ_7745c5c3_Err
566589 }
567590 if l.Passkeys {
568- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")
591+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")
569592 if templ_7745c5c3_Err != nil {
570593 return templ_7745c5c3_Err
571594 }
572595 }
573- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")
596+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")
574597 if templ_7745c5c3_Err != nil {
575598 return templ_7745c5c3_Err
576599 }
577600 } else if l.Passkeys {
578- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")
601+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")
579602 if templ_7745c5c3_Err != nil {
580603 return templ_7745c5c3_Err
581604 }
582605 }
583- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</div></div>")
606+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div></div>")
584607 if templ_7745c5c3_Err != nil {
585608 return templ_7745c5c3_Err
586609 }
587610 if len(l.Sections) > 0 {
588- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
611+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
589612 if templ_7745c5c3_Err != nil {
590613 return templ_7745c5c3_Err
591614 }
592615 for _, s := range l.Sections {
593- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<li><a href=\"")
616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<li><a href=\"")
594617 if templ_7745c5c3_Err != nil {
595618 return templ_7745c5c3_Err
596619 }
597- var templ_7745c5c3_Var30 templ.SafeURL
598- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
620+ var templ_7745c5c3_Var31 templ.SafeURL
621+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
599622 if templ_7745c5c3_Err != nil {
600- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 147, Col: 36}
623+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 150, Col: 36}
601624 }
602- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
625+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
603626 if templ_7745c5c3_Err != nil {
604627 return templ_7745c5c3_Err
605628 }
606- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" data-key=\"")
629+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" data-key=\"")
607630 if templ_7745c5c3_Err != nil {
608631 return templ_7745c5c3_Err
609632 }
610- var templ_7745c5c3_Var31 string
611- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
633+ var templ_7745c5c3_Var32 string
634+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
612635 if templ_7745c5c3_Err != nil {
613- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 148, Col: 24}
636+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 151, Col: 24}
614637 }
615- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
638+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
616639 if templ_7745c5c3_Err != nil {
617640 return templ_7745c5c3_Err
618641 }
619- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" aria-keyshortcuts=\"")
642+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" aria-keyshortcuts=\"")
620643 if templ_7745c5c3_Err != nil {
621644 return templ_7745c5c3_Err
622645 }
623- var templ_7745c5c3_Var32 string
624- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
646+ var templ_7745c5c3_Var33 string
647+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
625648 if templ_7745c5c3_Err != nil {
626- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 149, Col: 33}
649+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 152, Col: 33}
627650 }
628- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
651+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
629652 if templ_7745c5c3_Err != nil {
630653 return templ_7745c5c3_Err
631654 }
632- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\"")
655+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"")
633656 if templ_7745c5c3_Err != nil {
634657 return templ_7745c5c3_Err
635658 }
636659 if s.Current {
637- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " aria-current=\"page\"")
660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " aria-current=\"page\"")
638661 if templ_7745c5c3_Err != nil {
639662 return templ_7745c5c3_Err
640663 }
641664 }
642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">")
665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, ">")
643666 if templ_7745c5c3_Err != nil {
644667 return templ_7745c5c3_Err
645668 }
646- var templ_7745c5c3_Var33 string
647- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
669+ var templ_7745c5c3_Var34 string
670+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
648671 if templ_7745c5c3_Err != nil {
649- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 154, Col: 17}
672+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 157, Col: 17}
650673 }
651- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
674+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
652675 if templ_7745c5c3_Err != nil {
653676 return templ_7745c5c3_Err
654677 }
655- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " ")
678+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " ")
656679 if templ_7745c5c3_Err != nil {
657680 return templ_7745c5c3_Err
658681 }
659682 if s.Count != "" {
660- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<span class=\"count\">")
683+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<span class=\"count\">")
661684 if templ_7745c5c3_Err != nil {
662685 return templ_7745c5c3_Err
663686 }
664- var templ_7745c5c3_Var34 string
665- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
687+ var templ_7745c5c3_Var35 string
688+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
666689 if templ_7745c5c3_Err != nil {
667- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 156, Col: 38}
690+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 159, Col: 38}
668691 }
669- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
692+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
670693 if templ_7745c5c3_Err != nil {
671694 return templ_7745c5c3_Err
672695 }
673- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<span class=\"sr\">")
696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span class=\"sr\">")
674697 if templ_7745c5c3_Err != nil {
675698 return templ_7745c5c3_Err
676699 }
677- var templ_7745c5c3_Var35 string
678- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
700+ var templ_7745c5c3_Var36 string
701+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
679702 if templ_7745c5c3_Err != nil {
680- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 156, Col: 66}
703+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 159, Col: 66}
681704 }
682- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
705+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
683706 if templ_7745c5c3_Err != nil {
684707 return templ_7745c5c3_Err
685708 }
686- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</span></span> ")
709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span></span> ")
687710 if templ_7745c5c3_Err != nil {
688711 return templ_7745c5c3_Err
689712 }
690713 }
691- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<kbd aria-hidden=\"true\">")
714+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<kbd aria-hidden=\"true\">")
692715 if templ_7745c5c3_Err != nil {
693716 return templ_7745c5c3_Err
694717 }
695- var templ_7745c5c3_Var36 string
696- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
718+ var templ_7745c5c3_Var37 string
719+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
697720 if templ_7745c5c3_Err != nil {
698- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 158, Col: 39}
721+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 161, Col: 39}
699722 }
700- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
723+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
701724 if templ_7745c5c3_Err != nil {
702725 return templ_7745c5c3_Err
703726 }
704- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</kbd></a></li>")
727+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</kbd></a></li>")
705728 if templ_7745c5c3_Err != nil {
706729 return templ_7745c5c3_Err
707730 }
708731 }
709- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</ul></nav>")
732+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</ul></nav>")
710733 if templ_7745c5c3_Err != nil {
711734 return templ_7745c5c3_Err
712735 }
713736 }
714- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</header>")
737+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</header>")
715738 if templ_7745c5c3_Err != nil {
716739 return templ_7745c5c3_Err
717740 }
@@ -737,38 +760,38 @@ func Footer(l Layout) templ.Component {
737760 }()
738761 }
739762 ctx = templ.InitializeContext(ctx)
740- templ_7745c5c3_Var37 := templ.GetChildren(ctx)
741- if templ_7745c5c3_Var37 == nil {
742- templ_7745c5c3_Var37 = templ.NopComponent
763+ templ_7745c5c3_Var38 := templ.GetChildren(ctx)
764+ if templ_7745c5c3_Var38 == nil {
765+ templ_7745c5c3_Var38 = templ.NopComponent
743766 }
744767 ctx = templ.ClearChildren(ctx)
745- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<footer class=\"foot\"><span>ermite ")
768+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<footer class=\"foot\"><span>ermite ")
746769 if templ_7745c5c3_Err != nil {
747770 return templ_7745c5c3_Err
748771 }
749- var templ_7745c5c3_Var38 string
750- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
772+ var templ_7745c5c3_Var39 string
773+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
751774 if templ_7745c5c3_Err != nil {
752- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 172, Col: 26}
775+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 175, Col: 26}
753776 }
754- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
777+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
755778 if templ_7745c5c3_Err != nil {
756779 return templ_7745c5c3_Err
757780 }
758- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> <a href=\"")
781+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span> <a href=\"")
759782 if templ_7745c5c3_Err != nil {
760783 return templ_7745c5c3_Err
761784 }
762- var templ_7745c5c3_Var39 templ.SafeURL
763- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
785+ var templ_7745c5c3_Var40 templ.SafeURL
786+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
764787 if templ_7745c5c3_Err != nil {
765- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 173, Col: 38}
788+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 38}
766789 }
767- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
790+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
768791 if templ_7745c5c3_Err != nil {
769792 return templ_7745c5c3_Err
770793 }
771- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>")
794+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>")
772795 if templ_7745c5c3_Err != nil {
773796 return templ_7745c5c3_Err
774797 }
@@ -796,12 +819,12 @@ func Hint(l Layout) templ.Component {
796819 }()
797820 }
798821 ctx = templ.InitializeContext(ctx)
799- templ_7745c5c3_Var40 := templ.GetChildren(ctx)
800- if templ_7745c5c3_Var40 == nil {
801- templ_7745c5c3_Var40 = templ.NopComponent
822+ templ_7745c5c3_Var41 := templ.GetChildren(ctx)
823+ if templ_7745c5c3_Var41 == nil {
824+ templ_7745c5c3_Var41 = templ.NopComponent
802825 }
803826 ctx = templ.ClearChildren(ctx)
804- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
827+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
805828 if templ_7745c5c3_Err != nil {
806829 return templ_7745c5c3_Err
807830 }
@@ -809,7 +832,7 @@ func Hint(l Layout) templ.Component {
809832 if templ_7745c5c3_Err != nil {
810833 return templ_7745c5c3_Err
811834 }
812- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</div>")
835+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</div>")
813836 if templ_7745c5c3_Err != nil {
814837 return templ_7745c5c3_Err
815838 }
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