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
@@ -107,16 +107,22 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
107 stats.Deletions += entries[i].del107 stats.Deletions += entries[i].del
108 }108 }
109 var diff domain.Diff109 var diff domain.Diff
110- if len(entries) > limits.MaxFiles {110+ if limits.MaxFiles > 0 && len(entries) > limits.MaxFiles {
111 entries = entries[:limits.MaxFiles]111 entries = entries[:limits.MaxFiles]
112 diff.Truncated = true112 diff.Truncated = true
113 }113 }
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+ }
114 120
115 // The patch holds one section per file, two for a type change, in the121 // The patch holds one section per file, two for a type change, in the
116 // order of the entries. Entries without lines to show (binary, pure122 // order of the entries. Entries without lines to show (binary, pure
117 // rename, mode change) have a section without hunks.123 // rename, mode change) have a section without hunks.
118 lastRead := -1124 lastRead := -1
119- for i := range entries {125+ for i := from; i < to; i++ {
120 if wantsHunks(&entries[i], limits, limits.MaxTotalLines, limits.MaxTotalBytes) {126 if wantsHunks(&entries[i], limits, limits.MaxTotalLines, limits.MaxTotalBytes) {
121 lastRead = i127 lastRead = i
122 }128 }
@@ -136,8 +142,9 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
136 if e.typeChange {142 if e.typeChange {
137 sections = 2143 sections = 2
138 }144 }
145+ inWindow := i >= from && i < to
139 switch {146 switch {
140- case !changedLines(e):147+ case !inWindow, !changedLines(e):
141 // Nothing to show.148 // Nothing to show.
142 case !wantsHunks(e, limits, budget, pr.bytesLeft):149 case !wantsHunks(e, limits, budget, pr.bytesLeft):
143 e.file.Truncated = true150 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+}
@@ -484,3 +484,179 @@ func TestGetCommitStopsGitEarly(t *testing.T) {
484 assert.True(t, d.Diff.Files[1].Truncated)484 assert.True(t, d.Diff.Files[1].Truncated)
485 assert.Equal(t, 100005, d.Stats.Additions)485 assert.Equal(t, 100005, d.Stats.Additions)
486 }486 }
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 }
@@ -166,7 +166,7 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
166 if err != nil {166 if err != nil {
167 return nil, connectError(ctx, err)167 return nil, connectError(ctx, err)
168 }168 }
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()})
170 if err != nil {170 if err != nil {
171 return nil, connectError(ctx, err)171 return nil, connectError(ctx, err)
172 }172 }
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 {
@@ -212,7 +212,7 @@ func TestMergeRequestServiceGet(t *testing.T) {
212 t.Run(tt.name, func(t *testing.T) {212 t.Run(tt.name, func(t *testing.T) {
213 t.Parallel()213 t.Parallel()
214 mrs := mocks.NewMergeRequestService(t)214 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}).
216 Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once()216 Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once()
217 resp, err := (&mergeRequestService{mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))217 resp, err := (&mergeRequestService{mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))
218 if tt.wantCode != 0 {218 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">
@@ -423,6 +423,20 @@ templ CommitChecks(c ChecksSummary) {
423 }423 }
424 }424 }
425 425
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+
426 // MRChecks is the checks section of the rail of a merge request.440 // MRChecks is the checks section of the rail of a merge request.
427 templ MRChecks(c ChecksSection) {441 templ MRChecks(c ChecksSection) {
428 <section aria-labelledby="r-checks" id="mr-checks">442 <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>):
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
24 // Assets are the URLs of the assets a page loads. Their names carry a hash of24 // Assets are the URLs of the assets a page loads. Their names carry a hash of
25 // the content, so they can be cached for ever.25 // the content, so they can be cached for ever.
26 type Assets struct {26 type Assets struct {
27- CSS, HTMX, Live, SSE, App, Files, Passkey, Log, Icon string27+ CSS, HTMX, Live, SSE, App, Files, Passkey, Log, MR, Icon string
28 // Manifest is the web app manifest (install on a phone's home screen),28 // Manifest is the web app manifest (install on a phone's home screen),
29 // AppleIcon the home screen icon iOS reads from a link instead.29 // AppleIcon the home screen icon iOS reads from a link instead.
30 Manifest, AppleIcon string30 Manifest, AppleIcon string
@@ -62,6 +62,7 @@ var scripts = []struct {
62 {"files.js", func(a *Assets, u string) { a.Files = u }},62 {"files.js", func(a *Assets, u string) { a.Files = u }},
63 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},63 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
64 {"log.js", func(a *Assets, u string) { a.Log = u }},64 {"log.js", func(a *Assets, u string) { a.Log = u }},
65+ {"mr.js", func(a *Assets, u string) { a.MR = u }},
65 }66 }
66 67
67 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):68 // 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 {
@@ -80,10 +80,6 @@ type DiffHunk struct {
80 Split []SplitRow80 Split []SplitRow
81 }81 }
82 82
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-
87 // DiffFile is one changed file.83 // DiffFile is one changed file.
88 type DiffFile struct {84 type DiffFile struct {
89 ID string85 ID string
@@ -93,12 +89,48 @@ type DiffFile struct {
93 Key string89 Key string
94 Path string // display path, "old → new" for a rename90 Path string // display path, "old → new" for a rename
95 Add, Del int91 Add, Del int
96- Bar Bar92+ // Bin is true for a binary file: no lines to count.
93+ Bin bool
97 // Note is why there is no text to show, or what else changed: a binary94 // Note is why there is no text to show, or what else changed: a binary
98 // file, a mode change, a truncation, a similarity.95 // file, a mode change, a truncation, a similarity.
99 Note string96 Note string
100 Open bool97 Open bool
101 Hunks []DiffHunk98 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)
102 }134 }
103 135
104 func lineNumber(n int) string {136 func lineNumber(n int) string {
@@ -176,26 +208,6 @@ func pairLines(ls []DiffLine) []SplitRow {
176 return out208 return out
177 }209 }
178 210
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-
199 // statusLetter is the one-letter status of a file.211 // statusLetter is the one-letter status of a file.
200 func statusLetter(s domain.FileStatus) string {212 func statusLetter(s domain.FileStatus) string {
201 switch s {213 switch s {
@@ -213,6 +225,27 @@ func statusLetter(s domain.FileStatus) string {
213 return "M"225 return "M"
214 }226 }
215 227
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+
216 // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk.249 // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk.
217 func hunkHeader(h domain.Hunk) string {250 func hunkHeader(h domain.Hunk) string {
218 s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines)251 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 {
239 272
240 // diffFiles converts the files of a diff for the page.273 // diffFiles converts the files of a diff for the page.
241 func diffFiles(files []domain.FileDiff) []DiffFile {274 func diffFiles(files []domain.FileDiff) []DiffFile {
242- most := 0
243- for _, f := range files {
244- most = max(most, f.Additions+f.Deletions)
245- }
246 out := make([]DiffFile, 0, len(files))275 out := make([]DiffFile, 0, len(files))
247 for i, f := range files {276 for i, f := range files {
248 df := DiffFile{277 df := DiffFile{
249 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),278 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),
250- Add: f.Additions, Del: f.Deletions,279+ Add: f.Additions, Del: f.Deletions, Bin: f.Binary,
251- Bar: changeBar(f.Additions, f.Deletions, most),
252 // A file that is shown in full opens; a file with nothing to read280 // A file that is shown in full opens; a file with nothing to read
253 // (binary, truncated) stays closed under its note.281 // (binary, truncated) stays closed under its note.
254 Open: len(f.Hunks) > 0 && !f.Truncated,282 Open: len(f.Hunks) > 0 && !f.Truncated,
@@ -264,9 +292,8 @@ func diffFiles(files []domain.FileDiff) []DiffFile {
264 df.Path = visibleText(f.NewPath)292 df.Path = visibleText(f.NewPath)
265 }293 }
266 df.Note = fileNote(f)294 df.Note = fileNote(f)
267- if f.Binary {295+ df.Digits = gutterDigits(f.Hunks)
268- df.Bar = Bar{Rest: "bin"}296+ df.Name, df.Dir = splitPath(df.Key)
269- }
270 for _, h := range f.Hunks {297 for _, h := range f.Hunks {
271 dh := DiffHunk{Header: hunkHeader(h)}298 dh := DiffHunk{Header: hunkHeader(h)}
272 for _, l := range h.Lines {299 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) {
@@ -141,17 +141,39 @@ templ commitRail(p CommitPage) {
141 // FileList is the table of contents of a diff: status letter, path, +n -m141 // FileList is the table of contents of a diff: status letter, path, +n -m
142 // and an ASCII bar. The row of the file in view is marked.142 // and an ASCII bar. The row of the file in view is marked.
143 templ FileList(files []DiffFile) {143 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+ }
144 <ol class="files" aria-label="Changed files">157 <ol class="files" aria-label="Changed files">
145 for _, f := range files {158 for _, f := range files {
146 <li>159 <li>
147 <a160 <a
148 href={ templ.SafeURL("#" + f.ID) }161 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))"
150 >164 >
151- <span class="st" title={ f.Status }>{ f.Status }</span>165+ <span class="st" data-st={ f.Status } title={ statusName(f.Status) }>{ f.Status }</span>
152- <span class="p" title={ f.Path }>{ f.Path }</span>166+ <span class="nm"><span class="p">{ f.Name }</span>{ " " }<span class="d"><bdi>{ f.Dir }</bdi></span></span>
153- <span class="n"><span class="plus">+{ itoa(f.Add) }</span> <span class="minus">-{ itoa(f.Del) }</span></span>167+ if f.OpenNotes > 0 {
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>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+ }
155 </a>177 </a>
156 </li>178 </li>
157 }179 }
@@ -164,7 +186,7 @@ templ FileList(files []DiffFile) {
164 // colour alone.186 // colour alone.
165 templ DiffFileView(f DiffFile, p DiffView) {187 templ DiffFileView(f DiffFile, p DiffView) {
166 <details188 <details
167- class="dfile"189+ class={ "dfile", "lnw-" + itoa(f.Digits) }
168 id={ f.ID }190 id={ f.ID }
169 data-file191 data-file
170 if f.Open {192 if f.Open {
@@ -177,7 +199,14 @@ templ DiffFileView(f DiffFile, p DiffView) {
177 { " " }<span class="note">{ f.Note }</span>199 { " " }<span class="note">{ f.Note }</span>
178 }200 }
179 </summary>201 </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 {
181 <div class="dscroll">210 <div class="dscroll">
182 if p.ShowUnified {211 if p.ShowUnified {
183 <table class="dt uni" aria-label={ f.Path + ", unified" }>212 <table class="dt uni" aria-label={ f.Path + ", unified" }>
@@ -233,9 +262,30 @@ templ DiffFileView(f DiffFile, p DiffView) {
233 }262 }
234 </p>263 </p>
235 }264 }
265+ if len(f.Loose) > 0 {
266+ <div class="dloose">
267+ for _, c := range f.Loose {
268+ @commentView(c, "")
269+ }
270+ </div>
271+ }
236 </details>272 </details>
237 }273 }
238 274
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+
239 // splitSide is one half of a row of the split view; a side without a line is289 // splitSide is one half of a row of the split view; a side without a line is
240 // an empty cell.290 // an empty cell.
241 templ splitSide(ln *DiffLine, old bool) {291 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 {
@@ -85,15 +85,6 @@ func TestPairLines(t *testing.T) {
85 assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}, newN: 2}, ls[4])85 assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}, newN: 2}, ls[4])
86 }86 }
87 87
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-
97 func TestFileNotes(t *testing.T) {88 func TestFileNotes(t *testing.T) {
98 t.Parallel()89 t.Parallel()
99 tests := []struct {90 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
@@ -496,8 +496,13 @@ func (l *liveStream) mrChecks(ctx context.Context) ([]fragment, error) {
496 case err != nil:496 case err != nil:
497 return nil, err497 return nil, err
498 }498 }
499- f, err := renderFragment(ctx, "ci-run", MRChecks(checksSection(repo, l.s.checksFor(ctx, name, l.head))))499+ checks := l.s.checksFor(ctx, name, l.head)
500- return []fragment{f}, err500+ 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
501 }506 }
502 507
503 // recentActions is the "Recent actions" block of the summary rail, read again508 // 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+});
@@ -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 }
@@ -29,6 +29,9 @@ templ Keys() {
29 k-split from:body -> ermite.layout('split');29 k-split from:body -> ermite.layout('split');
30 k-unified from:body -> ermite.layout('unified');30 k-unified from:body -> ermite.layout('unified');
31 k-collapse from:body -> ermite.collapse();31 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()"
33 ></div>36 ></div>
34 }37 }
M internal/adapters/web/keys_templ.go +1 -1
@@ -34,7 +34,7 @@ func Keys() templ.Component {
3434 templ_7745c5c3_Var1 = templ.NopComponent
3535 }
3636 ctx = templ.ClearChildren(ctx)
37- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-permalink from:body -> ermite.permalink()\"></div>")
37+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles()\"></div>")
3838 if templ_7745c5c3_Err != nil {
3939 return templ_7745c5c3_Err
4040 }
@@ -34,7 +34,7 @@ func Keys() templ.Component {
34 templ_7745c5c3_Var1 = templ.NopComponent34 templ_7745c5c3_Var1 = templ.NopComponent
35 }35 }
36 ctx = templ.ClearChildren(ctx)36 ctx = templ.ClearChildren(ctx)
37- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-permalink from:body -> ermite.permalink()\"></div>")37+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles()\"></div>")
38 if templ_7745c5c3_Err != nil {38 if templ_7745c5c3_Err != nil {
39 return templ_7745c5c3_Err39 return templ_7745c5c3_Err
40 }40 }
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 }
@@ -28,6 +28,13 @@ import (
28 // GET /<repo>/merge-requests/<n> the request with its diff28 // GET /<repo>/merge-requests/<n> the request with its diff
29 // GET /<repo>/merge-requests/<n>/since the diff since the last read29 // GET /<repo>/merge-requests/<n>/since the diff since the last read
30 // GET /<repo>/compare/<branch> a branch against the default branch30 // 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.
31 //38 //
32 // Reading marks nothing as read. MarkRead stores state, and a GET must stay39 // Reading marks nothing as read. MarkRead stores state, and a GET must stay
33 // safe: a prefetcher, a link preview or a reload of the owner's tab would move40 // safe: a prefetcher, a link preview or a reload of the owner's tab would move
@@ -43,6 +50,9 @@ import (
43 // forms and the CLI lines exist only in the owner's copy.50 // forms and the CLI lines exist only in the owner's copy.
44 51
45 const (52 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
46 // mrPerPage is how many decided merge requests a page of the list shows,56 // mrPerPage is how many decided merge requests a page of the list shows,
47 // at least; pages of the use case are read whole.57 // at least; pages of the use case are read whole.
48 mrPerPage = 5058 mrPerPage = 50
@@ -114,6 +124,8 @@ type ResolveForm struct {
114 // Value is the state the form sets.124 // Value is the state the form sets.
115 Value bool125 Value bool
116 Label string126 Label string
127+ // From is the window of the page the comment is on (CommentForm.From).
128+ From int
117 }129 }
118 130
119 // CommentForm is the POST that adds a comment.131 // CommentForm is the POST that adds a comment.
@@ -125,6 +137,9 @@ type CommentForm struct {
125 Path, Line, Side string137 Path, Line, Side string
126 Cancel string138 Cancel string
127 Focus bool139 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
128 }143 }
129 144
130 // MRCommit is a commit of the request.145 // MRCommit is a commit of the request.
@@ -198,8 +213,11 @@ type MRPage struct {
198 Invisible bool213 Invisible bool
199 214
200 // General are the comments that are not on a line, Loose those on a line215 // 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).
202 General, Loose []CommentView219 General, Loose []CommentView
220+ LooseInFiles bool
203 NComments int221 NComments int
204 NUnresolved int222 NUnresolved int
205 GeneralForm *CommentForm223 GeneralForm *CommentForm
@@ -208,12 +226,16 @@ type MRPage struct {
208 CommitsNote string226 CommitsNote string
209 Modes []string227 Modes []string
210 ChecksBlock bool228 ChecksBlock bool
211- // Checks are the CI checks of the head of the request.229+ // Checks are the CI checks of the head of the request, ChecksSum their
212- Checks ChecksSection230+ // state in the bar above the diff.
231+ Checks ChecksSection
232+ ChecksSum ChecksSummary
213 233
214 Box *MergeBox234 Box *MergeBox
215 // Live is true when the page announces changes.235 // Live is true when the page announces changes.
216 Live bool236 Live bool
237+ // From is the first file of the window the page read the hunks of (?from=).
238+ From int
217 }239 }
218 240
219 // CompareCommit is a commit of a branch that the target lacks.241 // 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
419 if err != nil {441 if err != nil {
420 return nil, 0, err442 return nil, 0, err
421 }443 }
422- res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, since)444+ from, err := parseFrom(r)
423 if err != nil {445 if err != nil {
424 return nil, 0, err446 return nil, 0, err
425 }447 }
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+ }
426 m := res.MergeRequest457 m := res.MergeRequest
427 now := s.cfg.Now()458 now := s.cfg.Now()
428 signed := SignedIn(ctx)459 signed := SignedIn(ctx)
@@ -470,13 +501,20 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
470 }501 }
471 }502 }
472 p.ChecksBlock = d.repo.Settings.FailingChecksBlock503 p.ChecksBlock = d.repo.Settings.FailingChecksBlock
473- if open && head != "" {504+ if head != "" {
474- p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head))505+ checks := s.checksFor(ctx, d.repo.Name, head)
475- } else if head != "" {506+ p.Checks = checksSection(d.repo.Repo, checks)
476- p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head))507+ p.ChecksSum = checksSummary(d.repo.Name, head, checks)
477- p.Checks.Note = ""508+ if !open {
509+ p.Checks.Note = ""
510+ }
478 }511 }
479 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)512 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
480 518
481 // The commits of the request, newest first.519 // The commits of the request, newest first.
482 for _, c := range res.Commits {520 for _, c := range res.Commits {
@@ -538,7 +576,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
538 s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)576 s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
539 lines, invisible := diffLines(p.Files)577 lines, invisible := diffLines(p.Files)
540 p.Invisible = invisible578 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))
542 if signed && open {580 if signed && open {
543 p.Box = s.mergeBox(d, n, m.Source, head, lastRead)581 p.Box = s.mergeBox(d, n, m.Source, head, lastRead)
544 if m.IsDraft() {582 if m.IsDraft() {
@@ -570,6 +608,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
570 p.Live = true608 p.Live = true
571 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))609 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))
572 l.HasRail = true610 l.HasRail = true
611+ l.MR = true
573 return MergeRequest(l, p), http.StatusOK, nil612 return MergeRequest(l, p), http.StatusOK, nil
574 }613 }
575 614
@@ -666,23 +705,30 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
666 }705 }
667 index := map[lineKey]*DiffLine{}706 index := map[lineKey]*DiffLine{}
668 fileIDs := map[string]string{}707 fileIDs := map[string]string{}
708+ fileAt := map[string]int{}
669 for fi := range p.Files {709 for fi := range p.Files {
670 f := &p.Files[fi]710 f := &p.Files[fi]
671 fileIDs[f.Key] = f.ID711 fileIDs[f.Key] = f.ID
712+ fileAt[f.Key] = fi
672 linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)713 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+ }
673 for hi := range f.Hunks {719 for hi := range f.Hunks {
674 for li := range f.Hunks[hi].Lines {720 for li := range f.Hunks[hi].Lines {
675 ln := &f.Hunks[hi].Lines[li]721 ln := &f.Hunks[hi].Lines[li]
676 if ln.oldN > 0 && ln.Kind != "add" {722 if ln.oldN > 0 && ln.Kind != "add" {
677 index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln723 index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln
678 if linkable {724 if linkable {
679- ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID725+ ln.OldHref = on + "old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
680 }726 }
681 }727 }
682 if ln.newN > 0 && ln.Kind != "del" {728 if ln.newN > 0 && ln.Kind != "del" {
683 index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln729 index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln
684 if linkable {730 if linkable {
685- ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID731+ ln.NewHref = on + "new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
686 }732 }
687 }733 }
688 }734 }
@@ -711,6 +757,11 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
711 p.NUnresolved++757 p.NUnresolved++
712 }758 }
713 general := c.Path == ""759 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+ }
714 var ln *DiffLine765 var ln *DiffLine
715 if !general {766 if !general {
716 ln = find(c.Path, c.Side, c.Line)767 ln = find(c.Path, c.Side, c.Line)
@@ -724,6 +775,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
724 }775 }
725 if canWrite {776 if canWrite {
726 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}777 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
778+ if !general && listed {
779+ v.Resolve.From = windowStart(fi)
780+ }
727 if c.Resolved {781 if c.Resolved {
728 v.Resolve.Label = "Unresolve"782 v.Resolve.Label = "Unresolve"
729 }783 }
@@ -733,6 +787,8 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
733 ln.Notes = append(ln.Notes, v)787 ln.Notes = append(ln.Notes, v)
734 case general:788 case general:
735 p.General = append(p.General, v)789 p.General = append(p.General, v)
790+ case p.LooseInFiles && listed:
791+ p.Files[fi].Loose = append(p.Files[fi].Loose, v)
736 default:792 default:
737 p.Loose = append(p.Loose, v)793 p.Loose = append(p.Loose, v)
738 }794 }
@@ -747,7 +803,8 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
747 ln.Form = &CommentForm{803 ln.Form = &CommentForm{
748 ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,804 ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
749 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",805 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]),
751 }808 }
752 }809 }
753 }810 }
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">
@@ -162,13 +162,7 @@ templ MergeRequest(l Layout, p MRPage) {
162 if p.SinceNote != "" {162 if p.SinceNote != "" {
163 <p class="notice">{ p.SinceNote }</p>163 <p class="notice">{ p.SinceNote }</p>
164 }164 }
165- <div class="diff-tools" role="toolbar" aria-label="Diff view">165+ @mrBar(p)
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>
172 if p.Invisible {166 if p.Invisible {
173 <p class="notice">167 <p class="notice">
174 this diff holds invisible characters (direction controls, zero-width characters). They are shown as168 this diff holds invisible characters (direction controls, zero-width characters). They are shown as
@@ -180,6 +174,7 @@ templ MergeRequest(l Layout, p MRPage) {
180 }174 }
181 <div175 <div
182 id="diffs"176 id="diffs"
177+ data-mr
183 if p.Both {178 if p.Both {
184 data-diff={ p.View }179 data-diff={ p.View }
185 } else {180 } else {
@@ -256,6 +251,9 @@ templ commentView(c CommentView, suffix string) {
256 if c.Resolve != nil {251 if c.Resolve != nil {
257 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>252 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
258 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>253 <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+ }
259 <button type="submit" class="btn">{ c.Resolve.Label }</button>257 <button type="submit" class="btn">{ c.Resolve.Label }</button>
260 </form>258 </form>
261 }259 }
@@ -289,6 +287,9 @@ templ commentForm(f CommentForm, suffix string) {
289 <input type="hidden" name="line" value={ f.Line }/>287 <input type="hidden" name="line" value={ f.Line }/>
290 <input type="hidden" name="side" value={ f.Side }/>288 <input type="hidden" name="side" value={ f.Side }/>
291 }289 }
290+ if f.From > 0 {
291+ <input type="hidden" name="from" value={ itoa(f.From) }/>
292+ }
292 <div class="f-btns">293 <div class="f-btns">
293 <button type="submit" class="btn primary">Comment</button>294 <button type="submit" class="btn primary">Comment</button>
294 if f.Cancel != "" {295 if f.Cancel != "" {
@@ -299,25 +300,11 @@ templ commentForm(f CommentForm, suffix string) {
299 }300 }
300 301
301 templ mrRail(p MRPage) {302 templ mrRail(p MRPage) {
302- if p.Box != nil {303+ <section aria-labelledby="r-files" class="r-files">
303- @mergeBox(p)304+ <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd></h2>
304- }305+ @FileList(p.Files)
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>
320 </section>306 </section>
307+ @MRChecks(p.Checks)
321 <section aria-labelledby="r-commits">308 <section aria-labelledby="r-commits">
322 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>309 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
323 <ul class="pcommits">310 <ul class="pcommits">
@@ -344,74 +331,125 @@ templ mrRail(p MRPage) {
344 <p class="note">{ p.CommitsNote }</p>331 <p class="note">{ p.CommitsNote }</p>
345 }332 }
346 </section>333 </section>
347- <section aria-labelledby="r-files">334+ <section aria-labelledby="r-settings">
348- <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>335+ <h2 id="r-settings">Settings</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>
359 <dl class="kv">336 <dl class="kv">
360- <dt>head</dt>337+ <dt>modes</dt>
338+ <dd>{ strings.Join(p.Modes, ", ") }</dd>
339+ <dt>checks</dt>
361 <dd>340 <dd>
362- if p.Box.HeadGone {341+ if p.ChecksBlock {
363- gone: the source branch was deleted342+ failing ones block the merge
364 } else {343 } else {
365- { p.HeadShort }344+ do not block the merge
366 }345 }
367 </dd>346 </dd>
368- <dt>into</dt>
369- <dd>{ p.Target }</dd>
370 </dl>347 </dl>
371- if p.Box.ReadyAction != "" {348+ </section>
372- <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>349+}
373- <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>350+
374- <button type="submit" class="btn primary" title="remove the draft prefix from the title">Ready for review</button>351+// mrBar is the bar above the diff, which follows the review: the totals, the
375- </form>352+// layout of the diff, the state of the checks and, for the owner of an open
376- } else if !p.Box.HeadGone {353+// request, the merge menu and the other actions. Each menu is a <details>: it
377- <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>354+// works without scripts; with them, M and the keys of the menu drive it.
378- <input type="hidden" name="head_id" value={ p.Box.Head }/>355+templ mrBar(p MRPage) {
379- <label class="sr" for="merge-mode">merge mode</label>356+ <div class="diff-tools mr-bar" role="toolbar" aria-label="Diff view and merge">
380- <select id="merge-mode" name="mode">357+ <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
381- for _, m := range p.Box.Modes {358+ @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
382- <option value={ m.Value }>{ m.Label }</option>359+ @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
383- }360+ @collapseButton()
384- </select>361+ <span class="grow"></span>
385- if p.Box.CanDeleteBranch {362+ @MRChecksSum(p.ChecksSum)
386- <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge</label>363+ if p.Box != nil {
387- }364+ @mergeMenu(p)
388- <div class="d-btns">365+ @moreMenu(p)
389- <button type="submit" class="btn primary">Merge</button>
390- </div>
391- </form>
392 }366 }
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">
394 if p.Box.CanMarkRead {434 if p.Box.CanMarkRead {
395 <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>435 <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
396 <input type="hidden" name="head_id" value={ p.Box.Head }/>436 <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>
398 </form>438 </form>
399 }439 }
400 <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>440 <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
401 <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>441 <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
402 </form>442 </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+ }
403 </div>449 </div>
404- if !p.Box.HeadGone && p.Box.ReadyAction == "" {450+ </details>
405- <details class="d-or">
406- <summary class="mute">or from a terminal</summary>
407- @CloneBlock(p.Box.CLI)
408- </details>
409- }
410- </section>
411 }451 }
412 452
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.
415 templ Compare(l Layout, p ComparePage) {453 templ Compare(l Layout, p ComparePage) {
416 @Shell(l, compareRail(p)) {454 @Shell(l, compareRail(p)) {
417 <header class="show mr-h">455 <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+}
@@ -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
@@ -181,6 +181,7 @@ func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string
181 if err != nil {181 if err != nil {
182 return "", back, err182 return "", back, err
183 }183 }
184+ back = withFrom(back, formFrom(r))
184 c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params)185 c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params)
185 if err != nil {186 if err != nil {
186 return "", back, err187 return "", back, err
@@ -209,6 +210,7 @@ func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string
209 // An id this server never issued is 0: the use case answers not found,210 // An id this server never issued is 0: the use case answers not found,
210 // after the repository and the scope were checked, as for any unknown211 // after the repository and the scope were checked, as for any unknown
211 // comment.212 // comment.
213+ back = withFrom(back, formFrom(r))
212 c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved)214 c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved)
213 if err != nil {215 if err != nil {
214 return "", back, err216 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>
@@ -4,8 +4,9 @@ package web
4 4
5 import "strings"5 import "strings"
6 6
7-// OverlayHelp is the keyboard map panel.7+// OverlayHelp is the keyboard map panel; the keys of the merge request page
8-templ OverlayHelp() {8+// show on that page only.
9+templ OverlayHelp(mr bool) {
9 <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()">10 <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()">
10 <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>11 <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>
11 <dl class="keys">12 <dl class="keys">
@@ -21,6 +22,11 @@ templ OverlayHelp() {
21 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>22 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
22 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>23 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
23 <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>24 <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+ }
24 <div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div>30 <div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div>
25 </dl>31 </dl>
26 </dialog>32 </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()
@@ -29,6 +29,9 @@ templ Shell(l Layout, rail templ.Component) {
29 <script src={ l.Assets.Files } defer></script>29 <script src={ l.Assets.Files } defer></script>
30 <script src={ l.Assets.Passkey } defer></script>30 <script src={ l.Assets.Passkey } defer></script>
31 <script src={ l.Assets.Log } defer></script>31 <script src={ l.Assets.Log } defer></script>
32+ if l.MR {
33+ <script src={ l.Assets.MR } defer></script>
34+ }
32 </head>35 </head>
33 <body36 <body
34 data-ref={ l.RefPath }37 data-ref={ l.RefPath }
@@ -57,7 +60,7 @@ templ Shell(l Layout, rail templ.Component) {
57 </div>60 </div>
58 @Footer(l)61 @Footer(l)
59 @Hint(l)62 @Hint(l)
60- @OverlayHelp()63+ @OverlayHelp(l.MR)
61 @OverlayRefs(l)64 @OverlayRefs(l)
62 @OverlayCmd(l.Cmd)65 @OverlayCmd(l.Cmd)
63 @Keys()66 @Keys()
M internal/adapters/web/shell_templ.go +185 -162 generated file: collapsed
M internal/adapters/web/static/css/50-code.css +37 -11
@@ -46,10 +46,18 @@
4646 }
4747
4848 /* one file of a diff */
49+ .dfile { --lnw: 4ch; }
50+ .lnw-2 { --lnw: 2ch; } .lnw-3 { --lnw: 3ch; } .lnw-4 { --lnw: 4ch; } .lnw-5 { --lnw: 5ch; } .lnw-6 { --lnw: 6ch; } .lnw-7 { --lnw: 7ch; }
4951 .dfile {
50- margin-top: var(--s3); scroll-margin-top: calc(var(--hdr-h, 0px) + 56px);
52+ margin-top: var(--s3); scroll-margin-top: calc(var(--hdr-h, 0px) + var(--bar-h, 48px) + 8px);
53+ /* a file far from the screen is neither laid out nor painted: a diff of
54+ hundreds of files scrolls and switches layout like a short one. The
55+ browser remembers the last size it rendered (auto). */
56+ content-visibility: auto; contain-intrinsic-size: auto 480px;
5157 & > summary {
52- position: sticky; top: calc(var(--hdr-h, 0px) + 48px); z-index: 4;
58+ /* under the diff tools, whose height --bar-h is (two lines when they
59+ wrap: the merge request bar on a narrow screen) */
60+ position: sticky; top: calc(var(--hdr-h, 0px) + var(--bar-h, 48px)); z-index: 4;
5361 background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--r) var(--r) 0 0; padding: var(--s2) var(--s3);
5462 font-family: var(--mono); font-size: var(--fs-s);
5563 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; list-style: none;
@@ -66,7 +74,9 @@
6674 width: max-content; min-width: 100%; font-family: var(--mono);
6775 font-size: var(--fs-code); line-height: var(--lh-code);
6876 & td { padding: 0; vertical-align: top; }
69- & .ln { width: 6ch; min-width: 6ch; padding-right: 1ch; text-align: right; color: var(--ink-mute); user-select: none; }
77+ /* the gutter is as wide as the largest line number of the file (lnw-N on
78+ .dfile), plus 1ch on each side */
79+ & .ln { width: calc(var(--lnw) + 2ch); min-width: calc(var(--lnw) + 2ch); padding: 0 1ch; text-align: right; color: var(--ink-mute); user-select: none; }
7080 & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); }
7181 & .tx { white-space: pre; }
7282 & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; }
@@ -83,22 +93,38 @@
8393 the hunk header, which spans all columns); long lines wrap in their half */
8494 .spl {
8595 width: 100%; table-layout: fixed;
86- & .c-ln { width: 7ch; } & .c-m { width: 2.5ch; }
96+ & .c-ln { width: calc(var(--lnw) + 2ch); } & .c-m { width: 2.5ch; }
8797 & .tx, & .hunk td { white-space: pre-wrap; overflow-wrap: anywhere; }
8898 & td.ln:nth-child(4) { border-left: 1px solid var(--border); }
8999 }
90100 #diffs[data-diff="unified"] .spl, #diffs[data-diff="split"] .uni { display: none; }
91101
92- /* diff table of contents in the rail */
102+ /* diff table of contents in the rail: one line a file, the name first and
103+ its directory dimmed after it (cut from its start when long), the
104+ comments, the counts */
93105 .files {
94- font-size: var(--fs-s);
95- & a { display: grid; grid-template-columns: 2ch minmax(0, 1fr) auto; column-gap: var(--s2); padding: 2px var(--s2); margin: 0 calc(-1 * var(--s2)); border-radius: 4px; text-decoration: none; font-family: var(--mono); }
106+ font-size: var(--fs-s); position: relative; /* the offsets of its links count from it (ermite.js) */
107+ & li[hidden] { display: none; }
108+ & a {
109+ display: grid; grid-template-columns: 1.5ch minmax(0, 1fr) auto auto; column-gap: var(--s2); align-items: baseline;
110+ height: 26px; line-height: 26px; padding: 0 var(--s2); margin: 0 calc(-1 * var(--s2));
111+ border-radius: 4px; border-left: 2px solid transparent; text-decoration: none; font-family: var(--mono); color: var(--ink);
112+ }
96113 & a:hover { background: var(--bg-raised); }
97114 & a:hover .p { text-decoration: underline; }
98- & a[aria-current="true"] { background: var(--row); font-weight: bold; }
99- & .p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
100- & .n { color: var(--ink-dim); font-size: var(--fs-s); }
101- & .b { grid-column: 2 / -1; color: var(--ink-mute); font-size: var(--fs-s); letter-spacing: .1ch; line-height: 1; padding-bottom: 4px; }
115+ & a[aria-current="true"] { background: var(--row); border-left-color: var(--ink-hi); }
116+ & a[aria-current="true"] .p { font-weight: bold; color: var(--ink-hi); }
117+ & .st { color: var(--ink-mute); font-weight: bold; }
118+ & .st[data-st="A"] { color: var(--teal); }
119+ & .st[data-st="D"] { color: var(--red); }
120+ & .nm { overflow: hidden; white-space: nowrap; display: flex; min-width: 0; }
121+ & .nm .p { flex: none; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
122+ /* cut from the start (rtl), the path itself kept in its order (bdi) */
123+ & .nm .d { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; color: var(--ink-mute); padding-left: 1ch; }
124+ & .nm .d bdi { direction: ltr; }
125+ & .cn { font: 600 var(--fs-xs) / 16px var(--font); padding: 0 6px; border-radius: 999px; background: var(--ink-hi); color: var(--on-ink); align-self: center; }
126+ & .cn.done { background: var(--bg-strong); color: var(--ink-dim); }
127+ & .n { color: var(--ink-dim); font-size: var(--fs-xs); white-space: nowrap; }
102128 }
103129
104130 @media (max-width: 700px) {
@@ -46,10 +46,18 @@
46 }46 }
47 47
48 /* one file of a diff */48 /* 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; }
49 .dfile {51 .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;
51 & > summary {57 & > 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;
53 background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--r) var(--r) 0 0; padding: var(--s2) var(--s3);61 background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--r) var(--r) 0 0; padding: var(--s2) var(--s3);
54 font-family: var(--mono); font-size: var(--fs-s);62 font-family: var(--mono); font-size: var(--fs-s);
55 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; list-style: none;63 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; list-style: none;
@@ -66,7 +74,9 @@
66 width: max-content; min-width: 100%; font-family: var(--mono);74 width: max-content; min-width: 100%; font-family: var(--mono);
67 font-size: var(--fs-code); line-height: var(--lh-code);75 font-size: var(--fs-code); line-height: var(--lh-code);
68 & td { padding: 0; vertical-align: top; }76 & 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; }
70 & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); }80 & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); }
71 & .tx { white-space: pre; }81 & .tx { white-space: pre; }
72 & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; }82 & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; }
@@ -83,22 +93,38 @@
83 the hunk header, which spans all columns); long lines wrap in their half */93 the hunk header, which spans all columns); long lines wrap in their half */
84 .spl {94 .spl {
85 width: 100%; table-layout: fixed;95 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; }
87 & .tx, & .hunk td { white-space: pre-wrap; overflow-wrap: anywhere; }97 & .tx, & .hunk td { white-space: pre-wrap; overflow-wrap: anywhere; }
88 & td.ln:nth-child(4) { border-left: 1px solid var(--border); }98 & td.ln:nth-child(4) { border-left: 1px solid var(--border); }
89 }99 }
90 #diffs[data-diff="unified"] .spl, #diffs[data-diff="split"] .uni { display: none; }100 #diffs[data-diff="unified"] .spl, #diffs[data-diff="split"] .uni { display: none; }
91 101
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 */
93 .files {105 .files {
94- font-size: var(--fs-s);106+ font-size: var(--fs-s); position: relative; /* the offsets of its links count from it (ermite.js) */
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); }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+ }
96 & a:hover { background: var(--bg-raised); }113 & a:hover { background: var(--bg-raised); }
97 & a:hover .p { text-decoration: underline; }114 & a:hover .p { text-decoration: underline; }
98- & a[aria-current="true"] { background: var(--row); font-weight: bold; }115+ & a[aria-current="true"] { background: var(--row); border-left-color: var(--ink-hi); }
99- & .p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }116+ & a[aria-current="true"] .p { font-weight: bold; color: var(--ink-hi); }
100- & .n { color: var(--ink-dim); font-size: var(--fs-s); }117+ & .st { color: var(--ink-mute); font-weight: bold; }
101- & .b { grid-column: 2 / -1; color: var(--ink-mute); font-size: var(--fs-s); letter-spacing: .1ch; line-height: 1; padding-bottom: 4px; }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; }
102 }128 }
103 129
104 @media (max-width: 700px) {130 @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); }
@@ -18,41 +18,109 @@
18 & .mute { font-weight: 400; font-family: var(--mono); }18 & .mute { font-weight: 400; font-family: var(--mono); }
19 }19 }
20 20
21- /* merge block: first thing in the rail */21+ /* the bar above the diff of a merge request: the diff tools, the checks and
22- .decide {22+ the menus of the owner, on one line that follows the review */
23- border-color: var(--border-strong) !important;23+ /* on a narrow screen the bar takes two lines: the diff tools, then the
24- & select {24+ checks and the menus; the file headers stick under both */
25- display: block; width: 100%; height: 36px; padding: 0 var(--s3);25+ @media (max-width: 860px) {
26- font: inherit; color: inherit; background: var(--bg-inset);26+ body:has(.mr-bar) { --bar-h: 96px; }
27- border: 1px solid var(--border-strong); border-radius: var(--r-s);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);
28 }46 }
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; }47+ & .kv { margin: 0; }
30- & .d-check input { accent-color: var(--ink-hi); margin: 0; }48+ }
31- & .d-btns { display: flex; gap: var(--s2); margin-top: var(--s3); flex-wrap: wrap; }49+ .mmenu {
32- & .d-btns .primary { flex: 1; justify-content: center; }50+ & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; }
33- /* the terminal lines fold away: a reviewer who merges in the page does51+ & .d-modes label, & .d-check {
34- not need them in sight */52+ display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2));
35- & .d-draft { font-size: var(--fs-s); color: var(--ink-dim); }53+ border-radius: var(--r-s); color: var(--ink); cursor: pointer;
36- & .d-or { font-size: var(--fs-s); }54+ }
37- & .d-or > summary { cursor: pointer; list-style: none; }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); }
38 & .d-or > summary::-webkit-details-marker { display: none; }70 & .d-or > summary::-webkit-details-marker { display: none; }
39 & .d-or > summary::before { content: "▸ "; }71 & .d-or > summary::before { content: "▸ "; }
40 & .d-or[open] > summary::before { content: "▾ "; }72 & .d-or[open] > summary::before { content: "▾ "; }
41 & .d-or > summary:hover { color: var(--ink-hi); }73 & .d-or > summary:hover { color: var(--ink-hi); }
42 & .d-or[open] > .clone { margin-top: var(--s2); }74 & .d-or[open] > .clone { margin-top: var(--s2); }
43- & .clone { margin-top: 0; }
44 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }75 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
45 }76 }
46 77
47- /* the rail of a merge request follows the review: the merge block and the78+ /* the rail of a merge request: the list of files on top, which follows the
48- checks stay in reach at the end of a long diff. It scrolls on its own79+ review and scrolls on its own; checks, commits and settings scroll with
49- when it is taller than the window. Only on a two-column layout. */80+ the page. Only on a two-column layout. */
50 @media (min-width: 1081px) {81 @media (min-width: 1081px) {
51- .work.has-rail:has(#mr-checks) .rail {82+ /* the rail runs the height of the content, so that its first block can
52- position: sticky; top: calc(var(--hdr-h, 0px) + var(--s4));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;
53 max-height: calc(100dvh - var(--hdr-h, 0px) - var(--s4) * 2);89 max-height: calc(100dvh - var(--hdr-h, 0px) - var(--s4) * 2);
54- overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin;
55 }90 }
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);
56 }124 }
57 125
58 .pcommits { font-size: var(--fs-s); display: grid; gap: var(--s1); }126 .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
@@ -41,6 +41,7 @@
41 };41 };
42 42
43 const ermite = {43 const ermite = {
44+ flash,
44 fire(name) {45 fire(name) {
45 body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));46 body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
46 },47 },
@@ -48,6 +49,7 @@
48 // Row cursor: j / k.49 // Row cursor: j / k.
49 move(d) {50 move(d) {
50 if ($('dialog[open]')) return;51 if ($('dialog[open]')) return;
52+ if (window.ermiteMR?.stepFiles(d)) return;
51 const rows = visibleRows();53 const rows = visibleRows();
52 if (!rows.length) {54 if (!rows.length) {
53 scrollBy(0, d * 180);55 scrollBy(0, d * 180);
@@ -73,6 +75,11 @@
73 open() {75 open() {
74 if (!$('dialog[open]')) $('[data-cursor] a')?.click();76 if (!$('dialog[open]')) $('[data-cursor] a')?.click();
75 },77 },
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+ },
76 focusFilter() {83 focusFilter() {
77 $('[data-filter]')?.focus();84 $('[data-filter]')?.focus();
78 },85 },
@@ -210,9 +217,11 @@
210 217
211 // 1. Keys.218 // 1. Keys.
212 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',219 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' };
214 // "g t" is the key "t": the tabs have one event each.223 // "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' };
216 let pending = 0;225 let pending = 0;
217 let pendingTimer = 0;226 let pendingTimer = 0;
218 addEventListener('keydown', (e) => {227 addEventListener('keydown', (e) => {
@@ -221,7 +230,16 @@
221 // field keeps ctrl k (kill to end of line on macOS).230 // field keeps ctrl k (kill to end of line on macOS).
222 if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return ermite.fire('cmd'); }231 if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return ermite.fire('cmd'); }
223 if (e.ctrlKey || e.metaKey || e.altKey) return;232 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;
225 if (typing) return;243 if (typing) return;
226 // An open dialog takes only the keys that move between dialogs: the page244 // An open dialog takes only the keys that move between dialogs: the page
227 // behind it does not react (scripted clicks would go through inert).245 // behind it does not react (scripted clicks would go through inert).
@@ -273,7 +291,7 @@
273 // line (the first file when none is). One observer tracks the files in the291 // line (the first file when none is). One observer tracks the files in the
274 // band; it reports only when a file enters or leaves it, so a scroll costs292 // band; it reports only when a file enters or leaves it, so a scroll costs
275 // nothing in between, and a jump that skips the band is caught by the scan.293 // nothing in between, and a jump that skips the band is caught by the scan.
276- const files = $$('.dfile');294+ let files = $$('.dfile');
277 const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a]));295 const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a]));
278 if (files.length && links.size && 'IntersectionObserver' in window) {296 if (files.length && links.size && 'IntersectionObserver' in window) {
279 const inBand = new Set();297 const inBand = new Set();
@@ -284,7 +302,17 @@
284 ?? files[0];302 ?? files[0];
285 if (f === current) return;303 if (f === current) return;
286 links.get(current?.id)?.removeAttribute('aria-current');304 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+ }
288 current = f;316 current = f;
289 };317 };
290 const io = new IntersectionObserver((entries) => {318 const io = new IntersectionObserver((entries) => {
@@ -295,6 +323,15 @@
295 mark();323 mark();
296 }, { rootMargin: '0px 0px -67% 0px' });324 }, { rootMargin: '0px 0px -67% 0px' });
297 for (const f of files) io.observe(f);325 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+ };
298 }335 }
299 336
300 // 5. A failure of a handler or of a request (htmx logs it to the console337 // 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();
@@ -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.
@@ -67,6 +67,8 @@ type Layout struct {
67 // Passkeys tells that the passkey routes exist: the header then links to67 // Passkeys tells that the passkey routes exist: the header then links to
68 // them.68 // them.
69 Passkeys bool69 Passkeys bool
70+ // MR loads the script of the merge request page (mr.js).
71+ MR bool
70 }72 }
71 73
72 // Link is a plain label + href pair.74 // 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))
@@ -177,6 +177,7 @@ func (s *Server) routes() *http.ServeMux {
177 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))177 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
178 mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))178 mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))
179 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))179 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))
180 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))181 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
181 // The writes: POST only, behind the gate (ServeHTTP).182 // The writes: POST only, behind the gate (ServeHTTP).
182 mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))183 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>
@@ -374,7 +374,10 @@
374 k-split from:body -> ermite.layout('split');374 k-split from:body -> ermite.layout('split');
375 k-unified from:body -> ermite.layout('unified');375 k-unified from:body -> ermite.layout('unified');
376 k-collapse from:body -> ermite.collapse();376 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()">
378 </div>381 </div>
379 </body>382 </body>
380 </html>383 </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>
@@ -444,7 +444,10 @@
444 k-split from:body -> ermite.layout('split');444 k-split from:body -> ermite.layout('split');
445 k-unified from:body -> ermite.layout('unified');445 k-unified from:body -> ermite.layout('unified');
446 k-collapse from:body -> ermite.collapse();446 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()">
448 </div>451 </div>
449 </body>452 </body>
450 </html>453 </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>
@@ -374,7 +374,10 @@
374 k-split from:body -> ermite.layout('split');374 k-split from:body -> ermite.layout('split');
375 k-unified from:body -> ermite.layout('unified');375 k-unified from:body -> ermite.layout('unified');
376 k-collapse from:body -> ermite.collapse();376 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()">
378 </div>381 </div>
379 </body>382 </body>
380 </html>383 </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>
@@ -376,7 +376,10 @@
376 k-split from:body -> ermite.layout('split');376 k-split from:body -> ermite.layout('split');
377 k-unified from:body -> ermite.layout('unified');377 k-unified from:body -> ermite.layout('unified');
378 k-collapse from:body -> ermite.collapse();378 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()">
380 </div>383 </div>
381 </body>384 </body>
382 </html>385 </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>
@@ -441,7 +441,10 @@
441 k-split from:body -> ermite.layout('split');441 k-split from:body -> ermite.layout('split');
442 k-unified from:body -> ermite.layout('unified');442 k-unified from:body -> ermite.layout('unified');
443 k-collapse from:body -> ermite.collapse();443 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()">
445 </div>448 </div>
446 </body>449 </body>
447 </html>450 </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>
@@ -399,7 +399,10 @@
399 k-split from:body -> ermite.layout('split');399 k-split from:body -> ermite.layout('split');
400 k-unified from:body -> ermite.layout('unified');400 k-unified from:body -> ermite.layout('unified');
401 k-collapse from:body -> ermite.collapse();401 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()">
403 </div>406 </div>
404 </body>407 </body>
405 </html>408 </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>
@@ -394,7 +394,10 @@
394 k-split from:body -> ermite.layout('split');394 k-split from:body -> ermite.layout('split');
395 k-unified from:body -> ermite.layout('unified');395 k-unified from:body -> ermite.layout('unified');
396 k-collapse from:body -> ermite.collapse();396 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()">
398 </div>401 </div>
399 </body>402 </body>
400 </html>403 </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>
@@ -374,7 +374,10 @@
374 k-split from:body -> ermite.layout('split');374 k-split from:body -> ermite.layout('split');
375 k-unified from:body -> ermite.layout('unified');375 k-unified from:body -> ermite.layout('unified');
376 k-collapse from:body -> ermite.collapse();376 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()">
378 </div>381 </div>
379 </body>382 </body>
380 </html>383 </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>
@@ -441,7 +441,10 @@
441 k-split from:body -> ermite.layout('split');441 k-split from:body -> ermite.layout('split');
442 k-unified from:body -> ermite.layout('unified');442 k-unified from:body -> ermite.layout('unified');
443 k-collapse from:body -> ermite.collapse();443 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()">
445 </div>448 </div>
446 </body>449 </body>
447 </html>450 </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>
@@ -130,7 +130,7 @@
130 </div>130 </div>
131 <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>131 <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>
132 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">132 <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>
134 <summary>134 <summary>
135 <span class="st">A</span>135 <span class="st">A</span>
136 <span class="p">big.txt</span>136 <span class="p">big.txt</span>
@@ -148,18 +148,19 @@
148 </h2>148 </h2>
149 <ol class="files" aria-label="Changed files">149 <ol class="files" aria-label="Changed files">
150 <li>150 <li>
151-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">151+<a href="#f1" title="big.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
152-<span class="st" title="A">A</span>152+<span class="st" data-st="A" title="added">A</span>
153-<span class="p" title="big.txt">big.txt</span>153+<span class="nm">
154+<span class="p">big.txt</span>
155+<span class="d">
156+<bdi>
157+</bdi>
158+</span>
159+</span>
154 <span class="n">160 <span class="n">
155 <span class="plus">+2600</span>161 <span class="plus">+2600</span>
156 <span class="minus">-0</span>162 <span class="minus">-0</span>
157 </span>163 </span>
158-<span class="b" aria-hidden="true">
159-<span class="plus">++++++++++</span>
160-<span class="minus">
161-</span>
162-</span>
163 </a>164 </a>
164 </li>165 </li>
165 </ol>166 </ol>
@@ -411,7 +412,10 @@
411 k-split from:body -> ermite.layout('split');412 k-split from:body -> ermite.layout('split');
412 k-unified from:body -> ermite.layout('unified');413 k-unified from:body -> ermite.layout('unified');
413 k-collapse from:body -> ermite.collapse();414 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()">
415 </div>419 </div>
416 </body>420 </body>
417 </html>421 </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>
@@ -125,7 +125,7 @@
125 </button>125 </button>
126 </div>126 </div>
127 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">127 <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>
129 <summary>129 <summary>
130 <span class="st">M</span>130 <span class="st">M</span>
131 <span class="p">logo.bin</span>131 <span class="p">logo.bin</span>
@@ -143,18 +143,16 @@
143 </h2>143 </h2>
144 <ol class="files" aria-label="Changed files">144 <ol class="files" aria-label="Changed files">
145 <li>145 <li>
146-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">146+<a href="#f1" title="logo.bin" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
147-<span class="st" title="M">M</span>147+<span class="st" data-st="M" title="modified">M</span>
148-<span class="p" title="logo.bin">logo.bin</span>148+<span class="nm">
149-<span class="n">149+<span class="p">logo.bin</span>
150-<span class="plus">+0</span>150+<span class="d">
151-<span class="minus">-0</span>151+<bdi>
152+</bdi>
152 </span>153 </span>
153-<span class="b" aria-hidden="true">
154-<span class="plus">
155 </span>154 </span>
156-<span class="minus">155+<span class="n mute">bin</span>
157-</span>bin</span>
158 </a>156 </a>
159 </li>157 </li>
160 </ol>158 </ol>
@@ -406,7 +404,10 @@
406 k-split from:body -> ermite.layout('split');404 k-split from:body -> ermite.layout('split');
407 k-unified from:body -> ermite.layout('unified');405 k-unified from:body -> ermite.layout('unified');
408 k-collapse from:body -> ermite.collapse();406 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()">
410 </div>411 </div>
411 </body>412 </body>
412 </html>413 </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>
@@ -124,7 +124,7 @@
124 </button>124 </button>
125 </div>125 </div>
126 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">126 <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>
128 <summary>128 <summary>
129 <span class="st">A</span>129 <span class="st">A</span>
130 <span class="p">side.txt</span>130 <span class="p">side.txt</span>
@@ -182,18 +182,19 @@
182 </h2>182 </h2>
183 <ol class="files" aria-label="Changed files">183 <ol class="files" aria-label="Changed files">
184 <li>184 <li>
185-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">185+<a href="#f1" title="side.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
186-<span class="st" title="A">A</span>186+<span class="st" data-st="A" title="added">A</span>
187-<span class="p" title="side.txt">side.txt</span>187+<span class="nm">
188+<span class="p">side.txt</span>
189+<span class="d">
190+<bdi>
191+</bdi>
192+</span>
193+</span>
188 <span class="n">194 <span class="n">
189 <span class="plus">+1</span>195 <span class="plus">+1</span>
190 <span class="minus">-0</span>196 <span class="minus">-0</span>
191 </span>197 </span>
192-<span class="b" aria-hidden="true">
193-<span class="plus">++++++++++</span>
194-<span class="minus">
195-</span>
196-</span>
197 </a>198 </a>
198 </li>199 </li>
199 </ol>200 </ol>
@@ -445,7 +446,10 @@
445 k-split from:body -> ermite.layout('split');446 k-split from:body -> ermite.layout('split');
446 k-unified from:body -> ermite.layout('unified');447 k-unified from:body -> ermite.layout('unified');
447 k-collapse from:body -> ermite.collapse();448 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()">
449 </div>453 </div>
450 </body>454 </body>
451 </html>455 </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>
@@ -131,7 +131,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
131 </button>131 </button>
132 </div>132 </div>
133 <div id="diffs" data-diff="split">133 <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>
135 <summary>135 <summary>
136 <span class="st">M</span>136 <span class="st">M</span>
137 <span class="p">a.txt</span>137 <span class="p">a.txt</span>
@@ -271,7 +271,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
271 </table>271 </table>
272 </div>272 </div>
273 </details>273 </details>
274-<details class="dfile" id="f2" data-file>274+<details class="dfile lnw-2" id="f2" data-file>
275 <summary>275 <summary>
276 <span class="st">M</span>276 <span class="st">M</span>
277 <span class="p">run.sh</span>277 <span class="p">run.sh</span>
@@ -289,32 +289,35 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
289 </h2>289 </h2>
290 <ol class="files" aria-label="Changed files">290 <ol class="files" aria-label="Changed files">
291 <li>291 <li>
292-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">292+<a href="#f1" title="a.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
293-<span class="st" title="M">M</span>293+<span class="st" data-st="M" title="modified">M</span>
294-<span class="p" title="a.txt">a.txt</span>294+<span class="nm">
295+<span class="p">a.txt</span>
296+<span class="d">
297+<bdi>
298+</bdi>
299+</span>
300+</span>
295 <span class="n">301 <span class="n">
296 <span class="plus">+2</span>302 <span class="plus">+2</span>
297 <span class="minus">-1</span>303 <span class="minus">-1</span>
298 </span>304 </span>
299-<span class="b" aria-hidden="true">
300-<span class="plus">+++++++</span>
301-<span class="minus">---</span>
302-</span>
303 </a>305 </a>
304 </li>306 </li>
305 <li>307 <li>
306-<a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">308+<a href="#f2" title="run.sh" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
307-<span class="st" title="M">M</span>309+<span class="st" data-st="M" title="modified">M</span>
308-<span class="p" title="run.sh">run.sh</span>310+<span class="nm">
311+<span class="p">run.sh</span>
312+<span class="d">
313+<bdi>
314+</bdi>
315+</span>
316+</span>
309 <span class="n">317 <span class="n">
310 <span class="plus">+0</span>318 <span class="plus">+0</span>
311 <span class="minus">-0</span>319 <span class="minus">-0</span>
312 </span>320 </span>
313-<span class="b" aria-hidden="true">
314-<span class="plus">
315-</span>
316-<span class="minus">
317-</span>··········</span>
318 </a>321 </a>
319 </li>322 </li>
320 </ol>323 </ol>
@@ -566,7 +569,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
566 k-split from:body -> ermite.layout('split');569 k-split from:body -> ermite.layout('split');
567 k-unified from:body -> ermite.layout('unified');570 k-unified from:body -> ermite.layout('unified');
568 k-collapse from:body -> ermite.collapse();571 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()">
570 </div>576 </div>
571 </body>577 </body>
572 </html>578 </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>
@@ -131,7 +131,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
131 </button>131 </button>
132 </div>132 </div>
133 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">133 <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>
135 <summary>135 <summary>
136 <span class="st">M</span>136 <span class="st">M</span>
137 <span class="p">a.txt</span>137 <span class="p">a.txt</span>
@@ -271,7 +271,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
271 </table>271 </table>
272 </div>272 </div>
273 </details>273 </details>
274-<details class="dfile" id="f2" data-file>274+<details class="dfile lnw-2" id="f2" data-file>
275 <summary>275 <summary>
276 <span class="st">M</span>276 <span class="st">M</span>
277 <span class="p">run.sh</span>277 <span class="p">run.sh</span>
@@ -289,32 +289,35 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
289 </h2>289 </h2>
290 <ol class="files" aria-label="Changed files">290 <ol class="files" aria-label="Changed files">
291 <li>291 <li>
292-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">292+<a href="#f1" title="a.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
293-<span class="st" title="M">M</span>293+<span class="st" data-st="M" title="modified">M</span>
294-<span class="p" title="a.txt">a.txt</span>294+<span class="nm">
295+<span class="p">a.txt</span>
296+<span class="d">
297+<bdi>
298+</bdi>
299+</span>
300+</span>
295 <span class="n">301 <span class="n">
296 <span class="plus">+2</span>302 <span class="plus">+2</span>
297 <span class="minus">-1</span>303 <span class="minus">-1</span>
298 </span>304 </span>
299-<span class="b" aria-hidden="true">
300-<span class="plus">+++++++</span>
301-<span class="minus">---</span>
302-</span>
303 </a>305 </a>
304 </li>306 </li>
305 <li>307 <li>
306-<a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">308+<a href="#f2" title="run.sh" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
307-<span class="st" title="M">M</span>309+<span class="st" data-st="M" title="modified">M</span>
308-<span class="p" title="run.sh">run.sh</span>310+<span class="nm">
311+<span class="p">run.sh</span>
312+<span class="d">
313+<bdi>
314+</bdi>
315+</span>
316+</span>
309 <span class="n">317 <span class="n">
310 <span class="plus">+0</span>318 <span class="plus">+0</span>
311 <span class="minus">-0</span>319 <span class="minus">-0</span>
312 </span>320 </span>
313-<span class="b" aria-hidden="true">
314-<span class="plus">
315-</span>
316-<span class="minus">
317-</span>··········</span>
318 </a>321 </a>
319 </li>322 </li>
320 </ol>323 </ol>
@@ -566,7 +569,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
566 k-split from:body -> ermite.layout('split');569 k-split from:body -> ermite.layout('split');
567 k-unified from:body -> ermite.layout('unified');570 k-unified from:body -> ermite.layout('unified');
568 k-collapse from:body -> ermite.collapse();571 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()">
570 </div>576 </div>
571 </body>577 </body>
572 </html>578 </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>
@@ -125,7 +125,7 @@
125 </button>125 </button>
126 </div>126 </div>
127 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">127 <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>
129 <summary>129 <summary>
130 <span class="st">R</span>130 <span class="st">R</span>
131 <span class="p">old.txt → docs-old.txt</span>131 <span class="p">old.txt → docs-old.txt</span>
@@ -135,7 +135,7 @@
135 </summary>135 </summary>
136 <p class="note pad">renamed, 100% similar</p>136 <p class="note pad">renamed, 100% similar</p>
137 </details>137 </details>
138-<details class="dfile" id="f2" data-file open>138+<details class="dfile lnw-2" id="f2" data-file open>
139 <summary>139 <summary>
140 <span class="st">D</span>140 <span class="st">D</span>
141 <span class="p">gone.txt</span>141 <span class="p">gone.txt</span>
@@ -193,33 +193,35 @@
193 </h2>193 </h2>
194 <ol class="files" aria-label="Changed files">194 <ol class="files" aria-label="Changed files">
195 <li>195 <li>
196-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">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" title="R">R</span>197+<span class="st" data-st="R" title="renamed">R</span>
198-<span class="p" title="old.txt → docs-old.txt">old.txt → docs-old.txt</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>
199 <span class="n">205 <span class="n">
200 <span class="plus">+0</span>206 <span class="plus">+0</span>
201 <span class="minus">-0</span>207 <span class="minus">-0</span>
202 </span>208 </span>
203-<span class="b" aria-hidden="true">
204-<span class="plus">
205-</span>
206-<span class="minus">
207-</span>··········</span>
208 </a>209 </a>
209 </li>210 </li>
210 <li>211 <li>
211-<a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">212+<a href="#f2" title="gone.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
212-<span class="st" title="D">D</span>213+<span class="st" data-st="D" title="deleted">D</span>
213-<span class="p" title="gone.txt">gone.txt</span>214+<span class="nm">
215+<span class="p">gone.txt</span>
216+<span class="d">
217+<bdi>
218+</bdi>
219+</span>
220+</span>
214 <span class="n">221 <span class="n">
215 <span class="plus">+0</span>222 <span class="plus">+0</span>
216 <span class="minus">-1</span>223 <span class="minus">-1</span>
217 </span>224 </span>
218-<span class="b" aria-hidden="true">
219-<span class="plus">
220-</span>
221-<span class="minus">----------</span>
222-</span>
223 </a>225 </a>
224 </li>226 </li>
225 </ol>227 </ol>
@@ -471,7 +473,10 @@
471 k-split from:body -> ermite.layout('split');473 k-split from:body -> ermite.layout('split');
472 k-unified from:body -> ermite.layout('unified');474 k-unified from:body -> ermite.layout('unified');
473 k-collapse from:body -> ermite.collapse();475 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()">
475 </div>480 </div>
476 </body>481 </body>
477 </html>482 </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>
@@ -123,7 +123,7 @@
123 </button>123 </button>
124 </div>124 </div>
125 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">125 <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>
127 <summary>127 <summary>
128 <span class="st">A</span>128 <span class="st">A</span>
129 <span class="p">a.txt</span>129 <span class="p">a.txt</span>
@@ -245,7 +245,7 @@
245 </table>245 </table>
246 </div>246 </div>
247 </details>247 </details>
248-<details class="dfile" id="f2" data-file open>248+<details class="dfile lnw-2" id="f2" data-file open>
249 <summary>249 <summary>
250 <span class="st">A</span>250 <span class="st">A</span>
251 <span class="p">gone.txt</span>251 <span class="p">gone.txt</span>
@@ -295,7 +295,7 @@
295 </table>295 </table>
296 </div>296 </div>
297 </details>297 </details>
298-<details class="dfile" id="f3" data-file>298+<details class="dfile lnw-2" id="f3" data-file>
299 <summary>299 <summary>
300 <span class="st">A</span>300 <span class="st">A</span>
301 <span class="p">logo.bin</span>301 <span class="p">logo.bin</span>
@@ -305,7 +305,7 @@
305 </summary>305 </summary>
306 <p class="note pad">binary file: no text diff</p>306 <p class="note pad">binary file: no text diff</p>
307 </details>307 </details>
308-<details class="dfile" id="f4" data-file open>308+<details class="dfile lnw-2" id="f4" data-file open>
309 <summary>309 <summary>
310 <span class="st">A</span>310 <span class="st">A</span>
311 <span class="p">old.txt</span>311 <span class="p">old.txt</span>
@@ -391,7 +391,7 @@
391 </table>391 </table>
392 </div>392 </div>
393 </details>393 </details>
394-<details class="dfile" id="f5" data-file open>394+<details class="dfile lnw-2" id="f5" data-file open>
395 <summary>395 <summary>
396 <span class="st">A</span>396 <span class="st">A</span>
397 <span class="p">run.sh</span>397 <span class="p">run.sh</span>
@@ -467,75 +467,80 @@
467 </h2>467 </h2>
468 <ol class="files" aria-label="Changed files">468 <ol class="files" aria-label="Changed files">
469 <li>469 <li>
470-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">470+<a href="#f1" title="a.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
471-<span class="st" title="A">A</span>471+<span class="st" data-st="A" title="added">A</span>
472-<span class="p" title="a.txt">a.txt</span>472+<span class="nm">
473+<span class="p">a.txt</span>
474+<span class="d">
475+<bdi>
476+</bdi>
477+</span>
478+</span>
473 <span class="n">479 <span class="n">
474 <span class="plus">+5</span>480 <span class="plus">+5</span>
475 <span class="minus">-0</span>481 <span class="minus">-0</span>
476 </span>482 </span>
477-<span class="b" aria-hidden="true">
478-<span class="plus">++++++++++</span>
479-<span class="minus">
480-</span>
481-</span>
482 </a>483 </a>
483 </li>484 </li>
484 <li>485 <li>
485-<a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">486+<a href="#f2" title="gone.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
486-<span class="st" title="A">A</span>487+<span class="st" data-st="A" title="added">A</span>
487-<span class="p" title="gone.txt">gone.txt</span>488+<span class="nm">
489+<span class="p">gone.txt</span>
490+<span class="d">
491+<bdi>
492+</bdi>
493+</span>
494+</span>
488 <span class="n">495 <span class="n">
489 <span class="plus">+1</span>496 <span class="plus">+1</span>
490 <span class="minus">-0</span>497 <span class="minus">-0</span>
491 </span>498 </span>
492-<span class="b" aria-hidden="true">
493-<span class="plus">++</span>
494-<span class="minus">
495-</span>········</span>
496 </a>499 </a>
497 </li>500 </li>
498 <li>501 <li>
499-<a href="#f3" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">502+<a href="#f3" title="logo.bin" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
500-<span class="st" title="A">A</span>503+<span class="st" data-st="A" title="added">A</span>
501-<span class="p" title="logo.bin">logo.bin</span>504+<span class="nm">
502-<span class="n">505+<span class="p">logo.bin</span>
503-<span class="plus">+0</span>506+<span class="d">
504-<span class="minus">-0</span>507+<bdi>
508+</bdi>
505 </span>509 </span>
506-<span class="b" aria-hidden="true">
507-<span class="plus">
508 </span>510 </span>
509-<span class="minus">511+<span class="n mute">bin</span>
510-</span>bin</span>
511 </a>512 </a>
512 </li>513 </li>
513 <li>514 <li>
514-<a href="#f4" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">515+<a href="#f4" title="old.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
515-<span class="st" title="A">A</span>516+<span class="st" data-st="A" title="added">A</span>
516-<span class="p" title="old.txt">old.txt</span>517+<span class="nm">
518+<span class="p">old.txt</span>
519+<span class="d">
520+<bdi>
521+</bdi>
522+</span>
523+</span>
517 <span class="n">524 <span class="n">
518 <span class="plus">+3</span>525 <span class="plus">+3</span>
519 <span class="minus">-0</span>526 <span class="minus">-0</span>
520 </span>527 </span>
521-<span class="b" aria-hidden="true">
522-<span class="plus">++++++</span>
523-<span class="minus">
524-</span>····</span>
525 </a>528 </a>
526 </li>529 </li>
527 <li>530 <li>
528-<a href="#f5" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">531+<a href="#f5" title="run.sh" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
529-<span class="st" title="A">A</span>532+<span class="st" data-st="A" title="added">A</span>
530-<span class="p" title="run.sh">run.sh</span>533+<span class="nm">
534+<span class="p">run.sh</span>
535+<span class="d">
536+<bdi>
537+</bdi>
538+</span>
539+</span>
531 <span class="n">540 <span class="n">
532 <span class="plus">+2</span>541 <span class="plus">+2</span>
533 <span class="minus">-0</span>542 <span class="minus">-0</span>
534 </span>543 </span>
535-<span class="b" aria-hidden="true">
536-<span class="plus">++++</span>
537-<span class="minus">
538-</span>······</span>
539 </a>544 </a>
540 </li>545 </li>
541 </ol>546 </ol>
@@ -787,7 +792,10 @@
787 k-split from:body -> ermite.layout('split');792 k-split from:body -> ermite.layout('split');
788 k-unified from:body -> ermite.layout('unified');793 k-unified from:body -> ermite.layout('unified');
789 k-collapse from:body -> ermite.collapse();794 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()">
791 </div>799 </div>
792 </body>800 </body>
793 </html>801 </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>
@@ -127,7 +127,7 @@
127 </div>127 </div>
128 <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>128 <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>
129 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">129 <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>
131 <summary>131 <summary>
132 <span class="st">A</span>132 <span class="st">A</span>
133 <span class="p">evil.go</span>133 <span class="p">evil.go</span>
@@ -317,18 +317,19 @@
317 </h2>317 </h2>
318 <ol class="files" aria-label="Changed files">318 <ol class="files" aria-label="Changed files">
319 <li>319 <li>
320-<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">320+<a href="#f1" title="evil.go" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))">
321-<span class="st" title="A">A</span>321+<span class="st" data-st="A" title="added">A</span>
322-<span class="p" title="evil.go">evil.go</span>322+<span class="nm">
323+<span class="p">evil.go</span>
324+<span class="d">
325+<bdi>
326+</bdi>
327+</span>
328+</span>
323 <span class="n">329 <span class="n">
324 <span class="plus">+8</span>330 <span class="plus">+8</span>
325 <span class="minus">-0</span>331 <span class="minus">-0</span>
326 </span>332 </span>
327-<span class="b" aria-hidden="true">
328-<span class="plus">++++++++++</span>
329-<span class="minus">
330-</span>
331-</span>
332 </a>333 </a>
333 </li>334 </li>
334 </ol>335 </ol>
@@ -580,7 +581,10 @@
580 k-split from:body -> ermite.layout('split');581 k-split from:body -> ermite.layout('split');
581 k-unified from:body -> ermite.layout('unified');582 k-unified from:body -> ermite.layout('unified');
582 k-collapse from:body -> ermite.collapse();583 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()">
584 </div>588 </div>
585 </body>589 </body>
586 </html>590 </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>
@@ -391,7 +391,10 @@
391 k-split from:body -> ermite.layout('split');391 k-split from:body -> ermite.layout('split');
392 k-unified from:body -> ermite.layout('unified');392 k-unified from:body -> ermite.layout('unified');
393 k-collapse from:body -> ermite.collapse();393 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()">
395 </div>398 </div>
396 </body>399 </body>
397 </html>400 </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>
@@ -406,7 +406,10 @@
406 k-split from:body -> ermite.layout('split');406 k-split from:body -> ermite.layout('split');
407 k-unified from:body -> ermite.layout('unified');407 k-unified from:body -> ermite.layout('unified');
408 k-collapse from:body -> ermite.collapse();408 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()">
410 </div>413 </div>
411 </body>414 </body>
412 </html>415 </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>
@@ -1291,7 +1291,10 @@
1291 k-split from:body -> ermite.layout('split');1291 k-split from:body -> ermite.layout('split');
1292 k-unified from:body -> ermite.layout('unified');1292 k-unified from:body -> ermite.layout('unified');
1293 k-collapse from:body -> ermite.collapse();1293 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()">
1295 </div>1298 </div>
1296 </body>1299 </body>
1297 </html>1300 </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>
@@ -572,7 +572,10 @@
572 k-split from:body -> ermite.layout('split');572 k-split from:body -> ermite.layout('split');
573 k-unified from:body -> ermite.layout('unified');573 k-unified from:body -> ermite.layout('unified');
574 k-collapse from:body -> ermite.collapse();574 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()">
576 </div>579 </div>
577 </body>580 </body>
578 </html>581 </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>
@@ -240,7 +240,10 @@
240 k-split from:body -> ermite.layout('split');240 k-split from:body -> ermite.layout('split');
241 k-unified from:body -> ermite.layout('unified');241 k-unified from:body -> ermite.layout('unified');
242 k-collapse from:body -> ermite.collapse();242 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()">
244 </div>247 </div>
245 </body>248 </body>
246 </html>249 </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>
@@ -278,7 +278,10 @@
278 k-split from:body -> ermite.layout('split');278 k-split from:body -> ermite.layout('split');
279 k-unified from:body -> ermite.layout('unified');279 k-unified from:body -> ermite.layout('unified');
280 k-collapse from:body -> ermite.collapse();280 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()">
282 </div>285 </div>
283 </body>286 </body>
284 </html>287 </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>
@@ -223,7 +223,10 @@
223 k-split from:body -> ermite.layout('split');223 k-split from:body -> ermite.layout('split');
224 k-unified from:body -> ermite.layout('unified');224 k-unified from:body -> ermite.layout('unified');
225 k-collapse from:body -> ermite.collapse();225 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()">
227 </div>230 </div>
228 </body>231 </body>
229 </html>232 </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>
@@ -223,7 +223,10 @@
223 k-split from:body -> ermite.layout('split');223 k-split from:body -> ermite.layout('split');
224 k-unified from:body -> ermite.layout('unified');224 k-unified from:body -> ermite.layout('unified');
225 k-collapse from:body -> ermite.collapse();225 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()">
227 </div>230 </div>
228 </body>231 </body>
229 </html>232 </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>
@@ -458,7 +458,10 @@
458 k-split from:body -> ermite.layout('split');458 k-split from:body -> ermite.layout('split');
459 k-unified from:body -> ermite.layout('unified');459 k-unified from:body -> ermite.layout('unified');
460 k-collapse from:body -> ermite.collapse();460 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()">
462 </div>465 </div>
463 </body>466 </body>
464 </html>467 </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>
@@ -487,7 +487,10 @@
487 k-split from:body -> ermite.layout('split');487 k-split from:body -> ermite.layout('split');
488 k-unified from:body -> ermite.layout('unified');488 k-unified from:body -> ermite.layout('unified');
489 k-collapse from:body -> ermite.collapse();489 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()">
491 </div>494 </div>
492 </body>495 </body>
493 </html>496 </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>
@@ -394,7 +394,10 @@
394 k-split from:body -> ermite.layout('split');394 k-split from:body -> ermite.layout('split');
395 k-unified from:body -> ermite.layout('unified');395 k-unified from:body -> ermite.layout('unified');
396 k-collapse from:body -> ermite.collapse();396 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()">
398 </div>401 </div>
399 </body>402 </body>
400 </html>403 </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>
@@ -621,7 +621,10 @@
621 k-split from:body -> ermite.layout('split');621 k-split from:body -> ermite.layout('split');
622 k-unified from:body -> ermite.layout('unified');622 k-unified from:body -> ermite.layout('unified');
623 k-collapse from:body -> ermite.collapse();623 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()">
625 </div>628 </div>
626 </body>629 </body>
627 </html>630 </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>
@@ -391,7 +391,10 @@
391 k-split from:body -> ermite.layout('split');391 k-split from:body -> ermite.layout('split');
392 k-unified from:body -> ermite.layout('unified');392 k-unified from:body -> ermite.layout('unified');
393 k-collapse from:body -> ermite.collapse();393 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()">
395 </div>398 </div>
396 </body>399 </body>
397 </html>400 </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>
@@ -416,7 +416,10 @@
416 k-split from:body -> ermite.layout('split');416 k-split from:body -> ermite.layout('split');
417 k-unified from:body -> ermite.layout('unified');417 k-unified from:body -> ermite.layout('unified');
418 k-collapse from:body -> ermite.collapse();418 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()">
420 </div>423 </div>
421 </body>424 </body>
422 </html>425 </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[^"]*)"`)
@@ -3,6 +3,7 @@
3 package app3 package app
4 4
5 import (5 import (
6+ "fmt"
6 "html"7 "html"
7 "net/http"8 "net/http"
8 "net/url"9 "net/url"
@@ -141,7 +142,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
141 assert.Contains(t, mr.body, "Finds things.\nSee ", "line breaks are kept")142 assert.Contains(t, mr.body, "Finds things.\nSee ", "line breaks are kept")
142 assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form")143 assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form")
143 assert.NotContains(t, mr.body, "ermite mr merge", "nor the merge line")144 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"`)
145 assert.Contains(t, mr.body, `href="/proj/commit/`+head+`"`, "the commits are in the rail")146 assert.Contains(t, mr.body, `href="/proj/commit/`+head+`"`, "the commits are in the rail")
146 assert.NotContains(t, strings.ToLower(mr.body), "proposal")147 assert.NotContains(t, strings.ToLower(mr.body), "proposal")
147 assertNoInline(t, mr.body)148 assertNoInline(t, mr.body)
@@ -157,10 +158,11 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
157 assert.Contains(t, own.hdr.Values("Vary"), "Cookie")158 assert.Contains(t, own.hdr.Values("Vary"), "Cookie")
158 assert.Equal(t, head, formHead(t, own.body))159 assert.Equal(t, head, formHead(t, own.body))
159 assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`)160 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, `id="merge-menu"`, "the merge is a menu of the bar above the diff")
161- assert.Contains(t, own.body, `<option value="squash">`)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">`)
162 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head)164 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>`)
164 assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default")166 assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default")
165 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash")167 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash")
166 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)168 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)
@@ -313,7 +315,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
313 assert.Contains(t, own.body, `action="/proj/merge-requests/1/read"`)315 assert.Contains(t, own.body, `action="/proj/merge-requests/1/read"`)
314 res = b.form(base+"/read", token, url.Values{"head_id": {head1}})316 res = b.form(base+"/read", token, url.Values{"head_id": {head1}})
315 require.Equal(t, http.StatusSeeOther, res.status, res.body)317 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")
317 r.writeFile(r.wt, "extra.txt", []byte("extra\n"))319 r.writeFile(r.wt, "extra.txt", []byte("extra\n"))
318 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")320 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
319 head2 := r.headOf("feat/search")321 head2 := r.headOf("feat/search")
@@ -322,7 +324,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
322 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)324 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)
323 assert.Contains(t, all.body, "since your last read")325 assert.Contains(t, all.body, "since your last read")
324 assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked")326 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")
326 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")328 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")
327 since := b.get(base+"/since", as(token))329 since := b.get(base+"/since", as(token))
328 require.Equal(t, 200, since.status, since.body)330 require.Equal(t, 200, since.status, since.body)
@@ -357,7 +359,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
357 assert.Contains(t, done.body, "fast-forward")359 assert.Contains(t, done.body, "fast-forward")
358 assert.Contains(t, done.body, `href="/proj/commit/`+head2+`"`)360 assert.Contains(t, done.body, `href="/proj/commit/`+head2+`"`)
359 assert.NotContains(t, done.body, "<form")361 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")
361 // Writes on a merged request are refused with a page.363 // Writes on a merged request are refused with a page.
362 late := b.form(base+"/comments", token, url.Values{"text": {"too late"}})364 late := b.form(base+"/comments", token, url.Values{"text": {"too late"}})
363 assert.Equal(t, http.StatusConflict, late.status)365 assert.Equal(t, http.StatusConflict, late.status)
@@ -628,3 +630,86 @@ func TestWebMergeRequestSinceAfterForcePush(t *testing.T) {
628 assert.Contains(t, lost.body, "is gone, the branch was rewritten")630 assert.Contains(t, lost.body, "is gone, the branch was rewritten")
629 assert.Contains(t, lost.body, "other.txt", "the whole change is shown")631 assert.Contains(t, lost.body, "other.txt", "the whole change is shown")
630 }632 }
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
@@ -493,7 +493,8 @@ type FileDiff struct {
493 // Similarity is a percentage for a renamed or copied file, 0 otherwise.493 // Similarity is a percentage for a renamed or copied file, 0 otherwise.
494 Similarity int494 Similarity int
495 // Truncated means the file exceeded a diff limit: Hunks is empty although495 // 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.
497 Truncated bool498 Truncated bool
498 Hunks []Hunk499 Hunks []Hunk
499 }500 }
@@ -502,9 +503,11 @@ type FileDiff struct {
502 // parent, or against the empty tree for a root commit.503 // parent, or against the empty tree for a root commit.
503 type Diff struct {504 type Diff struct {
504 // Files are ordered as Git reports them (by path). Files past505 // 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.
506 Files []FileDiff507 Files []FileDiff
507 // Truncated is true when files were left out or a listed file is truncated.508 // 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.
508 Truncated bool511 Truncated bool
509 }512 }
510 513
@@ -523,7 +526,8 @@ type CommitDetail struct {
523 526
524 // DiffLimits bound the size of a Diff. Lines are hunk lines, context included.527 // DiffLimits bound the size of a Diff. Lines are hunk lines, context included.
525 type DiffLimits struct {528 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).
527 MaxFiles int531 MaxFiles int
528 // MaxFileLines is the most lines one file may have; a file over it is532 // MaxFileLines is the most lines one file may have; a file over it is
529 // truncated.533 // truncated.
@@ -536,6 +540,13 @@ type DiffLimits struct {
536 // the memory of a diff whose lines are very long; files are truncated540 // the memory of a diff whose lines are very long; files are truncated
537 // like for MaxTotalLines.541 // like for MaxTotalLines.
538 MaxTotalBytes int542 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
539 }550 }
540 551
541 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000552 // 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
@@ -194,6 +194,26 @@ type MergeRequestDetail struct {
194 LastReadLost bool194 LastReadLost bool
195 }195 }
196 196
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+
197 // AddCommentParams describes the comment to add.217 // AddCommentParams describes the comment to add.
198 type AddCommentParams struct {218 type AddCommentParams struct {
199 // Path, Line and Side designate a line of the diff; all empty for a219 // 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 }
@@ -361,8 +361,8 @@ func (_c *MergeRequestService_Create_Call) RunAndReturn(run func(ctx context.Con
361 }361 }
362 362
363 // Get provides a mock function for the type MergeRequestService363 // 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) {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, sinceLastRead)365+ ret := _mock.Called(ctx, p, name, number, opts)
366 366
367 if len(ret) == 0 {367 if len(ret) == 0 {
368 panic("no return value specified for Get")368 panic("no return value specified for Get")
@@ -370,16 +370,16 @@ func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, n
370 370
371 var r0 ports.MergeRequestDetail371 var r0 ports.MergeRequestDetail
372 var r1 error372 var r1 error
373- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) (ports.MergeRequestDetail, error)); ok {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, sinceLastRead)374+ return returnFunc(ctx, p, name, number, opts)
375 }375 }
376- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) ports.MergeRequestDetail); ok {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, sinceLastRead)377+ r0 = returnFunc(ctx, p, name, number, opts)
378 } else {378 } else {
379 r0 = ret.Get(0).(ports.MergeRequestDetail)379 r0 = ret.Get(0).(ports.MergeRequestDetail)
380 }380 }
381- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) error); ok {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, sinceLastRead)382+ r1 = returnFunc(ctx, p, name, number, opts)
383 } else {383 } else {
384 r1 = ret.Error(1)384 r1 = ret.Error(1)
385 }385 }
@@ -396,12 +396,12 @@ type MergeRequestService_Get_Call struct {
396 // - p domain.Principal396 // - p domain.Principal
397 // - name domain.RepoName397 // - name domain.RepoName
398 // - number int64398 // - number int64
399-// - sinceLastRead bool399+// - opts ports.GetMergeRequestOptions
400-func (_e *MergeRequestService_Expecter) Get(ctx any, p any, name any, number any, sinceLastRead any) *MergeRequestService_Get_Call {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, sinceLastRead)}401+ return &MergeRequestService_Get_Call{Call: _e.mock.On("Get", ctx, p, name, number, opts)}
402 }402 }
403 403
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 {
405 _c.Call.Run(func(args mock.Arguments) {405 _c.Call.Run(func(args mock.Arguments) {
406 var arg0 context.Context406 var arg0 context.Context
407 if args[0] != nil {407 if args[0] != nil {
@@ -419,9 +419,9 @@ func (_c *MergeRequestService_Get_Call) Run(run func(ctx context.Context, p doma
419 if args[3] != nil {419 if args[3] != nil {
420 arg3 = args[3].(int64)420 arg3 = args[3].(int64)
421 }421 }
422- var arg4 bool422+ var arg4 ports.GetMergeRequestOptions
423 if args[4] != nil {423 if args[4] != nil {
424- arg4 = args[4].(bool)424+ arg4 = args[4].(ports.GetMergeRequestOptions)
425 }425 }
426 run(426 run(
427 arg0,427 arg0,
@@ -439,7 +439,7 @@ func (_c *MergeRequestService_Get_Call) Return(mergeRequestDetail ports.MergeReq
439 return _c439 return _c
440 }440 }
441 441
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 {
443 _c.Call.Return(run)443 _c.Call.Return(run)
444 return _c444 return _c
445 }445 }
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)
@@ -108,7 +108,7 @@ type EventService interface {
108 type MergeRequestService interface {108 type MergeRequestService interface {
109 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)109 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)
110 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)110 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)
112 AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error)112 AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error)
113 ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error)113 ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error)
114 Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)114 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
@@ -75,7 +75,7 @@ func TestPickRemoteRef(t *testing.T) {
75 75
76 // countingRemote answers ListRefs from a function and counts the calls.76 // countingRemote answers ListRefs from a function and counts the calls.
77 type countingRemote struct {77 type countingRemote struct {
78- calls int78+ calls int
79 answer func() ([]ports.RemoteRef, error)79 answer func() ([]ports.RemoteRef, error)
80 }80 }
81 81
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
@@ -7,6 +7,8 @@ import (
7 "encoding/base64"7 "encoding/base64"
8 "errors"8 "errors"
9 "fmt"9 "fmt"
10+ "math"
11+ "slices"
10 "strconv"12 "strconv"
11 "strings"13 "strings"
12 "sync"14 "sync"
@@ -419,12 +421,24 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain
419 // Get returns a merge request with its commits, its diff, its comments and the421 // Get returns a merge request with its commits, its diff, its comments and the
420 // read position. The diff compares the merge base of the target and the head422 // read position. The diff compares the merge base of the target and the head
421 // with the head ("target...head"), within domain.DefaultDiffLimits. With423 // with the head ("target...head"), within domain.DefaultDiffLimits. With
422-// sinceLastRead it compares the head recorded by MarkRead with the current424+// opts.SinceLastRead it compares the head recorded by MarkRead with the
423-// head instead, which works after a force-push as long as the recorded commit425+// current head instead, which works after a force-push as long as the recorded
424-// still exists; when none was recorded the diff is the default one, and when426+// commit still exists; when none was recorded the diff is the default one, and
425-// the recorded commit is gone too, with LastReadLost set. A merged or closed427+// when the recorded commit is gone too, with LastReadLost set. A merged or
426-// request keeps the head and the base it had.428+// closed 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) {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+ }
428 _, mr, err := m.get(ctx, p, name, number)442 _, mr, err := m.get(ctx, p, name, number)
429 if err != nil {443 if err != nil {
430 return ports.MergeRequestDetail{}, err444 return ports.MergeRequestDetail{}, err
@@ -432,6 +446,16 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
432 if mr, err = m.withHead(ctx, mr); err != nil {446 if mr, err = m.withHead(ctx, mr); err != nil {
433 return ports.MergeRequestDetail{}, err447 return ports.MergeRequestDetail{}, err
434 }448 }
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+ }
435 d := ports.MergeRequestDetail{MergeRequest: mr}459 d := ports.MergeRequestDetail{MergeRequest: mr}
436 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {460 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
437 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)461 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
446 }470 }
447 head := mr.HeadID471 head := mr.HeadID
448 if head.IsZero() {472 if head.IsZero() {
449- return d, nil // the source branch is gone: no commits, no diff473+ return d, checkBase(domain.ObjectID{}) // the source branch is gone: no commits, no diff
450 }474 }
451 if !mr.IsOpen() {475 if !mr.IsOpen() {
452 // After a squash the reviewed commits are only reachable from the476 // 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
455 case err != nil:479 case err != nil:
456 return ports.MergeRequestDetail{}, err480 return ports.MergeRequestDetail{}, err
457 case !ok:481 case !ok:
458- return d, nil // the reviewed commits are gone: no commits, no diff482+ // 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
459 }487 }
460 }488 }
461- if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil {489+ if !opts.DiffOnly {
462- return ports.MergeRequestDetail{}, err490+ if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil {
491+ return ports.MergeRequestDetail{}, err
492+ }
463 }493 }
464- if sinceLastRead && !d.LastReadID.IsZero() {494+ if opts.SinceLastRead && !d.LastReadID.IsZero() {
465 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {495 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {
466 case err != nil:496 case err != nil:
467 return ports.MergeRequestDetail{}, err497 return ports.MergeRequestDetail{}, err
@@ -471,11 +501,14 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
471 d.LastReadLost = true501 d.LastReadLost = true
472 }502 }
473 }503 }
504+ if err := checkBase(base); err != nil {
505+ return ports.MergeRequestDetail{}, err
506+ }
474 if base.IsZero() {507 if base.IsZero() {
475 return d, nil508 return d, nil
476 }509 }
477 d.DiffBaseID = base510 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 {
479 return ports.MergeRequestDetail{}, err512 return ports.MergeRequestDetail{}, err
480 }513 }
481 return d, nil514 return d, nil
@@ -601,16 +634,41 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
601 v.Add("path", "the merge request has no diff to comment on")634 v.Add("path", "the merge request has no diff to comment on")
602 return v.Err()635 return v.Err()
603 }636 }
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)
605 if err != nil {647 if err != nil {
606 return err648 return err
607 }649 }
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) {
609 v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side))659 v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side))
610 }660 }
611 return v.Err()661 return v.Err()
612 }662 }
613 663
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+
614 // ResolveComment marks a comment resolved or unresolved. The principal needs672 // ResolveComment marks a comment resolved or unresolved. The principal needs
615 // mr:write. An unknown comment gives domain.ErrNotFound and a request that is673 // mr:write. An unknown comment gives domain.ErrNotFound and a request that is
616 // not open domain.ErrPreconditionFailed. Setting the state the comment already674 // 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")
@@ -39,7 +39,7 @@ func TestMergeWithBranchLimitedToken(t *testing.T) {
39 }39 }
40 assert.Equal(t, main, r.branchHead("main"), "main did not move")40 assert.Equal(t, main, r.branchHead("main"), "main did not move")
41 assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays")41 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{})
43 require.NoError(t, err)43 require.NoError(t, err)
44 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)44 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
45 assert.Empty(t, r.drain(), "no event")45 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)
@@ -42,7 +42,7 @@ func TestMergeDeletesTheSourceBranch(t *testing.T) {
42 }, evs[1].(domain.PushEvent).Updates)42 }, evs[1].(domain.PushEvent).Updates)
43 43
44 // The merged request still reads, with its commits and diff.44 // 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{})
46 require.NoError(t, err)46 require.NoError(t, err)
47 assert.Len(t, d.Commits, 2)47 assert.Len(t, d.Commits, 2)
48 assert.Len(t, d.Diff.Files, 2)48 assert.Len(t, d.Diff.Files, 2)
@@ -82,7 +82,7 @@ func TestMergeRefusesToDeleteANeededBranch(t *testing.T) {
82 assert.Contains(t, err.Error(), "!2 still uses")82 assert.Contains(t, err.Error(), "!2 still uses")
83 assert.Equal(t, main, r.branchHead("main"), "not merged")83 assert.Equal(t, main, r.branchHead("main"), "not merged")
84 assert.Equal(t, head, r.branchHead("feat"))84 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{})
86 require.NoError(t, err)86 require.NoError(t, err)
87 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)87 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
88 })88 })
@@ -138,7 +138,7 @@ func TestSquashMergedRequestReadsAfterItsCommitsArePruned(t *testing.T) {
138 require.NoError(t, err)138 require.NoError(t, err)
139 require.False(t, ok, "the reviewed commits are pruned")139 require.False(t, ok, "the reviewed commits are pruned")
140 140
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{})
142 require.NoError(t, err)142 require.NoError(t, err)
143 assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)143 assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
144 assert.Equal(t, res.MergedCommitID, d.MergeRequest.MergedCommitID)144 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+}
@@ -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
@@ -345,7 +345,7 @@ func TestMergeRequestAccess(t *testing.T) {
345 return err345 return err
346 },346 },
347 "Get": func(p domain.Principal) error {347 "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{})
349 return err349 return err
350 },350 },
351 }351 }
@@ -356,7 +356,7 @@ func TestMergeRequestAccess(t *testing.T) {
356 assert.NoError(t, call(reader), what+" with repo:read")356 assert.NoError(t, call(reader), what+" with repo:read")
357 assert.NoError(t, call(root), what+" by root")357 assert.NoError(t, call(root), what+" by root")
358 }358 }
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{})
360 assert.ErrorIs(t, err, domain.ErrNotFound, "unknown number")360 assert.ErrorIs(t, err, domain.ErrNotFound, "unknown number")
361 _, err = r.mrs.List(r.ctx, root, name(t, "ghost"), ports.ListMergeRequestsParams{})361 _, err = r.mrs.List(r.ctx, root, name(t, "ghost"), ports.ListMergeRequestsParams{})
362 assert.ErrorIs(t, err, domain.ErrNotFound, "unknown repository")362 assert.ErrorIs(t, err, domain.ErrNotFound, "unknown repository")
@@ -505,7 +505,7 @@ func TestGetMergeRequest(t *testing.T) {
505 _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: "second"})505 _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: "second"})
506 require.NoError(t, err)506 require.NoError(t, err)
507 507
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{})
509 require.NoError(t, err)509 require.NoError(t, err)
510 assert.Equal(t, head, d.MergeRequest.HeadID)510 assert.Equal(t, head, d.MergeRequest.HeadID)
511 assert.Equal(t, main, d.DiffBaseID, "the base is the merge base of the target and the head")511 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) {
523 r.sh("checkout", "-q", "main")523 r.sh("checkout", "-q", "main")
524 r.commit(author, "later.txt", "x\n", "later on main")524 r.commit(author, "later.txt", "x\n", "later on main")
525 r.push("main")525 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{})
527 require.NoError(t, err)527 require.NoError(t, err)
528 assert.Equal(t, main, d.DiffBaseID)528 assert.Equal(t, main, d.DiffBaseID)
529 assert.Len(t, d.Commits, 2, "the commits that main lacks")529 assert.Len(t, d.Commits, 2, "the commits that main lacks")
530 530
531 // A deleted source branch: no commits, no diff, an empty head.531 // A deleted source branch: no commits, no diff, an empty head.
532 r.run(r.bare(), r.env(author), "branch", "-D", "feat")532 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{})
534 require.NoError(t, err)534 require.NoError(t, err)
535 assert.True(t, d.MergeRequest.HeadID.IsZero())535 assert.True(t, d.MergeRequest.HeadID.IsZero())
536 assert.Empty(t, d.Commits)536 assert.Empty(t, d.Commits)
@@ -548,7 +548,7 @@ func TestGetMergeRequestTruncatesCommits(t *testing.T) {
548 }548 }
549 r.push("long")549 r.push("long")
550 mr := r.open("long")550 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{})
552 require.NoError(t, err)552 require.NoError(t, err)
553 assert.Len(t, d.Commits, usecase.MaxMergeRequestCommits)553 assert.Len(t, d.Commits, usecase.MaxMergeRequestCommits)
554 assert.True(t, d.CommitsTruncated)554 assert.True(t, d.CommitsTruncated)
@@ -610,7 +610,7 @@ func TestAddComment(t *testing.T) {
610 }610 }
611 611
612 // Ids count 1, 2, ... in order of creation and only the valid ones are kept.612 // 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{})
614 require.NoError(t, err)614 require.NoError(t, err)
615 var kept int615 var kept int
616 for _, tc := range tests {616 for _, tc := range tests {
@@ -657,7 +657,7 @@ func TestConcurrentComments(t *testing.T) {
657 seen[id] = true657 seen[id] = true
658 }658 }
659 assert.Len(t, seen, workers)659 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{})
661 require.NoError(t, err)661 require.NoError(t, err)
662 assert.Len(t, d.Comments, workers)662 assert.Len(t, d.Comments, workers)
663 }663 }
@@ -752,7 +752,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) {
752 n := mr.Number752 n := mr.Number
753 753
754 // Nothing recorded: the flag changes nothing.754 // 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})
756 require.NoError(t, err)756 require.NoError(t, err)
757 assert.True(t, d.LastReadID.IsZero())757 assert.True(t, d.LastReadID.IsZero())
758 assert.False(t, d.LastReadLost)758 assert.False(t, d.LastReadLost)
@@ -762,7 +762,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) {
762 got, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, domain.ObjectID{})762 got, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, domain.ObjectID{})
763 require.NoError(t, err)763 require.NoError(t, err)
764 assert.Equal(t, head1, got)764 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{})
766 require.NoError(t, err)766 require.NoError(t, err)
767 assert.Equal(t, head1, d.LastReadID, "reported whether or not since_last_read is set")767 assert.Equal(t, head1, d.LastReadID, "reported whether or not since_last_read is set")
768 assert.Equal(t, base, d.DiffBaseID)768 assert.Equal(t, base, d.DiffBaseID)
@@ -771,7 +771,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) {
771 r.commit(author, "d.txt", "dee\n", "add d")771 r.commit(author, "d.txt", "dee\n", "add d")
772 head2 := r.head()772 head2 := r.head()
773 r.push("feat")773 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})
775 require.NoError(t, err)775 require.NoError(t, err)
776 assert.Equal(t, head1, d.DiffBaseID)776 assert.Equal(t, head1, d.DiffBaseID)
777 assert.Equal(t, head2, d.MergeRequest.HeadID)777 assert.Equal(t, head2, d.MergeRequest.HeadID)
@@ -791,7 +791,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) {
791 // An explicit head is recorded as given.791 // An explicit head is recorded as given.
792 _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, base)792 _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, base)
793 require.NoError(t, err)793 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})
795 require.NoError(t, err)795 require.NoError(t, err)
796 assert.Equal(t, base, d.DiffBaseID)796 assert.Equal(t, base, d.DiffBaseID)
797 }797 }
@@ -819,7 +819,7 @@ func TestSinceLastReadAfterForcePush(t *testing.T) {
819 // head1 is not an ancestor of head2 any more.819 // head1 is not an ancestor of head2 any more.
820 r.run(r.bare(), r.env(author), "cat-file", "-e", head1.String())820 r.run(r.bare(), r.env(author), "cat-file", "-e", head1.String())
821 821
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})
823 require.NoError(t, err)823 require.NoError(t, err)
824 assert.False(t, d.LastReadLost)824 assert.False(t, d.LastReadLost)
825 assert.Equal(t, head1, d.DiffBaseID, "the diff starts at the head that was read")825 assert.Equal(t, head1, d.DiffBaseID, "the diff starts at the head that was read")
@@ -838,14 +838,14 @@ func TestSinceLastReadAfterForcePush(t *testing.T) {
838 assert.Equal(t, []string{"see"}, removed)838 assert.Equal(t, []string{"see"}, removed)
839 839
840 // The whole change is still available without the flag.840 // 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{})
842 require.NoError(t, err)842 require.NoError(t, err)
843 assert.Equal(t, base, d.DiffBaseID)843 assert.Equal(t, base, d.DiffBaseID)
844 assert.Len(t, d.Diff.Files, 2)844 assert.Len(t, d.Diff.Files, 2)
845 845
846 // Once the recorded commit is pruned, the response says so.846 // Once the recorded commit is pruned, the response says so.
847 r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now")847 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})
849 require.NoError(t, err)849 require.NoError(t, err)
850 assert.True(t, d.LastReadLost, "the recorded head is gone")850 assert.True(t, d.LastReadLost, "the recorded head is gone")
851 assert.Equal(t, head1, d.LastReadID, "still reported")851 assert.Equal(t, head1, d.LastReadID, "still reported")
@@ -871,7 +871,7 @@ func TestMergeFastForward(t *testing.T) {
871 _ = main871 _ = main
872 872
873 // The stored request is the merged one, and reads keep a diff.873 // 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{})
875 require.NoError(t, err)875 require.NoError(t, err)
876 assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)876 assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
877 assert.Equal(t, head, d.MergeRequest.HeadID)877 assert.Equal(t, head, d.MergeRequest.HeadID)
@@ -940,7 +940,7 @@ func TestMergeRefusals(t *testing.T) {
940 assert.Contains(t, err.Error(), "branch moved, read again")940 assert.Contains(t, err.Error(), "branch moved, read again")
941 assert.NotEqual(t, head, r.branchHead("main"), "main did not move")941 assert.NotEqual(t, head, r.branchHead("main"), "main did not move")
942 // Reading again gives the new head, which merges.942 // 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{})
944 require.NoError(t, err)944 require.NoError(t, err)
945 _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: d.MergeRequest.HeadID})945 _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: d.MergeRequest.HeadID})
946 require.NoError(t, err)946 require.NoError(t, err)
@@ -962,7 +962,7 @@ func TestMergeRefusals(t *testing.T) {
962 assert.Contains(t, err.Error(), "rebase", mode)962 assert.Contains(t, err.Error(), "rebase", mode)
963 }963 }
964 assert.Equal(t, tip, r.branchHead("main"))964 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{})
966 require.NoError(t, err)966 require.NoError(t, err)
967 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)967 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
968 968
@@ -1081,14 +1081,14 @@ func TestConcurrentMergesSerialize(t *testing.T) {
1081 require.Len(t, won, 1, "exactly one merge request was merged: %v", won)1081 require.Len(t, won, 1, "exactly one merge request was merged: %v", won)
1082 for number, times := range won {1082 for number, times := range won {
1083 assert.Equal(t, 1, times, "!%d merged twice", number)1083 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{})
1085 require.NoError(t, err)1085 require.NoError(t, err)
1086 assert.Equal(t, domain.MRStateMerged, mr.MergeRequest.State)1086 assert.Equal(t, domain.MRStateMerged, mr.MergeRequest.State)
1087 assert.Equal(t, mr.MergeRequest.MergedCommitID, r.branchHead("main"))1087 assert.Equal(t, mr.MergeRequest.MergedCommitID, r.branchHead("main"))
1088 }1088 }
1089 for number := range heads {1089 for number := range heads {
1090 if won[number] == 0 {1090 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{})
1092 require.NoError(t, err)1092 require.NoError(t, err)
1093 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State, "!%d lost and stays open", number)1093 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State, "!%d lost and stays open", number)
1094 }1094 }
@@ -1275,7 +1275,7 @@ func TestAgentPresetWorksOnItsPrivateRepository(t *testing.T) {
1275 // repo:write alone does not read merge requests.1275 // repo:write alone does not read merge requests.
1276 _, err = r.mrs.List(r.ctx, agent, r.repo, ports.ListMergeRequestsParams{})1276 _, err = r.mrs.List(r.ctx, agent, r.repo, ports.ListMergeRequestsParams{})
1277 assert.ErrorIs(t, err, domain.ErrNotFound)1277 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{})
1279 assert.ErrorIs(t, err, domain.ErrNotFound)1279 assert.ErrorIs(t, err, domain.ErrNotFound)
1280 1280
1281 closed, err := r.mrs.Close(r.ctx, agent, r.repo, mr.Number)1281 closed, err := r.mrs.Close(r.ctx, agent, r.repo, mr.Number)
@@ -1328,7 +1328,7 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) {
1328 }1328 }
1329 _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "one too many"})1329 _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "one too many"})
1330 assert.ErrorIs(t, err, domain.ErrResourceExhausted)1330 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{})
1332 require.NoError(t, err)1332 require.NoError(t, err)
1333 assert.Len(t, d.Comments, usecase.MaxCommentsPerMergeRequest)1333 assert.Len(t, d.Comments, usecase.MaxCommentsPerMergeRequest)
1334 1334

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