detached 032b9ef

feat(web): show the lines a diff hides (e / E) (#54)

commit
032b9efee6dcade40d71e6fd7bf5c696373fad80
author
brice · · 2026-10-17 23:52 UTC
committer
bnema · · 2026-10-17 23:52 UTC
parent
7ecd377
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.20.0
48 files · +1136 -96
M internal/adapters/web/commit.go +1 -0
@@ -122,6 +122,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
122122 Dels: res.Stats.Deletions,
123123 Files: diffFiles(res.Diff.Files),
124124 }
125+ contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
125126 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
126127 p.Committer = &cm
127128 }
@@ -122,6 +122,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
122 Dels: res.Stats.Deletions,122 Dels: res.Stats.Deletions,
123 Files: diffFiles(res.Diff.Files),123 Files: diffFiles(res.Diff.Files),
124 }124 }
125+ contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
125 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {126 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
126 p.Committer = &cm127 p.Committer = &cm
127 }128 }
M internal/adapters/web/compare.go +1 -0
@@ -87,6 +87,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
8787 }
8888 p.HasDiff = true
8989 p.Files = diffFiles(res.Diff.Files)
90+ contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
9091 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
9192 if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {
9293 p.Note = n
@@ -87,6 +87,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
87 }87 }
88 p.HasDiff = true88 p.HasDiff = true
89 p.Files = diffFiles(res.Diff.Files)89 p.Files = diffFiles(res.Diff.Files)
90+ contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
90 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions91 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
91 if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {92 if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {
92 p.Note = n93 p.Note = n
A internal/adapters/web/context.go +111 -0
@@ -0,0 +1,111 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "strconv"
9+ "strings"
10+
11+ "github.com/a-h/templ"
12+
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+// maxContextLines bounds the lines one gap loads: the rest stays behind a
17+// smaller gap that loads the next lines.
18+const maxContextLines = 2000
19+
20+// ContextRows is the hidden lines of a gap, for both layouts: they replace the
21+// rows of the gap. More is the gap that is left, when the file goes on.
22+type ContextRows struct {
23+ Lines []DiffLine
24+ More *DiffGap
25+}
26+
27+// diffContext is GET /<repo>/context/<commit>?path=&old=&new=&n=: the lines of
28+// a file of a diff that its hunks hide, as rows of the diff tables. commit is
29+// the full id of the commit of the new side; path the file there; old and new
30+// the numbers of the first hidden line on each side, which move together (the
31+// lines are unchanged); n the number of lines, 0 for the rest of the file.
32+//
33+//hexcheck:ignore suspicious-business-logic-in-adapter cuts a file the use case opened into rows of the diff; no rule is decided here
34+func (s *Server) diffContext(ctx context.Context, r *http.Request) (templ.Component, int, error) {
35+ sha := r.PathValue("sha")
36+ if !isFullID(sha) {
37+ return nil, 0, errNotFound
38+ }
39+ q := r.URL.Query()
40+ num := func(k string, least int) (int, bool) {
41+ v, err := strconv.Atoi(q.Get(k))
42+ return v, err == nil && v >= least && strconv.Itoa(v) == q.Get(k)
43+ }
44+ oldFrom, ok1 := num("old", 1)
45+ newFrom, ok2 := num("new", 1)
46+ n, ok3 := num("n", 0)
47+ path := q.Get("path")
48+ if !ok1 || !ok2 || !ok3 || path == "" {
49+ return nil, 0, errNotFound
50+ }
51+ repo, err := domain.ParseRepoName(r.PathValue("repo"))
52+ if err != nil {
53+ return nil, 0, err
54+ }
55+ info, body, err := s.openBlobAt(ctx, repo, sha, path)
56+ if err != nil {
57+ return nil, 0, err
58+ }
59+ b := &blobStream{body: body, info: info}
60+ defer b.close()
61+ if info.Binary || info.Size > maxCodeBytes {
62+ return nil, 0, errNotFound
63+ }
64+ if err := b.fill(int(info.Size)); err != nil {
65+ return nil, 0, err
66+ }
67+ // Lines as Git numbers them: split on LF only (a lone CR is a character of
68+ // its line, shown as a marker; the CR of a CRLF is dropped by segments).
69+ text := strings.ToValidUTF8(string(b.buf), "\uFFFD")
70+ lines := strings.Split(text, "\n")
71+ noEOL := text != "" && !strings.HasSuffix(text, "\n")
72+ if !noEOL {
73+ lines = lines[:len(lines)-1]
74+ }
75+ if newFrom > len(lines)+1 {
76+ return nil, 0, errNotFound
77+ }
78+ end := len(lines)
79+ if n > 0 {
80+ end = min(end, newFrom-1+n)
81+ }
82+ cut := min(end, newFrom-1+maxContextLines)
83+
84+ var out ContextRows
85+ for i := newFrom - 1; i < cut; i++ {
86+ o, nw := oldFrom+i-(newFrom-1), i+1
87+ out.Lines = append(out.Lines, DiffLine{
88+ Kind: "ctx", Mark: " ", Segs: segments(lines[i]),
89+ Old: itoa(o), New: itoa(nw), oldN: o, newN: nw,
90+ NoEOL: noEOL && i == len(lines)-1,
91+ })
92+ }
93+ if cut < end {
94+ rest := 0
95+ if n > 0 {
96+ rest = end - cut
97+ }
98+ next := gapHref(r, oldFrom+cut-(newFrom-1), cut+1, rest)
99+ out.More = &DiffGap{Key: q.Get("key"), Lines: rest, Src: next}
100+ }
101+ return DiffContext(out), http.StatusOK, nil
102+}
103+
104+// gapHref is the address of the gap that starts at the lines old and new.
105+func gapHref(r *http.Request, old, new, n int) string {
106+ q := r.URL.Query()
107+ q.Set("old", itoa(old))
108+ q.Set("new", itoa(new))
109+ q.Set("n", itoa(n))
110+ return r.URL.Path + "?" + q.Encode()
111+}
@@ -0,0 +1,111 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "strconv"
9+ "strings"
10+
11+ "github.com/a-h/templ"
12+
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+// maxContextLines bounds the lines one gap loads: the rest stays behind a
17+// smaller gap that loads the next lines.
18+const maxContextLines = 2000
19+
20+// ContextRows is the hidden lines of a gap, for both layouts: they replace the
21+// rows of the gap. More is the gap that is left, when the file goes on.
22+type ContextRows struct {
23+ Lines []DiffLine
24+ More *DiffGap
25+}
26+
27+// diffContext is GET /<repo>/context/<commit>?path=&old=&new=&n=: the lines of
28+// a file of a diff that its hunks hide, as rows of the diff tables. commit is
29+// the full id of the commit of the new side; path the file there; old and new
30+// the numbers of the first hidden line on each side, which move together (the
31+// lines are unchanged); n the number of lines, 0 for the rest of the file.
32+//
33+//hexcheck:ignore suspicious-business-logic-in-adapter cuts a file the use case opened into rows of the diff; no rule is decided here
34+func (s *Server) diffContext(ctx context.Context, r *http.Request) (templ.Component, int, error) {
35+ sha := r.PathValue("sha")
36+ if !isFullID(sha) {
37+ return nil, 0, errNotFound
38+ }
39+ q := r.URL.Query()
40+ num := func(k string, least int) (int, bool) {
41+ v, err := strconv.Atoi(q.Get(k))
42+ return v, err == nil && v >= least && strconv.Itoa(v) == q.Get(k)
43+ }
44+ oldFrom, ok1 := num("old", 1)
45+ newFrom, ok2 := num("new", 1)
46+ n, ok3 := num("n", 0)
47+ path := q.Get("path")
48+ if !ok1 || !ok2 || !ok3 || path == "" {
49+ return nil, 0, errNotFound
50+ }
51+ repo, err := domain.ParseRepoName(r.PathValue("repo"))
52+ if err != nil {
53+ return nil, 0, err
54+ }
55+ info, body, err := s.openBlobAt(ctx, repo, sha, path)
56+ if err != nil {
57+ return nil, 0, err
58+ }
59+ b := &blobStream{body: body, info: info}
60+ defer b.close()
61+ if info.Binary || info.Size > maxCodeBytes {
62+ return nil, 0, errNotFound
63+ }
64+ if err := b.fill(int(info.Size)); err != nil {
65+ return nil, 0, err
66+ }
67+ // Lines as Git numbers them: split on LF only (a lone CR is a character of
68+ // its line, shown as a marker; the CR of a CRLF is dropped by segments).
69+ text := strings.ToValidUTF8(string(b.buf), "\uFFFD")
70+ lines := strings.Split(text, "\n")
71+ noEOL := text != "" && !strings.HasSuffix(text, "\n")
72+ if !noEOL {
73+ lines = lines[:len(lines)-1]
74+ }
75+ if newFrom > len(lines)+1 {
76+ return nil, 0, errNotFound
77+ }
78+ end := len(lines)
79+ if n > 0 {
80+ end = min(end, newFrom-1+n)
81+ }
82+ cut := min(end, newFrom-1+maxContextLines)
83+
84+ var out ContextRows
85+ for i := newFrom - 1; i < cut; i++ {
86+ o, nw := oldFrom+i-(newFrom-1), i+1
87+ out.Lines = append(out.Lines, DiffLine{
88+ Kind: "ctx", Mark: " ", Segs: segments(lines[i]),
89+ Old: itoa(o), New: itoa(nw), oldN: o, newN: nw,
90+ NoEOL: noEOL && i == len(lines)-1,
91+ })
92+ }
93+ if cut < end {
94+ rest := 0
95+ if n > 0 {
96+ rest = end - cut
97+ }
98+ next := gapHref(r, oldFrom+cut-(newFrom-1), cut+1, rest)
99+ out.More = &DiffGap{Key: q.Get("key"), Lines: rest, Src: next}
100+ }
101+ return DiffContext(out), http.StatusOK, nil
102+}
103+
104+// gapHref is the address of the gap that starts at the lines old and new.
105+func gapHref(r *http.Request, old, new, n int) string {
106+ q := r.URL.Query()
107+ q.Set("old", itoa(old))
108+ q.Set("new", itoa(new))
109+ q.Set("n", itoa(n))
110+ return r.URL.Path + "?" + q.Encode()
111+}
M internal/adapters/web/diff.go +83 -0
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "html"
99 "io"
10+ "net/url"
1011 "slices"
1112 "strings"
1213 "unicode/utf8"
@@ -78,6 +79,28 @@ type DiffHunk struct {
7879 Header string
7980 Lines []DiffLine
8081 Split []SplitRow
82+ // Gap is the unchanged lines before the hunk that the diff does not show,
83+ // nil when there are none or they cannot be loaded.
84+ Gap *DiffGap
85+}
86+
87+// DiffGap is a run of unchanged lines a diff hides: a row of the diff that
88+// loads them (diffContext) and takes their place.
89+type DiffGap struct {
90+ // Key tells the gap apart within its file: the unified and the split
91+ // tables hold one row each for it.
92+ Key string
93+ // Lines is the number of hidden lines, 0 for the rest of the file.
94+ Lines int
95+ Src string
96+}
97+
98+// Label is the text of the row of the gap.
99+func (g DiffGap) Label() string {
100+ if g.Lines == 0 {
101+ return "show the rest of the file"
102+ }
103+ return plural(g.Lines, "hidden line", "hidden lines")
81104 }
82105
83106 // DiffFile is one changed file.
@@ -121,6 +144,66 @@ type DiffFile struct {
121144 // Loose are the comments of a file loaded with its window that are on a
122145 // line its diff does not show: listed at the end of the file.
123146 Loose []CommentView
147+ // Tail is the rest of the file after its last hunk, nil when it cannot be
148+ // loaded. Its length is not known until it is.
149+ Tail *DiffGap
150+}
151+
152+// diffContextLines is the number of unchanged lines Git shows around a change
153+// (the -U3 of the diffs of the Git adapter, Git's default).
154+const diffContextLines = 3
155+
156+// contextGaps gives the files of a diff the gaps around their hunks, which
157+// load the hidden lines of the new side from base (/<repo>/context/<commit>,
158+// the commit of the new side). Only a file whose two sides hold text has
159+// them: an added or a deleted file shows every line already.
160+func contextGaps(out []DiffFile, files []domain.FileDiff, base string) {
161+ for i, f := range files {
162+ if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
163+ continue
164+ }
165+ switch f.Status {
166+ case domain.FileModified, domain.FileRenamed, domain.FileCopied:
167+ default:
168+ continue
169+ }
170+ gap := func(key string, oldFrom, newFrom, n int) *DiffGap {
171+ q := url.Values{}
172+ q.Set("path", f.NewPath)
173+ q.Set("old", itoa(oldFrom))
174+ q.Set("new", itoa(newFrom))
175+ q.Set("n", itoa(n))
176+ q.Set("key", key)
177+ return &DiffGap{Key: key, Lines: n, Src: base + "?" + q.Encode()}
178+ }
179+ // first is the first line of a side of a hunk: a side without lines
180+ // names the line before the hunk ("-5,0").
181+ first := func(start, lines int) int {
182+ if lines == 0 {
183+ return start + 1
184+ }
185+ return start
186+ }
187+ oldNext, newNext := 1, 1
188+ for j, h := range f.Hunks {
189+ oldFirst, newFirst := first(h.OldStart, h.OldLines), first(h.NewStart, h.NewLines)
190+ if n := newFirst - newNext; n > 0 && n == oldFirst-oldNext {
191+ out[i].Hunks[j].Gap = gap("g"+itoa(j), oldNext, newNext, n)
192+ }
193+ oldNext, newNext = oldFirst+h.OldLines, newFirst+h.NewLines
194+ }
195+ // Git ends a hunk diffContextLines lines after its last change, or at
196+ // the end of the file: fewer lines after the last change mean the
197+ // file ends there.
198+ last := f.Hunks[len(f.Hunks)-1].Lines
199+ trailing := 0
200+ for k := len(last) - 1; k >= 0 && last[k].Kind == domain.LineContext && !last[k].NoNewlineAtEOF; k-- {
201+ trailing++
202+ }
203+ if trailing >= diffContextLines {
204+ out[i].Tail = gap("tail", oldNext, newNext, 0)
205+ }
206+ }
124207 }
125208
126209 // gutterDigits is the number of digits of the largest line number of the
@@ -7,6 +7,7 @@ import (
7 "fmt"7 "fmt"
8 "html"8 "html"
9 "io"9 "io"
10+ "net/url"
10 "slices"11 "slices"
11 "strings"12 "strings"
12 "unicode/utf8"13 "unicode/utf8"
@@ -78,6 +79,28 @@ type DiffHunk struct {
78 Header string79 Header string
79 Lines []DiffLine80 Lines []DiffLine
80 Split []SplitRow81 Split []SplitRow
82+ // Gap is the unchanged lines before the hunk that the diff does not show,
83+ // nil when there are none or they cannot be loaded.
84+ Gap *DiffGap
85+}
86+
87+// DiffGap is a run of unchanged lines a diff hides: a row of the diff that
88+// loads them (diffContext) and takes their place.
89+type DiffGap struct {
90+ // Key tells the gap apart within its file: the unified and the split
91+ // tables hold one row each for it.
92+ Key string
93+ // Lines is the number of hidden lines, 0 for the rest of the file.
94+ Lines int
95+ Src string
96+}
97+
98+// Label is the text of the row of the gap.
99+func (g DiffGap) Label() string {
100+ if g.Lines == 0 {
101+ return "show the rest of the file"
102+ }
103+ return plural(g.Lines, "hidden line", "hidden lines")
81 }104 }
82 105
83 // DiffFile is one changed file.106 // DiffFile is one changed file.
@@ -121,6 +144,66 @@ type DiffFile struct {
121 // Loose are the comments of a file loaded with its window that are on a144 // 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.145 // line its diff does not show: listed at the end of the file.
123 Loose []CommentView146 Loose []CommentView
147+ // Tail is the rest of the file after its last hunk, nil when it cannot be
148+ // loaded. Its length is not known until it is.
149+ Tail *DiffGap
150+}
151+
152+// diffContextLines is the number of unchanged lines Git shows around a change
153+// (the -U3 of the diffs of the Git adapter, Git's default).
154+const diffContextLines = 3
155+
156+// contextGaps gives the files of a diff the gaps around their hunks, which
157+// load the hidden lines of the new side from base (/<repo>/context/<commit>,
158+// the commit of the new side). Only a file whose two sides hold text has
159+// them: an added or a deleted file shows every line already.
160+func contextGaps(out []DiffFile, files []domain.FileDiff, base string) {
161+ for i, f := range files {
162+ if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
163+ continue
164+ }
165+ switch f.Status {
166+ case domain.FileModified, domain.FileRenamed, domain.FileCopied:
167+ default:
168+ continue
169+ }
170+ gap := func(key string, oldFrom, newFrom, n int) *DiffGap {
171+ q := url.Values{}
172+ q.Set("path", f.NewPath)
173+ q.Set("old", itoa(oldFrom))
174+ q.Set("new", itoa(newFrom))
175+ q.Set("n", itoa(n))
176+ q.Set("key", key)
177+ return &DiffGap{Key: key, Lines: n, Src: base + "?" + q.Encode()}
178+ }
179+ // first is the first line of a side of a hunk: a side without lines
180+ // names the line before the hunk ("-5,0").
181+ first := func(start, lines int) int {
182+ if lines == 0 {
183+ return start + 1
184+ }
185+ return start
186+ }
187+ oldNext, newNext := 1, 1
188+ for j, h := range f.Hunks {
189+ oldFirst, newFirst := first(h.OldStart, h.OldLines), first(h.NewStart, h.NewLines)
190+ if n := newFirst - newNext; n > 0 && n == oldFirst-oldNext {
191+ out[i].Hunks[j].Gap = gap("g"+itoa(j), oldNext, newNext, n)
192+ }
193+ oldNext, newNext = oldFirst+h.OldLines, newFirst+h.NewLines
194+ }
195+ // Git ends a hunk diffContextLines lines after its last change, or at
196+ // the end of the file: fewer lines after the last change mean the
197+ // file ends there.
198+ last := f.Hunks[len(f.Hunks)-1].Lines
199+ trailing := 0
200+ for k := len(last) - 1; k >= 0 && last[k].Kind == domain.LineContext && !last[k].NoNewlineAtEOF; k-- {
201+ trailing++
202+ }
203+ if trailing >= diffContextLines {
204+ out[i].Tail = gap("tail", oldNext, newNext, 0)
205+ }
206+ }
124 }207 }
125 208
126 // gutterDigits is the number of digits of the largest line number of the209 // gutterDigits is the number of digits of the largest line number of the
M internal/adapters/web/diff.templ +60 -0
@@ -212,6 +212,9 @@ templ DiffFileView(f DiffFile, p DiffView) {
212212 <table class="dt uni" aria-label={ f.Path + ", unified" }>
213213 for _, h := range f.Hunks {
214214 <tbody>
215+ if h.Gap != nil {
216+ @gapRow(*h.Gap, 4)
217+ }
215218 <tr class="hunk"><td colspan="4">{ h.Header }</td></tr>
216219 for _, ln := range h.Lines {
217220 <tr class={ ln.Kind }>
@@ -229,6 +232,11 @@ templ DiffFileView(f DiffFile, p DiffView) {
229232 }
230233 </tbody>
231234 }
235+ if f.Tail != nil {
236+ <tbody>
237+ @gapRow(*f.Tail, 4)
238+ </tbody>
239+ }
232240 </table>
233241 }
234242 if p.ShowSplit {
@@ -236,6 +244,9 @@ templ DiffFileView(f DiffFile, p DiffView) {
236244 <colgroup><col class="c-ln"/><col class="c-m"/><col/><col class="c-ln"/><col class="c-m"/><col/></colgroup>
237245 for _, h := range f.Hunks {
238246 <tbody>
247+ if h.Gap != nil {
248+ @gapRow(*h.Gap, 6)
249+ }
239250 <tr class="hunk"><td colspan="6">{ h.Header }</td></tr>
240251 for _, row := range h.Split {
241252 <tr>
@@ -250,6 +261,11 @@ templ DiffFileView(f DiffFile, p DiffView) {
250261 }
251262 </tbody>
252263 }
264+ if f.Tail != nil {
265+ <tbody>
266+ @gapRow(*f.Tail, 6)
267+ </tbody>
268+ }
253269 </table>
254270 }
255271 </div>
@@ -286,6 +302,50 @@ templ MRFilesMoved() {
286302 <p class="notice" data-moved>this merge request changed since the page was loaded — <a href="">reload</a></p>
287303 }
288304
305+// gapRow is the row of the hidden lines before a hunk, or of the rest of the
306+// file: a button that loads them in their place (ermite.js, ermite.expand).
307+// Without scripts nothing loads them: hidden.
308+templ gapRow(g DiffGap, cols int) {
309+ <tr class="gap" data-js data-gap={ g.Key }>
310+ <td colspan={ itoa(cols) }>
311+ <button type="button" data-src={ g.Src }>
312+ <span aria-hidden="true">⋯</span> { g.Label() }
313+ </button>
314+ </td>
315+ </tr>
316+}
317+
318+// DiffContext is the hidden lines of a gap, for /context: one body a layout,
319+// whose rows replace the rows of the gap in the table of that layout.
320+templ DiffContext(c ContextRows) {
321+ <table>
322+ <tbody data-for="uni">
323+ for _, ln := range c.Lines {
324+ <tr class="ctx">
325+ <td class="ln">{ ln.Old }</td>
326+ <td class="ln">{ ln.New }</td>
327+ <td class="m">{ ln.Mark }</td>
328+ <td class="tx">@codeLine(ln)</td>
329+ </tr>
330+ }
331+ if c.More != nil {
332+ @gapRow(*c.More, 4)
333+ }
334+ </tbody>
335+ <tbody data-for="spl">
336+ for i := range c.Lines {
337+ <tr>
338+ @splitSide(&c.Lines[i], true)
339+ @splitSide(&c.Lines[i], false)
340+ </tr>
341+ }
342+ if c.More != nil {
343+ @gapRow(*c.More, 6)
344+ }
345+ </tbody>
346+ </table>
347+}
348+
289349 // splitSide is one half of a row of the split view; a side without a line is
290350 // an empty cell.
291351 templ splitSide(ln *DiffLine, old bool) {
@@ -212,6 +212,9 @@ templ DiffFileView(f DiffFile, p DiffView) {
212 <table class="dt uni" aria-label={ f.Path + ", unified" }>212 <table class="dt uni" aria-label={ f.Path + ", unified" }>
213 for _, h := range f.Hunks {213 for _, h := range f.Hunks {
214 <tbody>214 <tbody>
215+ if h.Gap != nil {
216+ @gapRow(*h.Gap, 4)
217+ }
215 <tr class="hunk"><td colspan="4">{ h.Header }</td></tr>218 <tr class="hunk"><td colspan="4">{ h.Header }</td></tr>
216 for _, ln := range h.Lines {219 for _, ln := range h.Lines {
217 <tr class={ ln.Kind }>220 <tr class={ ln.Kind }>
@@ -229,6 +232,11 @@ templ DiffFileView(f DiffFile, p DiffView) {
229 }232 }
230 </tbody>233 </tbody>
231 }234 }
235+ if f.Tail != nil {
236+ <tbody>
237+ @gapRow(*f.Tail, 4)
238+ </tbody>
239+ }
232 </table>240 </table>
233 }241 }
234 if p.ShowSplit {242 if p.ShowSplit {
@@ -236,6 +244,9 @@ templ DiffFileView(f DiffFile, p DiffView) {
236 <colgroup><col class="c-ln"/><col class="c-m"/><col/><col class="c-ln"/><col class="c-m"/><col/></colgroup>244 <colgroup><col class="c-ln"/><col class="c-m"/><col/><col class="c-ln"/><col class="c-m"/><col/></colgroup>
237 for _, h := range f.Hunks {245 for _, h := range f.Hunks {
238 <tbody>246 <tbody>
247+ if h.Gap != nil {
248+ @gapRow(*h.Gap, 6)
249+ }
239 <tr class="hunk"><td colspan="6">{ h.Header }</td></tr>250 <tr class="hunk"><td colspan="6">{ h.Header }</td></tr>
240 for _, row := range h.Split {251 for _, row := range h.Split {
241 <tr>252 <tr>
@@ -250,6 +261,11 @@ templ DiffFileView(f DiffFile, p DiffView) {
250 }261 }
251 </tbody>262 </tbody>
252 }263 }
264+ if f.Tail != nil {
265+ <tbody>
266+ @gapRow(*f.Tail, 6)
267+ </tbody>
268+ }
253 </table>269 </table>
254 }270 }
255 </div>271 </div>
@@ -286,6 +302,50 @@ templ MRFilesMoved() {
286 <p class="notice" data-moved>this merge request changed since the page was loaded — <a href="">reload</a></p>302 <p class="notice" data-moved>this merge request changed since the page was loaded — <a href="">reload</a></p>
287 }303 }
288 304
305+// gapRow is the row of the hidden lines before a hunk, or of the rest of the
306+// file: a button that loads them in their place (ermite.js, ermite.expand).
307+// Without scripts nothing loads them: hidden.
308+templ gapRow(g DiffGap, cols int) {
309+ <tr class="gap" data-js data-gap={ g.Key }>
310+ <td colspan={ itoa(cols) }>
311+ <button type="button" data-src={ g.Src }>
312+ <span aria-hidden="true">⋯</span> { g.Label() }
313+ </button>
314+ </td>
315+ </tr>
316+}
317+
318+// DiffContext is the hidden lines of a gap, for /context: one body a layout,
319+// whose rows replace the rows of the gap in the table of that layout.
320+templ DiffContext(c ContextRows) {
321+ <table>
322+ <tbody data-for="uni">
323+ for _, ln := range c.Lines {
324+ <tr class="ctx">
325+ <td class="ln">{ ln.Old }</td>
326+ <td class="ln">{ ln.New }</td>
327+ <td class="m">{ ln.Mark }</td>
328+ <td class="tx">@codeLine(ln)</td>
329+ </tr>
330+ }
331+ if c.More != nil {
332+ @gapRow(*c.More, 4)
333+ }
334+ </tbody>
335+ <tbody data-for="spl">
336+ for i := range c.Lines {
337+ <tr>
338+ @splitSide(&c.Lines[i], true)
339+ @splitSide(&c.Lines[i], false)
340+ </tr>
341+ }
342+ if c.More != nil {
343+ @gapRow(*c.More, 6)
344+ }
345+ </tbody>
346+ </table>
347+}
348+
289 // splitSide is one half of a row of the split view; a side without a line is349 // splitSide is one half of a row of the split view; a side without a line is
290 // an empty cell.350 // an empty cell.
291 templ splitSide(ln *DiffLine, old bool) {351 templ splitSide(ln *DiffLine, old bool) {
M internal/adapters/web/diff_templ.go +339 -85 generated file: collapsed
M internal/adapters/web/diff_test.go +50 -0
@@ -141,3 +141,53 @@ func TestCodeLine(t *testing.T) {
141141 assert.Equal(t, `a &lt;b&gt; <span class="inv" title="invisible character">&lt;U+202E&gt;</span> c d`+
142142 `<span class="eol" title="no newline at end of file">`+noEOL+`</span>`, b.String())
143143 }
144+
145+// The gaps of a file: before a hunk the unchanged lines it does not show
146+// (the same number on each side), after the last hunk the rest of the file;
147+// none for a file whose lines are all in the diff.
148+func TestContextGaps(t *testing.T) {
149+ t.Parallel()
150+ ctx := func(o, n int) domain.Line { return line(domain.LineContext, o, n, "x") }
151+ files := []domain.FileDiff{
152+ {Status: domain.FileModified, NewPath: "a b.go", Hunks: []domain.Hunk{
153+ {OldStart: 1, OldLines: 2, NewStart: 1, NewLines: 2, Lines: []domain.Line{ctx(1, 1), ctx(2, 2)}},
154+ {OldStart: 10, OldLines: 3, NewStart: 10, NewLines: 3, Lines: []domain.Line{ctx(10, 10), ctx(11, 11), ctx(12, 12)}},
155+ }},
156+ {Status: domain.FileModified, NewPath: "b.go", Hunks: []domain.Hunk{
157+ // a pure deletion: the new side has no line, "+4,0" names line 4
158+ {OldStart: 5, OldLines: 1, NewStart: 4, NewLines: 0, Lines: []domain.Line{line(domain.LineDeletion, 5, 0, "x")}},
159+ }},
160+ {Status: domain.FileAdded, NewPath: "c.go", Hunks: []domain.Hunk{{OldStart: 0, NewStart: 1, NewLines: 1, Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
161+ {Status: domain.FileModified, NewPath: "d.go", Hunks: []domain.Hunk{
162+ {OldStart: 3, OldLines: 1, NewStart: 3, NewLines: 1, Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 3, Text: "end", NoNewlineAtEOF: true}}},
163+ }},
164+ }
165+ out := diffFiles(files)
166+ contextGaps(out, files, "/r/context/c")
167+
168+ a := out[0]
169+ assert.Nil(t, a.Hunks[0].Gap, "the first hunk starts at line 1")
170+ require.NotNil(t, a.Hunks[1].Gap)
171+ assert.Equal(t, 7, a.Hunks[1].Gap.Lines, "lines 3 to 9 on both sides")
172+ assert.Equal(t, "/r/context/c?key=g1&n=7&new=3&old=3&path=a+b.go", a.Hunks[1].Gap.Src)
173+ require.NotNil(t, a.Tail)
174+ assert.Equal(t, "/r/context/c?key=tail&n=0&new=13&old=13&path=a+b.go", a.Tail.Src)
175+
176+ b := out[1]
177+ require.NotNil(t, b.Hunks[0].Gap)
178+ assert.Equal(t, 4, b.Hunks[0].Gap.Lines, "lines 1 to 4 on both sides before the deleted line 5")
179+ assert.Nil(t, b.Tail, "a hunk that ends on a change ends the file")
180+
181+ assert.Nil(t, out[2].Tail, "an added file shows every line")
182+ assert.Nil(t, out[3].Tail, "the last hunk holds the end of the file")
183+ assert.NotNil(t, a.Tail, "three unchanged lines after the change: the file may go on")
184+ assert.NotNil(t, out[3].Hunks[0].Gap)
185+
186+ // Sides that do not move together (a parse the page cannot trust): no gap.
187+ odd := []domain.FileDiff{{Status: domain.FileModified, NewPath: "e.go", Hunks: []domain.Hunk{
188+ {OldStart: 10, OldLines: 1, NewStart: 12, NewLines: 1, Lines: []domain.Line{ctx(10, 12)}},
189+ }}}
190+ o := diffFiles(odd)
191+ contextGaps(o, odd, "/r/context/c")
192+ assert.Nil(t, o[0].Hunks[0].Gap)
193+}
@@ -141,3 +141,53 @@ func TestCodeLine(t *testing.T) {
141 assert.Equal(t, `a &lt;b&gt; <span class="inv" title="invisible character">&lt;U+202E&gt;</span> c d`+141 assert.Equal(t, `a &lt;b&gt; <span class="inv" title="invisible character">&lt;U+202E&gt;</span> c d`+
142 `<span class="eol" title="no newline at end of file">`+noEOL+`</span>`, b.String())142 `<span class="eol" title="no newline at end of file">`+noEOL+`</span>`, b.String())
143 }143 }
144+
145+// The gaps of a file: before a hunk the unchanged lines it does not show
146+// (the same number on each side), after the last hunk the rest of the file;
147+// none for a file whose lines are all in the diff.
148+func TestContextGaps(t *testing.T) {
149+ t.Parallel()
150+ ctx := func(o, n int) domain.Line { return line(domain.LineContext, o, n, "x") }
151+ files := []domain.FileDiff{
152+ {Status: domain.FileModified, NewPath: "a b.go", Hunks: []domain.Hunk{
153+ {OldStart: 1, OldLines: 2, NewStart: 1, NewLines: 2, Lines: []domain.Line{ctx(1, 1), ctx(2, 2)}},
154+ {OldStart: 10, OldLines: 3, NewStart: 10, NewLines: 3, Lines: []domain.Line{ctx(10, 10), ctx(11, 11), ctx(12, 12)}},
155+ }},
156+ {Status: domain.FileModified, NewPath: "b.go", Hunks: []domain.Hunk{
157+ // a pure deletion: the new side has no line, "+4,0" names line 4
158+ {OldStart: 5, OldLines: 1, NewStart: 4, NewLines: 0, Lines: []domain.Line{line(domain.LineDeletion, 5, 0, "x")}},
159+ }},
160+ {Status: domain.FileAdded, NewPath: "c.go", Hunks: []domain.Hunk{{OldStart: 0, NewStart: 1, NewLines: 1, Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
161+ {Status: domain.FileModified, NewPath: "d.go", Hunks: []domain.Hunk{
162+ {OldStart: 3, OldLines: 1, NewStart: 3, NewLines: 1, Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 3, Text: "end", NoNewlineAtEOF: true}}},
163+ }},
164+ }
165+ out := diffFiles(files)
166+ contextGaps(out, files, "/r/context/c")
167+
168+ a := out[0]
169+ assert.Nil(t, a.Hunks[0].Gap, "the first hunk starts at line 1")
170+ require.NotNil(t, a.Hunks[1].Gap)
171+ assert.Equal(t, 7, a.Hunks[1].Gap.Lines, "lines 3 to 9 on both sides")
172+ assert.Equal(t, "/r/context/c?key=g1&n=7&new=3&old=3&path=a+b.go", a.Hunks[1].Gap.Src)
173+ require.NotNil(t, a.Tail)
174+ assert.Equal(t, "/r/context/c?key=tail&n=0&new=13&old=13&path=a+b.go", a.Tail.Src)
175+
176+ b := out[1]
177+ require.NotNil(t, b.Hunks[0].Gap)
178+ assert.Equal(t, 4, b.Hunks[0].Gap.Lines, "lines 1 to 4 on both sides before the deleted line 5")
179+ assert.Nil(t, b.Tail, "a hunk that ends on a change ends the file")
180+
181+ assert.Nil(t, out[2].Tail, "an added file shows every line")
182+ assert.Nil(t, out[3].Tail, "the last hunk holds the end of the file")
183+ assert.NotNil(t, a.Tail, "three unchanged lines after the change: the file may go on")
184+ assert.NotNil(t, out[3].Hunks[0].Gap)
185+
186+ // Sides that do not move together (a parse the page cannot trust): no gap.
187+ odd := []domain.FileDiff{{Status: domain.FileModified, NewPath: "e.go", Hunks: []domain.Hunk{
188+ {OldStart: 10, OldLines: 1, NewStart: 12, NewLines: 1, Lines: []domain.Line{ctx(10, 12)}},
189+ }}}
190+ o := diffFiles(odd)
191+ contextGaps(o, odd, "/r/context/c")
192+ assert.Nil(t, o[0].Hunks[0].Gap)
193+}
M internal/adapters/web/keys.templ +2 -0
@@ -31,6 +31,8 @@ templ Keys() {
3131 k-split from:body -> ermite.layout('split');
3232 k-unified from:body -> ermite.layout('unified');
3333 k-collapse from:body -> ermite.collapse();
34+ k-expand from:body -> ermite.expandFile();
35+ k-expandAll from:body -> ermite.expandAll();
3436 k-permalink from:body -> ermite.permalink();
3537 k-merge from:body -> window.ermiteMR?.openMerge();
3638 k-read from:body -> window.ermiteMR?.markRead();
@@ -31,6 +31,8 @@ templ Keys() {
31 k-split from:body -> ermite.layout('split');31 k-split from:body -> ermite.layout('split');
32 k-unified from:body -> ermite.layout('unified');32 k-unified from:body -> ermite.layout('unified');
33 k-collapse from:body -> ermite.collapse();33 k-collapse from:body -> ermite.collapse();
34+ k-expand from:body -> ermite.expandFile();
35+ k-expandAll from:body -> ermite.expandAll();
34 k-permalink from:body -> ermite.permalink();36 k-permalink from:body -> ermite.permalink();
35 k-merge from:body -> window.ermiteMR?.openMerge();37 k-merge from:body -> window.ermiteMR?.openMerge();
36 k-read from:body -> window.ermiteMR?.markRead();38 k-read from:body -> window.ermiteMR?.markRead();
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/mr.go +1 -0
@@ -476,6 +476,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
476476 Dels: res.Stats.Deletions,
477477 Files: diffFiles(res.Diff.Files),
478478 }
479+ contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
479480 p.Opened.Name = p.Author
480481 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
481482 if !m.CreatedAt.IsZero() {
@@ -476,6 +476,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
476 Dels: res.Stats.Deletions,476 Dels: res.Stats.Deletions,
477 Files: diffFiles(res.Diff.Files),477 Files: diffFiles(res.Diff.Files),
478 }478 }
479+ contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
479 p.Opened.Name = p.Author480 p.Opened.Name = p.Author
480 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)481 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
481 if !m.CreatedAt.IsZero() {482 if !m.CreatedAt.IsZero() {
M internal/adapters/web/mr_files.go +1 -0
@@ -197,6 +197,7 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
197197 signed := SignedIn(ctx)
198198 canWrite := signed && m.State == domain.MRStateOpen && !since
199199 p := MRPage{Files: diffFiles(res.Diff.Files), LooseInFiles: true}
200+ contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
200201 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
201202 window := p.Files[from:min(from+mrWindow, len(p.Files))]
202203 // The layouts follow the page: it sends ?view= (the layout shown) and
@@ -197,6 +197,7 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
197 signed := SignedIn(ctx)197 signed := SignedIn(ctx)
198 canWrite := signed && m.State == domain.MRStateOpen && !since198 canWrite := signed && m.State == domain.MRStateOpen && !since
199 p := MRPage{Files: diffFiles(res.Diff.Files), LooseInFiles: true}199 p := MRPage{Files: diffFiles(res.Diff.Files), LooseInFiles: true}
200+ contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
200 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)201 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 window := p.Files[from:min(from+mrWindow, len(p.Files))]
202 // The layouts follow the page: it sends ?view= (the layout shown) and203 // The layouts follow the page: it sends ?view= (the layout shown) and
M internal/adapters/web/overlays.templ +1 -0
@@ -22,6 +22,7 @@ templ OverlayHelp(mr bool) {
2222 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
2323 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
2424 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
25+ <div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div>
2526 <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>
2627 if mr {
2728 <div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div>
@@ -22,6 +22,7 @@ templ OverlayHelp(mr bool) {
22 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>22 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
23 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>23 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
24 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>24 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
25+ <div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div>
25 <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>26 <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>
26 if mr {27 if mr {
27 <div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div>28 <div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div>
M internal/adapters/web/overlays_templ.go +9 -9 generated file: collapsed
M internal/adapters/web/static/css/50-code.css +9 -0
@@ -80,6 +80,15 @@
8080 & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); }
8181 & .tx { white-space: pre; }
8282 & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; }
83+ /* a run of hidden lines: the whole row loads them */
84+ & .gap td { padding: 0; background: var(--paper-alt); }
85+ & .gap button {
86+ display: block; width: 100%; padding: 2px 1ch; text-align: left; cursor: pointer;
87+ font: inherit; color: var(--ink-dim); background: none; border: 0;
88+ border-block: 1px dashed var(--border);
89+ }
90+ & .gap button:hover, & .gap button:focus-visible { color: var(--ink-hi); background: var(--bg-strong); }
91+ & .gap button:disabled { cursor: progress; opacity: .6; }
8392 & .add, & tr.add td { background: var(--add); }
8493 & .del, & tr.del td { background: var(--del); }
8594 & .add.m, & tr.add .m { color: var(--teal); font-weight: bold; }
@@ -80,6 +80,15 @@
80 & .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); }
81 & .tx { white-space: pre; }81 & .tx { white-space: pre; }
82 & .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+ /* a run of hidden lines: the whole row loads them */
84+ & .gap td { padding: 0; background: var(--paper-alt); }
85+ & .gap button {
86+ display: block; width: 100%; padding: 2px 1ch; text-align: left; cursor: pointer;
87+ font: inherit; color: var(--ink-dim); background: none; border: 0;
88+ border-block: 1px dashed var(--border);
89+ }
90+ & .gap button:hover, & .gap button:focus-visible { color: var(--ink-hi); background: var(--bg-strong); }
91+ & .gap button:disabled { cursor: progress; opacity: .6; }
83 & .add, & tr.add td { background: var(--add); }92 & .add, & tr.add td { background: var(--add); }
84 & .del, & tr.del td { background: var(--del); }93 & .del, & tr.del td { background: var(--del); }
85 & .add.m, & tr.add .m { color: var(--teal); font-weight: bold; }94 & .add.m, & tr.add .m { color: var(--teal); font-weight: bold; }
M internal/adapters/web/static/js/ermite.js +109 -1
@@ -10,6 +10,7 @@
1010 // 3. ermiteLive applies the fragments of the event stream (see events.go).
1111 // 4. The file of the diff in view is marked in the table of contents.
1212 // 5. A handler that fails is shown in the hint badge.
13+// 6. The lines a diff hides load in place (ermiteGaps; e and E).
1314 (() => {
1415 // Marks the page as scripted: what does nothing without scripts (the filters,
1516 // the "go to" and help buttons) is hidden until this class is set (see the
@@ -179,6 +180,26 @@
179180 const open = !files.some((d) => d.open);
180181 for (const d of files) d.open = open;
181182 },
183+ // e / E: the whole file in view, or every file of the diff, with every
184+ // line its hunks hide.
185+ expandFile() {
186+ const f = window.ermiteGaps.current();
187+ if (!f) return flash('no file in view');
188+ f.open = true;
189+ window.ermiteGaps.file(f).then((r) => {
190+ if (!r.done) return; // load said what failed
191+ flash(r.lines ? 'whole file' : 'nothing hidden in this file');
192+ });
193+ },
194+ async expandAll() {
195+ const files = $$('[data-file]').filter((f) => f.querySelector('tr.gap'));
196+ if (!files.length) return flash('nothing hidden in the loaded files');
197+ flash('loading every line…', 10000);
198+ for (const f of files) {
199+ if (!(await window.ermiteGaps.file(f)).done) return; // load said what failed
200+ }
201+ flash('every line of the loaded files');
202+ },
182203 nextNote() {
183204 const notes = $$('#diffs .cmt');
184205 if (!notes.length) return;
@@ -280,9 +301,96 @@
280301 };
281302 })();
282303
304+ // The hidden lines of a diff: a row .gap holds a button that loads them
305+ // (/context); the rows that come back replace the gap in the table of each
306+ // layout (the unified and the split tables hold one row each for it). A
307+ // file whose lines are all loaded has no gap left.
308+ window.ermiteGaps = (() => {
309+ const pending = new Map();
310+ // load fills the gap the button is in, in both tables of its file.
311+ function load(btn) {
312+ const src = btn.dataset.src;
313+ const row = btn.closest('tr.gap');
314+ const file = row?.closest('[data-file]');
315+ if (!src || !file) return Promise.resolve(0);
316+ if (pending.has(src)) return pending.get(src);
317+ const key = row.dataset.gap;
318+ const table = btn.closest('table');
319+ const focused = document.activeElement === btn;
320+ btn.disabled = true;
321+ const p = fetch(src, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
322+ .then(async (res) => {
323+ if (!res.ok) throw new Error('loading the hidden lines: ' + res.status);
324+ const t = document.createElement('template');
325+ t.innerHTML = await res.text();
326+ let n = 0;
327+ for (const table of file.querySelectorAll('table.dt')) {
328+ const kind = table.classList.contains('spl') ? 'spl' : 'uni';
329+ const from = t.content.querySelector('tbody[data-for="' + kind + '"]');
330+ const at = [...table.querySelectorAll('tr.gap')].find((r) => r.dataset.gap === key);
331+ if (!from || !at) continue;
332+ const rows = [...from.children].map((r) => r.cloneNode(true));
333+ n = Math.max(n, rows.filter((r) => !r.classList.contains('gap')).length);
334+ at.replaceWith(...rows);
335+ }
336+ // The focus was on the button, which is gone: on the gap that is
337+ // left in the same table, else on the content.
338+ if (focused && (document.activeElement === btn || document.activeElement === document.body)) {
339+ const next = [...(table?.querySelectorAll('tr.gap') || [])].find((r) => r.dataset.gap === key)?.querySelector('button');
340+ if (next) next.focus({ preventScroll: true });
341+ else document.getElementById('main')?.focus({ preventScroll: true });
342+ }
343+ return n;
344+ })
345+ .catch((err) => {
346+ btn.disabled = false;
347+ window.ermite?.flash?.(err.message, 4000);
348+ return -1;
349+ })
350+ .finally(() => pending.delete(src));
351+ pending.set(src, p);
352+ return p;
353+ }
354+ // file loads every gap of a file, until none is left (a long gap comes
355+ // back with a gap for the rest). A gap already loading is waited for.
356+ // done is false when a load failed (it said why) or the file never ran
357+ // out of gaps.
358+ async function file(f) {
359+ let lines = 0;
360+ for (let guard = 0; guard < 1000; guard++) {
361+ const btn = f.querySelector('tr.gap button[data-src]');
362+ if (!btn) return { done: true, lines };
363+ const n = await load(btn);
364+ if (n < 0) return { done: false, lines };
365+ if (btn.isConnected) {
366+ // an answer that did not take the gap's place: stop, not loop
367+ window.ermite?.flash?.('some hidden lines could not be shown', 4000);
368+ return { done: false, lines };
369+ }
370+ lines += n;
371+ }
372+ window.ermite?.flash?.('the file is too long to show every line at once', 4000);
373+ return { done: false, lines };
374+ }
375+ // current is the file in view: the one the table of contents marks, else
376+ // the last whose top is above the top third of the window.
377+ function current() {
378+ const a = document.querySelector('.files a[aria-current="true"]');
379+ const marked = a && document.getElementById(a.hash.slice(1));
380+ if (marked?.matches('[data-file]')) return marked;
381+ const files = [...document.querySelectorAll('[data-file]')];
382+ return files.findLast((f) => f.getBoundingClientRect().top < innerHeight / 3) ?? files[0] ?? null;
383+ }
384+ addEventListener('click', (e) => {
385+ const btn = e.target.closest?.('tr.gap button[data-src]');
386+ if (btn) load(btn);
387+ });
388+ return { load, file, current };
389+ })();
390+
283391 // 1. Keys.
284392 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
285- s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc',
393+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
286394 // the merge request page (mr.js): the merge menu, mark read
287395 M: 'merge', R: 'read' };
288396 // "g t" is the key "t": the tabs have one event each.
@@ -10,6 +10,7 @@
10 // 3. ermiteLive applies the fragments of the event stream (see events.go).10 // 3. ermiteLive applies the fragments of the event stream (see events.go).
11 // 4. The file of the diff in view is marked in the table of contents.11 // 4. The file of the diff in view is marked in the table of contents.
12 // 5. A handler that fails is shown in the hint badge.12 // 5. A handler that fails is shown in the hint badge.
13+// 6. The lines a diff hides load in place (ermiteGaps; e and E).
13 (() => {14 (() => {
14 // Marks the page as scripted: what does nothing without scripts (the filters,15 // Marks the page as scripted: what does nothing without scripts (the filters,
15 // the "go to" and help buttons) is hidden until this class is set (see the16 // the "go to" and help buttons) is hidden until this class is set (see the
@@ -179,6 +180,26 @@
179 const open = !files.some((d) => d.open);180 const open = !files.some((d) => d.open);
180 for (const d of files) d.open = open;181 for (const d of files) d.open = open;
181 },182 },
183+ // e / E: the whole file in view, or every file of the diff, with every
184+ // line its hunks hide.
185+ expandFile() {
186+ const f = window.ermiteGaps.current();
187+ if (!f) return flash('no file in view');
188+ f.open = true;
189+ window.ermiteGaps.file(f).then((r) => {
190+ if (!r.done) return; // load said what failed
191+ flash(r.lines ? 'whole file' : 'nothing hidden in this file');
192+ });
193+ },
194+ async expandAll() {
195+ const files = $$('[data-file]').filter((f) => f.querySelector('tr.gap'));
196+ if (!files.length) return flash('nothing hidden in the loaded files');
197+ flash('loading every line…', 10000);
198+ for (const f of files) {
199+ if (!(await window.ermiteGaps.file(f)).done) return; // load said what failed
200+ }
201+ flash('every line of the loaded files');
202+ },
182 nextNote() {203 nextNote() {
183 const notes = $$('#diffs .cmt');204 const notes = $$('#diffs .cmt');
184 if (!notes.length) return;205 if (!notes.length) return;
@@ -280,9 +301,96 @@
280 };301 };
281 })();302 })();
282 303
304+ // The hidden lines of a diff: a row .gap holds a button that loads them
305+ // (/context); the rows that come back replace the gap in the table of each
306+ // layout (the unified and the split tables hold one row each for it). A
307+ // file whose lines are all loaded has no gap left.
308+ window.ermiteGaps = (() => {
309+ const pending = new Map();
310+ // load fills the gap the button is in, in both tables of its file.
311+ function load(btn) {
312+ const src = btn.dataset.src;
313+ const row = btn.closest('tr.gap');
314+ const file = row?.closest('[data-file]');
315+ if (!src || !file) return Promise.resolve(0);
316+ if (pending.has(src)) return pending.get(src);
317+ const key = row.dataset.gap;
318+ const table = btn.closest('table');
319+ const focused = document.activeElement === btn;
320+ btn.disabled = true;
321+ const p = fetch(src, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
322+ .then(async (res) => {
323+ if (!res.ok) throw new Error('loading the hidden lines: ' + res.status);
324+ const t = document.createElement('template');
325+ t.innerHTML = await res.text();
326+ let n = 0;
327+ for (const table of file.querySelectorAll('table.dt')) {
328+ const kind = table.classList.contains('spl') ? 'spl' : 'uni';
329+ const from = t.content.querySelector('tbody[data-for="' + kind + '"]');
330+ const at = [...table.querySelectorAll('tr.gap')].find((r) => r.dataset.gap === key);
331+ if (!from || !at) continue;
332+ const rows = [...from.children].map((r) => r.cloneNode(true));
333+ n = Math.max(n, rows.filter((r) => !r.classList.contains('gap')).length);
334+ at.replaceWith(...rows);
335+ }
336+ // The focus was on the button, which is gone: on the gap that is
337+ // left in the same table, else on the content.
338+ if (focused && (document.activeElement === btn || document.activeElement === document.body)) {
339+ const next = [...(table?.querySelectorAll('tr.gap') || [])].find((r) => r.dataset.gap === key)?.querySelector('button');
340+ if (next) next.focus({ preventScroll: true });
341+ else document.getElementById('main')?.focus({ preventScroll: true });
342+ }
343+ return n;
344+ })
345+ .catch((err) => {
346+ btn.disabled = false;
347+ window.ermite?.flash?.(err.message, 4000);
348+ return -1;
349+ })
350+ .finally(() => pending.delete(src));
351+ pending.set(src, p);
352+ return p;
353+ }
354+ // file loads every gap of a file, until none is left (a long gap comes
355+ // back with a gap for the rest). A gap already loading is waited for.
356+ // done is false when a load failed (it said why) or the file never ran
357+ // out of gaps.
358+ async function file(f) {
359+ let lines = 0;
360+ for (let guard = 0; guard < 1000; guard++) {
361+ const btn = f.querySelector('tr.gap button[data-src]');
362+ if (!btn) return { done: true, lines };
363+ const n = await load(btn);
364+ if (n < 0) return { done: false, lines };
365+ if (btn.isConnected) {
366+ // an answer that did not take the gap's place: stop, not loop
367+ window.ermite?.flash?.('some hidden lines could not be shown', 4000);
368+ return { done: false, lines };
369+ }
370+ lines += n;
371+ }
372+ window.ermite?.flash?.('the file is too long to show every line at once', 4000);
373+ return { done: false, lines };
374+ }
375+ // current is the file in view: the one the table of contents marks, else
376+ // the last whose top is above the top third of the window.
377+ function current() {
378+ const a = document.querySelector('.files a[aria-current="true"]');
379+ const marked = a && document.getElementById(a.hash.slice(1));
380+ if (marked?.matches('[data-file]')) return marked;
381+ const files = [...document.querySelectorAll('[data-file]')];
382+ return files.findLast((f) => f.getBoundingClientRect().top < innerHeight / 3) ?? files[0] ?? null;
383+ }
384+ addEventListener('click', (e) => {
385+ const btn = e.target.closest?.('tr.gap button[data-src]');
386+ if (btn) load(btn);
387+ });
388+ return { load, file, current };
389+ })();
390+
283 // 1. Keys.391 // 1. Keys.
284 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',392 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
285- s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc',393+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
286 // the merge request page (mr.js): the merge menu, mark read394 // the merge request page (mr.js): the merge menu, mark read
287 M: 'merge', R: 'read' };395 M: 'merge', R: 'read' };
288 // "g t" is the key "t": the tabs have one event each.396 // "g t" is the key "t": the tabs have one event each.
M internal/adapters/web/web.go +1 -0
@@ -172,6 +172,7 @@ func (s *Server) routes() *http.ServeMux {
172172 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
173173 mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
174174 mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths)
175+ mux.HandleFunc("GET /{repo}/context/{sha}", s.page(s.diffContext))
175176 if s.cfg.MergeRequests != nil {
176177 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
177178 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
@@ -172,6 +172,7 @@ func (s *Server) routes() *http.ServeMux {
172 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))172 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
173 mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)173 mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
174 mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths)174 mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths)
175+ mux.HandleFunc("GET /{repo}/context/{sha}", s.page(s.diffContext))
175 if s.cfg.MergeRequests != nil {176 if s.cfg.MergeRequests != nil {
176 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))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/{$}", s.page(s.mrList))
M internal/app/testdata/web/blob-binary.html +9 -0
@@ -234,6 +234,13 @@
234234 </div>
235235 <div>
236236 <dt>
237+<kbd>e</kbd>
238+<kbd>E</kbd>
239+</dt>
240+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
241+</div>
242+<div>
243+<dt>
237244 <kbd>n</kbd>
238245 </dt>
239246 <dd>merge request: next note in the diff</dd>
@@ -383,6 +390,8 @@
383390 k-split from:body -> ermite.layout('split');
384391 k-unified from:body -> ermite.layout('unified');
385392 k-collapse from:body -> ermite.collapse();
393+ k-expand from:body -> ermite.expandFile();
394+ k-expandAll from:body -> ermite.expandAll();
386395 k-permalink from:body -> ermite.permalink();
387396 k-merge from:body -> window.ermiteMR?.openMerge();
388397 k-read from:body -> window.ermiteMR?.markRead();
@@ -234,6 +234,13 @@
234 </div>234 </div>
235 <div>235 <div>
236 <dt>236 <dt>
237+<kbd>e</kbd>
238+<kbd>E</kbd>
239+</dt>
240+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
241+</div>
242+<div>
243+<dt>
237 <kbd>n</kbd>244 <kbd>n</kbd>
238 </dt>245 </dt>
239 <dd>merge request: next note in the diff</dd>246 <dd>merge request: next note in the diff</dd>
@@ -383,6 +390,8 @@
383 k-split from:body -> ermite.layout('split');390 k-split from:body -> ermite.layout('split');
384 k-unified from:body -> ermite.layout('unified');391 k-unified from:body -> ermite.layout('unified');
385 k-collapse from:body -> ermite.collapse();392 k-collapse from:body -> ermite.collapse();
393+ k-expand from:body -> ermite.expandFile();
394+ k-expandAll from:body -> ermite.expandAll();
386 k-permalink from:body -> ermite.permalink();395 k-permalink from:body -> ermite.permalink();
387 k-merge from:body -> window.ermiteMR?.openMerge();396 k-merge from:body -> window.ermiteMR?.openMerge();
388 k-read from:body -> window.ermiteMR?.markRead();397 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/blob-code.html +9 -0
@@ -304,6 +304,13 @@
304304 </div>
305305 <div>
306306 <dt>
307+<kbd>e</kbd>
308+<kbd>E</kbd>
309+</dt>
310+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
311+</div>
312+<div>
313+<dt>
307314 <kbd>n</kbd>
308315 </dt>
309316 <dd>merge request: next note in the diff</dd>
@@ -453,6 +460,8 @@
453460 k-split from:body -> ermite.layout('split');
454461 k-unified from:body -> ermite.layout('unified');
455462 k-collapse from:body -> ermite.collapse();
463+ k-expand from:body -> ermite.expandFile();
464+ k-expandAll from:body -> ermite.expandAll();
456465 k-permalink from:body -> ermite.permalink();
457466 k-merge from:body -> window.ermiteMR?.openMerge();
458467 k-read from:body -> window.ermiteMR?.markRead();
@@ -304,6 +304,13 @@
304 </div>304 </div>
305 <div>305 <div>
306 <dt>306 <dt>
307+<kbd>e</kbd>
308+<kbd>E</kbd>
309+</dt>
310+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
311+</div>
312+<div>
313+<dt>
307 <kbd>n</kbd>314 <kbd>n</kbd>
308 </dt>315 </dt>
309 <dd>merge request: next note in the diff</dd>316 <dd>merge request: next note in the diff</dd>
@@ -453,6 +460,8 @@
453 k-split from:body -> ermite.layout('split');460 k-split from:body -> ermite.layout('split');
454 k-unified from:body -> ermite.layout('unified');461 k-unified from:body -> ermite.layout('unified');
455 k-collapse from:body -> ermite.collapse();462 k-collapse from:body -> ermite.collapse();
463+ k-expand from:body -> ermite.expandFile();
464+ k-expandAll from:body -> ermite.expandAll();
456 k-permalink from:body -> ermite.permalink();465 k-permalink from:body -> ermite.permalink();
457 k-merge from:body -> window.ermiteMR?.openMerge();466 k-merge from:body -> window.ermiteMR?.openMerge();
458 k-read from:body -> window.ermiteMR?.markRead();467 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/blob-empty.html +9 -0
@@ -234,6 +234,13 @@
234234 </div>
235235 <div>
236236 <dt>
237+<kbd>e</kbd>
238+<kbd>E</kbd>
239+</dt>
240+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
241+</div>
242+<div>
243+<dt>
237244 <kbd>n</kbd>
238245 </dt>
239246 <dd>merge request: next note in the diff</dd>
@@ -383,6 +390,8 @@
383390 k-split from:body -> ermite.layout('split');
384391 k-unified from:body -> ermite.layout('unified');
385392 k-collapse from:body -> ermite.collapse();
393+ k-expand from:body -> ermite.expandFile();
394+ k-expandAll from:body -> ermite.expandAll();
386395 k-permalink from:body -> ermite.permalink();
387396 k-merge from:body -> window.ermiteMR?.openMerge();
388397 k-read from:body -> window.ermiteMR?.markRead();
@@ -234,6 +234,13 @@
234 </div>234 </div>
235 <div>235 <div>
236 <dt>236 <dt>
237+<kbd>e</kbd>
238+<kbd>E</kbd>
239+</dt>
240+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
241+</div>
242+<div>
243+<dt>
237 <kbd>n</kbd>244 <kbd>n</kbd>
238 </dt>245 </dt>
239 <dd>merge request: next note in the diff</dd>246 <dd>merge request: next note in the diff</dd>
@@ -383,6 +390,8 @@
383 k-split from:body -> ermite.layout('split');390 k-split from:body -> ermite.layout('split');
384 k-unified from:body -> ermite.layout('unified');391 k-unified from:body -> ermite.layout('unified');
385 k-collapse from:body -> ermite.collapse();392 k-collapse from:body -> ermite.collapse();
393+ k-expand from:body -> ermite.expandFile();
394+ k-expandAll from:body -> ermite.expandAll();
386 k-permalink from:body -> ermite.permalink();395 k-permalink from:body -> ermite.permalink();
387 k-merge from:body -> window.ermiteMR?.openMerge();396 k-merge from:body -> window.ermiteMR?.openMerge();
388 k-read from:body -> window.ermiteMR?.markRead();397 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/blob-image.html +9 -0
@@ -236,6 +236,13 @@
236236 </div>
237237 <div>
238238 <dt>
239+<kbd>e</kbd>
240+<kbd>E</kbd>
241+</dt>
242+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
243+</div>
244+<div>
245+<dt>
239246 <kbd>n</kbd>
240247 </dt>
241248 <dd>merge request: next note in the diff</dd>
@@ -385,6 +392,8 @@
385392 k-split from:body -> ermite.layout('split');
386393 k-unified from:body -> ermite.layout('unified');
387394 k-collapse from:body -> ermite.collapse();
395+ k-expand from:body -> ermite.expandFile();
396+ k-expandAll from:body -> ermite.expandAll();
388397 k-permalink from:body -> ermite.permalink();
389398 k-merge from:body -> window.ermiteMR?.openMerge();
390399 k-read from:body -> window.ermiteMR?.markRead();
@@ -236,6 +236,13 @@
236 </div>236 </div>
237 <div>237 <div>
238 <dt>238 <dt>
239+<kbd>e</kbd>
240+<kbd>E</kbd>
241+</dt>
242+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
243+</div>
244+<div>
245+<dt>
239 <kbd>n</kbd>246 <kbd>n</kbd>
240 </dt>247 </dt>
241 <dd>merge request: next note in the diff</dd>248 <dd>merge request: next note in the diff</dd>
@@ -385,6 +392,8 @@
385 k-split from:body -> ermite.layout('split');392 k-split from:body -> ermite.layout('split');
386 k-unified from:body -> ermite.layout('unified');393 k-unified from:body -> ermite.layout('unified');
387 k-collapse from:body -> ermite.collapse();394 k-collapse from:body -> ermite.collapse();
395+ k-expand from:body -> ermite.expandFile();
396+ k-expandAll from:body -> ermite.expandAll();
388 k-permalink from:body -> ermite.permalink();397 k-permalink from:body -> ermite.permalink();
389 k-merge from:body -> window.ermiteMR?.openMerge();398 k-merge from:body -> window.ermiteMR?.openMerge();
390 k-read from:body -> window.ermiteMR?.markRead();399 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/blob-markdown.html +9 -0
@@ -301,6 +301,13 @@
301301 </div>
302302 <div>
303303 <dt>
304+<kbd>e</kbd>
305+<kbd>E</kbd>
306+</dt>
307+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
308+</div>
309+<div>
310+<dt>
304311 <kbd>n</kbd>
305312 </dt>
306313 <dd>merge request: next note in the diff</dd>
@@ -450,6 +457,8 @@
450457 k-split from:body -> ermite.layout('split');
451458 k-unified from:body -> ermite.layout('unified');
452459 k-collapse from:body -> ermite.collapse();
460+ k-expand from:body -> ermite.expandFile();
461+ k-expandAll from:body -> ermite.expandAll();
453462 k-permalink from:body -> ermite.permalink();
454463 k-merge from:body -> window.ermiteMR?.openMerge();
455464 k-read from:body -> window.ermiteMR?.markRead();
@@ -301,6 +301,13 @@
301 </div>301 </div>
302 <div>302 <div>
303 <dt>303 <dt>
304+<kbd>e</kbd>
305+<kbd>E</kbd>
306+</dt>
307+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
308+</div>
309+<div>
310+<dt>
304 <kbd>n</kbd>311 <kbd>n</kbd>
305 </dt>312 </dt>
306 <dd>merge request: next note in the diff</dd>313 <dd>merge request: next note in the diff</dd>
@@ -450,6 +457,8 @@
450 k-split from:body -> ermite.layout('split');457 k-split from:body -> ermite.layout('split');
451 k-unified from:body -> ermite.layout('unified');458 k-unified from:body -> ermite.layout('unified');
452 k-collapse from:body -> ermite.collapse();459 k-collapse from:body -> ermite.collapse();
460+ k-expand from:body -> ermite.expandFile();
461+ k-expandAll from:body -> ermite.expandAll();
453 k-permalink from:body -> ermite.permalink();462 k-permalink from:body -> ermite.permalink();
454 k-merge from:body -> window.ermiteMR?.openMerge();463 k-merge from:body -> window.ermiteMR?.openMerge();
455 k-read from:body -> window.ermiteMR?.markRead();464 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/blob-svg.html +9 -0
@@ -259,6 +259,13 @@
259259 </div>
260260 <div>
261261 <dt>
262+<kbd>e</kbd>
263+<kbd>E</kbd>
264+</dt>
265+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
266+</div>
267+<div>
268+<dt>
262269 <kbd>n</kbd>
263270 </dt>
264271 <dd>merge request: next note in the diff</dd>
@@ -408,6 +415,8 @@
408415 k-split from:body -> ermite.layout('split');
409416 k-unified from:body -> ermite.layout('unified');
410417 k-collapse from:body -> ermite.collapse();
418+ k-expand from:body -> ermite.expandFile();
419+ k-expandAll from:body -> ermite.expandAll();
411420 k-permalink from:body -> ermite.permalink();
412421 k-merge from:body -> window.ermiteMR?.openMerge();
413422 k-read from:body -> window.ermiteMR?.markRead();
@@ -259,6 +259,13 @@
259 </div>259 </div>
260 <div>260 <div>
261 <dt>261 <dt>
262+<kbd>e</kbd>
263+<kbd>E</kbd>
264+</dt>
265+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
266+</div>
267+<div>
268+<dt>
262 <kbd>n</kbd>269 <kbd>n</kbd>
263 </dt>270 </dt>
264 <dd>merge request: next note in the diff</dd>271 <dd>merge request: next note in the diff</dd>
@@ -408,6 +415,8 @@
408 k-split from:body -> ermite.layout('split');415 k-split from:body -> ermite.layout('split');
409 k-unified from:body -> ermite.layout('unified');416 k-unified from:body -> ermite.layout('unified');
410 k-collapse from:body -> ermite.collapse();417 k-collapse from:body -> ermite.collapse();
418+ k-expand from:body -> ermite.expandFile();
419+ k-expandAll from:body -> ermite.expandAll();
411 k-permalink from:body -> ermite.permalink();420 k-permalink from:body -> ermite.permalink();
412 k-merge from:body -> window.ermiteMR?.openMerge();421 k-merge from:body -> window.ermiteMR?.openMerge();
413 k-read from:body -> window.ermiteMR?.markRead();422 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/blob-text.html +9 -0
@@ -254,6 +254,13 @@
254254 </div>
255255 <div>
256256 <dt>
257+<kbd>e</kbd>
258+<kbd>E</kbd>
259+</dt>
260+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
261+</div>
262+<div>
263+<dt>
257264 <kbd>n</kbd>
258265 </dt>
259266 <dd>merge request: next note in the diff</dd>
@@ -403,6 +410,8 @@
403410 k-split from:body -> ermite.layout('split');
404411 k-unified from:body -> ermite.layout('unified');
405412 k-collapse from:body -> ermite.collapse();
413+ k-expand from:body -> ermite.expandFile();
414+ k-expandAll from:body -> ermite.expandAll();
406415 k-permalink from:body -> ermite.permalink();
407416 k-merge from:body -> window.ermiteMR?.openMerge();
408417 k-read from:body -> window.ermiteMR?.markRead();
@@ -254,6 +254,13 @@
254 </div>254 </div>
255 <div>255 <div>
256 <dt>256 <dt>
257+<kbd>e</kbd>
258+<kbd>E</kbd>
259+</dt>
260+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
261+</div>
262+<div>
263+<dt>
257 <kbd>n</kbd>264 <kbd>n</kbd>
258 </dt>265 </dt>
259 <dd>merge request: next note in the diff</dd>266 <dd>merge request: next note in the diff</dd>
@@ -403,6 +410,8 @@
403 k-split from:body -> ermite.layout('split');410 k-split from:body -> ermite.layout('split');
404 k-unified from:body -> ermite.layout('unified');411 k-unified from:body -> ermite.layout('unified');
405 k-collapse from:body -> ermite.collapse();412 k-collapse from:body -> ermite.collapse();
413+ k-expand from:body -> ermite.expandFile();
414+ k-expandAll from:body -> ermite.expandAll();
406 k-permalink from:body -> ermite.permalink();415 k-permalink from:body -> ermite.permalink();
407 k-merge from:body -> window.ermiteMR?.openMerge();416 k-merge from:body -> window.ermiteMR?.openMerge();
408 k-read from:body -> window.ermiteMR?.markRead();417 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/blob-too-large.html +9 -0
@@ -234,6 +234,13 @@
234234 </div>
235235 <div>
236236 <dt>
237+<kbd>e</kbd>
238+<kbd>E</kbd>
239+</dt>
240+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
241+</div>
242+<div>
243+<dt>
237244 <kbd>n</kbd>
238245 </dt>
239246 <dd>merge request: next note in the diff</dd>
@@ -383,6 +390,8 @@
383390 k-split from:body -> ermite.layout('split');
384391 k-unified from:body -> ermite.layout('unified');
385392 k-collapse from:body -> ermite.collapse();
393+ k-expand from:body -> ermite.expandFile();
394+ k-expandAll from:body -> ermite.expandAll();
386395 k-permalink from:body -> ermite.permalink();
387396 k-merge from:body -> window.ermiteMR?.openMerge();
388397 k-read from:body -> window.ermiteMR?.markRead();
@@ -234,6 +234,13 @@
234 </div>234 </div>
235 <div>235 <div>
236 <dt>236 <dt>
237+<kbd>e</kbd>
238+<kbd>E</kbd>
239+</dt>
240+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
241+</div>
242+<div>
243+<dt>
237 <kbd>n</kbd>244 <kbd>n</kbd>
238 </dt>245 </dt>
239 <dd>merge request: next note in the diff</dd>246 <dd>merge request: next note in the diff</dd>
@@ -383,6 +390,8 @@
383 k-split from:body -> ermite.layout('split');390 k-split from:body -> ermite.layout('split');
384 k-unified from:body -> ermite.layout('unified');391 k-unified from:body -> ermite.layout('unified');
385 k-collapse from:body -> ermite.collapse();392 k-collapse from:body -> ermite.collapse();
393+ k-expand from:body -> ermite.expandFile();
394+ k-expandAll from:body -> ermite.expandAll();
386 k-permalink from:body -> ermite.permalink();395 k-permalink from:body -> ermite.permalink();
387 k-merge from:body -> window.ermiteMR?.openMerge();396 k-merge from:body -> window.ermiteMR?.openMerge();
388 k-read from:body -> window.ermiteMR?.markRead();397 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/blob-trojan.html +9 -0
@@ -301,6 +301,13 @@
301301 </div>
302302 <div>
303303 <dt>
304+<kbd>e</kbd>
305+<kbd>E</kbd>
306+</dt>
307+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
308+</div>
309+<div>
310+<dt>
304311 <kbd>n</kbd>
305312 </dt>
306313 <dd>merge request: next note in the diff</dd>
@@ -450,6 +457,8 @@
450457 k-split from:body -> ermite.layout('split');
451458 k-unified from:body -> ermite.layout('unified');
452459 k-collapse from:body -> ermite.collapse();
460+ k-expand from:body -> ermite.expandFile();
461+ k-expandAll from:body -> ermite.expandAll();
453462 k-permalink from:body -> ermite.permalink();
454463 k-merge from:body -> window.ermiteMR?.openMerge();
455464 k-read from:body -> window.ermiteMR?.markRead();
@@ -301,6 +301,13 @@
301 </div>301 </div>
302 <div>302 <div>
303 <dt>303 <dt>
304+<kbd>e</kbd>
305+<kbd>E</kbd>
306+</dt>
307+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
308+</div>
309+<div>
310+<dt>
304 <kbd>n</kbd>311 <kbd>n</kbd>
305 </dt>312 </dt>
306 <dd>merge request: next note in the diff</dd>313 <dd>merge request: next note in the diff</dd>
@@ -450,6 +457,8 @@
450 k-split from:body -> ermite.layout('split');457 k-split from:body -> ermite.layout('split');
451 k-unified from:body -> ermite.layout('unified');458 k-unified from:body -> ermite.layout('unified');
452 k-collapse from:body -> ermite.collapse();459 k-collapse from:body -> ermite.collapse();
460+ k-expand from:body -> ermite.expandFile();
461+ k-expandAll from:body -> ermite.expandAll();
453 k-permalink from:body -> ermite.permalink();462 k-permalink from:body -> ermite.permalink();
454 k-merge from:body -> window.ermiteMR?.openMerge();463 k-merge from:body -> window.ermiteMR?.openMerge();
455 k-read from:body -> window.ermiteMR?.markRead();464 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/commit-big.html +9 -0
@@ -272,6 +272,13 @@
272272 </div>
273273 <div>
274274 <dt>
275+<kbd>e</kbd>
276+<kbd>E</kbd>
277+</dt>
278+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
279+</div>
280+<div>
281+<dt>
275282 <kbd>n</kbd>
276283 </dt>
277284 <dd>merge request: next note in the diff</dd>
@@ -421,6 +428,8 @@
421428 k-split from:body -> ermite.layout('split');
422429 k-unified from:body -> ermite.layout('unified');
423430 k-collapse from:body -> ermite.collapse();
431+ k-expand from:body -> ermite.expandFile();
432+ k-expandAll from:body -> ermite.expandAll();
424433 k-permalink from:body -> ermite.permalink();
425434 k-merge from:body -> window.ermiteMR?.openMerge();
426435 k-read from:body -> window.ermiteMR?.markRead();
@@ -272,6 +272,13 @@
272 </div>272 </div>
273 <div>273 <div>
274 <dt>274 <dt>
275+<kbd>e</kbd>
276+<kbd>E</kbd>
277+</dt>
278+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
279+</div>
280+<div>
281+<dt>
275 <kbd>n</kbd>282 <kbd>n</kbd>
276 </dt>283 </dt>
277 <dd>merge request: next note in the diff</dd>284 <dd>merge request: next note in the diff</dd>
@@ -421,6 +428,8 @@
421 k-split from:body -> ermite.layout('split');428 k-split from:body -> ermite.layout('split');
422 k-unified from:body -> ermite.layout('unified');429 k-unified from:body -> ermite.layout('unified');
423 k-collapse from:body -> ermite.collapse();430 k-collapse from:body -> ermite.collapse();
431+ k-expand from:body -> ermite.expandFile();
432+ k-expandAll from:body -> ermite.expandAll();
424 k-permalink from:body -> ermite.permalink();433 k-permalink from:body -> ermite.permalink();
425 k-merge from:body -> window.ermiteMR?.openMerge();434 k-merge from:body -> window.ermiteMR?.openMerge();
426 k-read from:body -> window.ermiteMR?.markRead();435 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/commit-binary.html +9 -0
@@ -264,6 +264,13 @@
264264 </div>
265265 <div>
266266 <dt>
267+<kbd>e</kbd>
268+<kbd>E</kbd>
269+</dt>
270+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
271+</div>
272+<div>
273+<dt>
267274 <kbd>n</kbd>
268275 </dt>
269276 <dd>merge request: next note in the diff</dd>
@@ -413,6 +420,8 @@
413420 k-split from:body -> ermite.layout('split');
414421 k-unified from:body -> ermite.layout('unified');
415422 k-collapse from:body -> ermite.collapse();
423+ k-expand from:body -> ermite.expandFile();
424+ k-expandAll from:body -> ermite.expandAll();
416425 k-permalink from:body -> ermite.permalink();
417426 k-merge from:body -> window.ermiteMR?.openMerge();
418427 k-read from:body -> window.ermiteMR?.markRead();
@@ -264,6 +264,13 @@
264 </div>264 </div>
265 <div>265 <div>
266 <dt>266 <dt>
267+<kbd>e</kbd>
268+<kbd>E</kbd>
269+</dt>
270+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
271+</div>
272+<div>
273+<dt>
267 <kbd>n</kbd>274 <kbd>n</kbd>
268 </dt>275 </dt>
269 <dd>merge request: next note in the diff</dd>276 <dd>merge request: next note in the diff</dd>
@@ -413,6 +420,8 @@
413 k-split from:body -> ermite.layout('split');420 k-split from:body -> ermite.layout('split');
414 k-unified from:body -> ermite.layout('unified');421 k-unified from:body -> ermite.layout('unified');
415 k-collapse from:body -> ermite.collapse();422 k-collapse from:body -> ermite.collapse();
423+ k-expand from:body -> ermite.expandFile();
424+ k-expandAll from:body -> ermite.expandAll();
416 k-permalink from:body -> ermite.permalink();425 k-permalink from:body -> ermite.permalink();
417 k-merge from:body -> window.ermiteMR?.openMerge();426 k-merge from:body -> window.ermiteMR?.openMerge();
418 k-read from:body -> window.ermiteMR?.markRead();427 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/commit-merge.html +9 -0
@@ -306,6 +306,13 @@
306306 </div>
307307 <div>
308308 <dt>
309+<kbd>e</kbd>
310+<kbd>E</kbd>
311+</dt>
312+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
313+</div>
314+<div>
315+<dt>
309316 <kbd>n</kbd>
310317 </dt>
311318 <dd>merge request: next note in the diff</dd>
@@ -455,6 +462,8 @@
455462 k-split from:body -> ermite.layout('split');
456463 k-unified from:body -> ermite.layout('unified');
457464 k-collapse from:body -> ermite.collapse();
465+ k-expand from:body -> ermite.expandFile();
466+ k-expandAll from:body -> ermite.expandAll();
458467 k-permalink from:body -> ermite.permalink();
459468 k-merge from:body -> window.ermiteMR?.openMerge();
460469 k-read from:body -> window.ermiteMR?.markRead();
@@ -306,6 +306,13 @@
306 </div>306 </div>
307 <div>307 <div>
308 <dt>308 <dt>
309+<kbd>e</kbd>
310+<kbd>E</kbd>
311+</dt>
312+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
313+</div>
314+<div>
315+<dt>
309 <kbd>n</kbd>316 <kbd>n</kbd>
310 </dt>317 </dt>
311 <dd>merge request: next note in the diff</dd>318 <dd>merge request: next note in the diff</dd>
@@ -455,6 +462,8 @@
455 k-split from:body -> ermite.layout('split');462 k-split from:body -> ermite.layout('split');
456 k-unified from:body -> ermite.layout('unified');463 k-unified from:body -> ermite.layout('unified');
457 k-collapse from:body -> ermite.collapse();464 k-collapse from:body -> ermite.collapse();
465+ k-expand from:body -> ermite.expandFile();
466+ k-expandAll from:body -> ermite.expandAll();
458 k-permalink from:body -> ermite.permalink();467 k-permalink from:body -> ermite.permalink();
459 k-merge from:body -> window.ermiteMR?.openMerge();468 k-merge from:body -> window.ermiteMR?.openMerge();
460 k-read from:body -> window.ermiteMR?.markRead();469 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/commit-modify-split.html +9 -0
@@ -429,6 +429,13 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
429429 </div>
430430 <div>
431431 <dt>
432+<kbd>e</kbd>
433+<kbd>E</kbd>
434+</dt>
435+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
436+</div>
437+<div>
438+<dt>
432439 <kbd>n</kbd>
433440 </dt>
434441 <dd>merge request: next note in the diff</dd>
@@ -578,6 +585,8 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
578585 k-split from:body -> ermite.layout('split');
579586 k-unified from:body -> ermite.layout('unified');
580587 k-collapse from:body -> ermite.collapse();
588+ k-expand from:body -> ermite.expandFile();
589+ k-expandAll from:body -> ermite.expandAll();
581590 k-permalink from:body -> ermite.permalink();
582591 k-merge from:body -> window.ermiteMR?.openMerge();
583592 k-read from:body -> window.ermiteMR?.markRead();
@@ -429,6 +429,13 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
429 </div>429 </div>
430 <div>430 <div>
431 <dt>431 <dt>
432+<kbd>e</kbd>
433+<kbd>E</kbd>
434+</dt>
435+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
436+</div>
437+<div>
438+<dt>
432 <kbd>n</kbd>439 <kbd>n</kbd>
433 </dt>440 </dt>
434 <dd>merge request: next note in the diff</dd>441 <dd>merge request: next note in the diff</dd>
@@ -578,6 +585,8 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
578 k-split from:body -> ermite.layout('split');585 k-split from:body -> ermite.layout('split');
579 k-unified from:body -> ermite.layout('unified');586 k-unified from:body -> ermite.layout('unified');
580 k-collapse from:body -> ermite.collapse();587 k-collapse from:body -> ermite.collapse();
588+ k-expand from:body -> ermite.expandFile();
589+ k-expandAll from:body -> ermite.expandAll();
581 k-permalink from:body -> ermite.permalink();590 k-permalink from:body -> ermite.permalink();
582 k-merge from:body -> window.ermiteMR?.openMerge();591 k-merge from:body -> window.ermiteMR?.openMerge();
583 k-read from:body -> window.ermiteMR?.markRead();592 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/commit-modify.html +9 -0
@@ -429,6 +429,13 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
429429 </div>
430430 <div>
431431 <dt>
432+<kbd>e</kbd>
433+<kbd>E</kbd>
434+</dt>
435+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
436+</div>
437+<div>
438+<dt>
432439 <kbd>n</kbd>
433440 </dt>
434441 <dd>merge request: next note in the diff</dd>
@@ -578,6 +585,8 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
578585 k-split from:body -> ermite.layout('split');
579586 k-unified from:body -> ermite.layout('unified');
580587 k-collapse from:body -> ermite.collapse();
588+ k-expand from:body -> ermite.expandFile();
589+ k-expandAll from:body -> ermite.expandAll();
581590 k-permalink from:body -> ermite.permalink();
582591 k-merge from:body -> window.ermiteMR?.openMerge();
583592 k-read from:body -> window.ermiteMR?.markRead();
@@ -429,6 +429,13 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
429 </div>429 </div>
430 <div>430 <div>
431 <dt>431 <dt>
432+<kbd>e</kbd>
433+<kbd>E</kbd>
434+</dt>
435+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
436+</div>
437+<div>
438+<dt>
432 <kbd>n</kbd>439 <kbd>n</kbd>
433 </dt>440 </dt>
434 <dd>merge request: next note in the diff</dd>441 <dd>merge request: next note in the diff</dd>
@@ -578,6 +585,8 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
578 k-split from:body -> ermite.layout('split');585 k-split from:body -> ermite.layout('split');
579 k-unified from:body -> ermite.layout('unified');586 k-unified from:body -> ermite.layout('unified');
580 k-collapse from:body -> ermite.collapse();587 k-collapse from:body -> ermite.collapse();
588+ k-expand from:body -> ermite.expandFile();
589+ k-expandAll from:body -> ermite.expandAll();
581 k-permalink from:body -> ermite.permalink();590 k-permalink from:body -> ermite.permalink();
582 k-merge from:body -> window.ermiteMR?.openMerge();591 k-merge from:body -> window.ermiteMR?.openMerge();
583 k-read from:body -> window.ermiteMR?.markRead();592 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/commit-rename.html +9 -0
@@ -333,6 +333,13 @@
333333 </div>
334334 <div>
335335 <dt>
336+<kbd>e</kbd>
337+<kbd>E</kbd>
338+</dt>
339+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
340+</div>
341+<div>
342+<dt>
336343 <kbd>n</kbd>
337344 </dt>
338345 <dd>merge request: next note in the diff</dd>
@@ -482,6 +489,8 @@
482489 k-split from:body -> ermite.layout('split');
483490 k-unified from:body -> ermite.layout('unified');
484491 k-collapse from:body -> ermite.collapse();
492+ k-expand from:body -> ermite.expandFile();
493+ k-expandAll from:body -> ermite.expandAll();
485494 k-permalink from:body -> ermite.permalink();
486495 k-merge from:body -> window.ermiteMR?.openMerge();
487496 k-read from:body -> window.ermiteMR?.markRead();
@@ -333,6 +333,13 @@
333 </div>333 </div>
334 <div>334 <div>
335 <dt>335 <dt>
336+<kbd>e</kbd>
337+<kbd>E</kbd>
338+</dt>
339+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
340+</div>
341+<div>
342+<dt>
336 <kbd>n</kbd>343 <kbd>n</kbd>
337 </dt>344 </dt>
338 <dd>merge request: next note in the diff</dd>345 <dd>merge request: next note in the diff</dd>
@@ -482,6 +489,8 @@
482 k-split from:body -> ermite.layout('split');489 k-split from:body -> ermite.layout('split');
483 k-unified from:body -> ermite.layout('unified');490 k-unified from:body -> ermite.layout('unified');
484 k-collapse from:body -> ermite.collapse();491 k-collapse from:body -> ermite.collapse();
492+ k-expand from:body -> ermite.expandFile();
493+ k-expandAll from:body -> ermite.expandAll();
485 k-permalink from:body -> ermite.permalink();494 k-permalink from:body -> ermite.permalink();
486 k-merge from:body -> window.ermiteMR?.openMerge();495 k-merge from:body -> window.ermiteMR?.openMerge();
487 k-read from:body -> window.ermiteMR?.markRead();496 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/commit-root.html +9 -0
@@ -652,6 +652,13 @@
652652 </div>
653653 <div>
654654 <dt>
655+<kbd>e</kbd>
656+<kbd>E</kbd>
657+</dt>
658+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
659+</div>
660+<div>
661+<dt>
655662 <kbd>n</kbd>
656663 </dt>
657664 <dd>merge request: next note in the diff</dd>
@@ -801,6 +808,8 @@
801808 k-split from:body -> ermite.layout('split');
802809 k-unified from:body -> ermite.layout('unified');
803810 k-collapse from:body -> ermite.collapse();
811+ k-expand from:body -> ermite.expandFile();
812+ k-expandAll from:body -> ermite.expandAll();
804813 k-permalink from:body -> ermite.permalink();
805814 k-merge from:body -> window.ermiteMR?.openMerge();
806815 k-read from:body -> window.ermiteMR?.markRead();
@@ -652,6 +652,13 @@
652 </div>652 </div>
653 <div>653 <div>
654 <dt>654 <dt>
655+<kbd>e</kbd>
656+<kbd>E</kbd>
657+</dt>
658+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
659+</div>
660+<div>
661+<dt>
655 <kbd>n</kbd>662 <kbd>n</kbd>
656 </dt>663 </dt>
657 <dd>merge request: next note in the diff</dd>664 <dd>merge request: next note in the diff</dd>
@@ -801,6 +808,8 @@
801 k-split from:body -> ermite.layout('split');808 k-split from:body -> ermite.layout('split');
802 k-unified from:body -> ermite.layout('unified');809 k-unified from:body -> ermite.layout('unified');
803 k-collapse from:body -> ermite.collapse();810 k-collapse from:body -> ermite.collapse();
811+ k-expand from:body -> ermite.expandFile();
812+ k-expandAll from:body -> ermite.expandAll();
804 k-permalink from:body -> ermite.permalink();813 k-permalink from:body -> ermite.permalink();
805 k-merge from:body -> window.ermiteMR?.openMerge();814 k-merge from:body -> window.ermiteMR?.openMerge();
806 k-read from:body -> window.ermiteMR?.markRead();815 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/commit-trojan.html +9 -0
@@ -441,6 +441,13 @@
441441 </div>
442442 <div>
443443 <dt>
444+<kbd>e</kbd>
445+<kbd>E</kbd>
446+</dt>
447+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
448+</div>
449+<div>
450+<dt>
444451 <kbd>n</kbd>
445452 </dt>
446453 <dd>merge request: next note in the diff</dd>
@@ -590,6 +597,8 @@
590597 k-split from:body -> ermite.layout('split');
591598 k-unified from:body -> ermite.layout('unified');
592599 k-collapse from:body -> ermite.collapse();
600+ k-expand from:body -> ermite.expandFile();
601+ k-expandAll from:body -> ermite.expandAll();
593602 k-permalink from:body -> ermite.permalink();
594603 k-merge from:body -> window.ermiteMR?.openMerge();
595604 k-read from:body -> window.ermiteMR?.markRead();
@@ -441,6 +441,13 @@
441 </div>441 </div>
442 <div>442 <div>
443 <dt>443 <dt>
444+<kbd>e</kbd>
445+<kbd>E</kbd>
446+</dt>
447+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
448+</div>
449+<div>
450+<dt>
444 <kbd>n</kbd>451 <kbd>n</kbd>
445 </dt>452 </dt>
446 <dd>merge request: next note in the diff</dd>453 <dd>merge request: next note in the diff</dd>
@@ -590,6 +597,8 @@
590 k-split from:body -> ermite.layout('split');597 k-split from:body -> ermite.layout('split');
591 k-unified from:body -> ermite.layout('unified');598 k-unified from:body -> ermite.layout('unified');
592 k-collapse from:body -> ermite.collapse();599 k-collapse from:body -> ermite.collapse();
600+ k-expand from:body -> ermite.expandFile();
601+ k-expandAll from:body -> ermite.expandAll();
593 k-permalink from:body -> ermite.permalink();602 k-permalink from:body -> ermite.permalink();
594 k-merge from:body -> window.ermiteMR?.openMerge();603 k-merge from:body -> window.ermiteMR?.openMerge();
595 k-read from:body -> window.ermiteMR?.markRead();604 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/commits-page2.html +9 -0
@@ -265,6 +265,13 @@
265265 </div>
266266 <div>
267267 <dt>
268+<kbd>e</kbd>
269+<kbd>E</kbd>
270+</dt>
271+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
272+</div>
273+<div>
274+<dt>
268275 <kbd>n</kbd>
269276 </dt>
270277 <dd>merge request: next note in the diff</dd>
@@ -400,6 +407,8 @@
400407 k-split from:body -> ermite.layout('split');
401408 k-unified from:body -> ermite.layout('unified');
402409 k-collapse from:body -> ermite.collapse();
410+ k-expand from:body -> ermite.expandFile();
411+ k-expandAll from:body -> ermite.expandAll();
403412 k-permalink from:body -> ermite.permalink();
404413 k-merge from:body -> window.ermiteMR?.openMerge();
405414 k-read from:body -> window.ermiteMR?.markRead();
@@ -265,6 +265,13 @@
265 </div>265 </div>
266 <div>266 <div>
267 <dt>267 <dt>
268+<kbd>e</kbd>
269+<kbd>E</kbd>
270+</dt>
271+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
272+</div>
273+<div>
274+<dt>
268 <kbd>n</kbd>275 <kbd>n</kbd>
269 </dt>276 </dt>
270 <dd>merge request: next note in the diff</dd>277 <dd>merge request: next note in the diff</dd>
@@ -400,6 +407,8 @@
400 k-split from:body -> ermite.layout('split');407 k-split from:body -> ermite.layout('split');
401 k-unified from:body -> ermite.layout('unified');408 k-unified from:body -> ermite.layout('unified');
402 k-collapse from:body -> ermite.collapse();409 k-collapse from:body -> ermite.collapse();
410+ k-expand from:body -> ermite.expandFile();
411+ k-expandAll from:body -> ermite.expandAll();
403 k-permalink from:body -> ermite.permalink();412 k-permalink from:body -> ermite.permalink();
404 k-merge from:body -> window.ermiteMR?.openMerge();413 k-merge from:body -> window.ermiteMR?.openMerge();
405 k-read from:body -> window.ermiteMR?.markRead();414 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/commits-path.html +9 -0
@@ -280,6 +280,13 @@
280280 </div>
281281 <div>
282282 <dt>
283+<kbd>e</kbd>
284+<kbd>E</kbd>
285+</dt>
286+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
287+</div>
288+<div>
289+<dt>
283290 <kbd>n</kbd>
284291 </dt>
285292 <dd>merge request: next note in the diff</dd>
@@ -415,6 +422,8 @@
415422 k-split from:body -> ermite.layout('split');
416423 k-unified from:body -> ermite.layout('unified');
417424 k-collapse from:body -> ermite.collapse();
425+ k-expand from:body -> ermite.expandFile();
426+ k-expandAll from:body -> ermite.expandAll();
418427 k-permalink from:body -> ermite.permalink();
419428 k-merge from:body -> window.ermiteMR?.openMerge();
420429 k-read from:body -> window.ermiteMR?.markRead();
@@ -280,6 +280,13 @@
280 </div>280 </div>
281 <div>281 <div>
282 <dt>282 <dt>
283+<kbd>e</kbd>
284+<kbd>E</kbd>
285+</dt>
286+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
287+</div>
288+<div>
289+<dt>
283 <kbd>n</kbd>290 <kbd>n</kbd>
284 </dt>291 </dt>
285 <dd>merge request: next note in the diff</dd>292 <dd>merge request: next note in the diff</dd>
@@ -415,6 +422,8 @@
415 k-split from:body -> ermite.layout('split');422 k-split from:body -> ermite.layout('split');
416 k-unified from:body -> ermite.layout('unified');423 k-unified from:body -> ermite.layout('unified');
417 k-collapse from:body -> ermite.collapse();424 k-collapse from:body -> ermite.collapse();
425+ k-expand from:body -> ermite.expandFile();
426+ k-expandAll from:body -> ermite.expandAll();
418 k-permalink from:body -> ermite.permalink();427 k-permalink from:body -> ermite.permalink();
419 k-merge from:body -> window.ermiteMR?.openMerge();428 k-merge from:body -> window.ermiteMR?.openMerge();
420 k-read from:body -> window.ermiteMR?.markRead();429 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/commits-root.html +9 -0
@@ -1165,6 +1165,13 @@
11651165 </div>
11661166 <div>
11671167 <dt>
1168+<kbd>e</kbd>
1169+<kbd>E</kbd>
1170+</dt>
1171+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
1172+</div>
1173+<div>
1174+<dt>
11681175 <kbd>n</kbd>
11691176 </dt>
11701177 <dd>merge request: next note in the diff</dd>
@@ -1300,6 +1307,8 @@
13001307 k-split from:body -> ermite.layout('split');
13011308 k-unified from:body -> ermite.layout('unified');
13021309 k-collapse from:body -> ermite.collapse();
1310+ k-expand from:body -> ermite.expandFile();
1311+ k-expandAll from:body -> ermite.expandAll();
13031312 k-permalink from:body -> ermite.permalink();
13041313 k-merge from:body -> window.ermiteMR?.openMerge();
13051314 k-read from:body -> window.ermiteMR?.markRead();
@@ -1165,6 +1165,13 @@
1165 </div>1165 </div>
1166 <div>1166 <div>
1167 <dt>1167 <dt>
1168+<kbd>e</kbd>
1169+<kbd>E</kbd>
1170+</dt>
1171+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
1172+</div>
1173+<div>
1174+<dt>
1168 <kbd>n</kbd>1175 <kbd>n</kbd>
1169 </dt>1176 </dt>
1170 <dd>merge request: next note in the diff</dd>1177 <dd>merge request: next note in the diff</dd>
@@ -1300,6 +1307,8 @@
1300 k-split from:body -> ermite.layout('split');1307 k-split from:body -> ermite.layout('split');
1301 k-unified from:body -> ermite.layout('unified');1308 k-unified from:body -> ermite.layout('unified');
1302 k-collapse from:body -> ermite.collapse();1309 k-collapse from:body -> ermite.collapse();
1310+ k-expand from:body -> ermite.expandFile();
1311+ k-expandAll from:body -> ermite.expandAll();
1303 k-permalink from:body -> ermite.permalink();1312 k-permalink from:body -> ermite.permalink();
1304 k-merge from:body -> window.ermiteMR?.openMerge();1313 k-merge from:body -> window.ermiteMR?.openMerge();
1305 k-read from:body -> window.ermiteMR?.markRead();1314 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/commits-small.html +9 -0
@@ -426,6 +426,13 @@
426426 </div>
427427 <div>
428428 <dt>
429+<kbd>e</kbd>
430+<kbd>E</kbd>
431+</dt>
432+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
433+</div>
434+<div>
435+<dt>
429436 <kbd>n</kbd>
430437 </dt>
431438 <dd>merge request: next note in the diff</dd>
@@ -581,6 +588,8 @@
581588 k-split from:body -> ermite.layout('split');
582589 k-unified from:body -> ermite.layout('unified');
583590 k-collapse from:body -> ermite.collapse();
591+ k-expand from:body -> ermite.expandFile();
592+ k-expandAll from:body -> ermite.expandAll();
584593 k-permalink from:body -> ermite.permalink();
585594 k-merge from:body -> window.ermiteMR?.openMerge();
586595 k-read from:body -> window.ermiteMR?.markRead();
@@ -426,6 +426,13 @@
426 </div>426 </div>
427 <div>427 <div>
428 <dt>428 <dt>
429+<kbd>e</kbd>
430+<kbd>E</kbd>
431+</dt>
432+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
433+</div>
434+<div>
435+<dt>
429 <kbd>n</kbd>436 <kbd>n</kbd>
430 </dt>437 </dt>
431 <dd>merge request: next note in the diff</dd>438 <dd>merge request: next note in the diff</dd>
@@ -581,6 +588,8 @@
581 k-split from:body -> ermite.layout('split');588 k-split from:body -> ermite.layout('split');
582 k-unified from:body -> ermite.layout('unified');589 k-unified from:body -> ermite.layout('unified');
583 k-collapse from:body -> ermite.collapse();590 k-collapse from:body -> ermite.collapse();
591+ k-expand from:body -> ermite.expandFile();
592+ k-expandAll from:body -> ermite.expandAll();
584 k-permalink from:body -> ermite.permalink();593 k-permalink from:body -> ermite.permalink();
585 k-merge from:body -> window.ermiteMR?.openMerge();594 k-merge from:body -> window.ermiteMR?.openMerge();
586 k-read from:body -> window.ermiteMR?.markRead();595 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/empty.html +9 -0
@@ -183,6 +183,13 @@
183183 </div>
184184 <div>
185185 <dt>
186+<kbd>e</kbd>
187+<kbd>E</kbd>
188+</dt>
189+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
190+</div>
191+<div>
192+<dt>
186193 <kbd>n</kbd>
187194 </dt>
188195 <dd>merge request: next note in the diff</dd>
@@ -249,6 +256,8 @@
249256 k-split from:body -> ermite.layout('split');
250257 k-unified from:body -> ermite.layout('unified');
251258 k-collapse from:body -> ermite.collapse();
259+ k-expand from:body -> ermite.expandFile();
260+ k-expandAll from:body -> ermite.expandAll();
252261 k-permalink from:body -> ermite.permalink();
253262 k-merge from:body -> window.ermiteMR?.openMerge();
254263 k-read from:body -> window.ermiteMR?.markRead();
@@ -183,6 +183,13 @@
183 </div>183 </div>
184 <div>184 <div>
185 <dt>185 <dt>
186+<kbd>e</kbd>
187+<kbd>E</kbd>
188+</dt>
189+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
190+</div>
191+<div>
192+<dt>
186 <kbd>n</kbd>193 <kbd>n</kbd>
187 </dt>194 </dt>
188 <dd>merge request: next note in the diff</dd>195 <dd>merge request: next note in the diff</dd>
@@ -249,6 +256,8 @@
249 k-split from:body -> ermite.layout('split');256 k-split from:body -> ermite.layout('split');
250 k-unified from:body -> ermite.layout('unified');257 k-unified from:body -> ermite.layout('unified');
251 k-collapse from:body -> ermite.collapse();258 k-collapse from:body -> ermite.collapse();
259+ k-expand from:body -> ermite.expandFile();
260+ k-expandAll from:body -> ermite.expandAll();
252 k-permalink from:body -> ermite.permalink();261 k-permalink from:body -> ermite.permalink();
253 k-merge from:body -> window.ermiteMR?.openMerge();262 k-merge from:body -> window.ermiteMR?.openMerge();
254 k-read from:body -> window.ermiteMR?.markRead();263 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/home.html +9 -0
@@ -215,6 +215,13 @@
215215 </div>
216216 <div>
217217 <dt>
218+<kbd>e</kbd>
219+<kbd>E</kbd>
220+</dt>
221+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
222+</div>
223+<div>
224+<dt>
218225 <kbd>n</kbd>
219226 </dt>
220227 <dd>merge request: next note in the diff</dd>
@@ -287,6 +294,8 @@
287294 k-split from:body -> ermite.layout('split');
288295 k-unified from:body -> ermite.layout('unified');
289296 k-collapse from:body -> ermite.collapse();
297+ k-expand from:body -> ermite.expandFile();
298+ k-expandAll from:body -> ermite.expandAll();
290299 k-permalink from:body -> ermite.permalink();
291300 k-merge from:body -> window.ermiteMR?.openMerge();
292301 k-read from:body -> window.ermiteMR?.markRead();
@@ -215,6 +215,13 @@
215 </div>215 </div>
216 <div>216 <div>
217 <dt>217 <dt>
218+<kbd>e</kbd>
219+<kbd>E</kbd>
220+</dt>
221+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
222+</div>
223+<div>
224+<dt>
218 <kbd>n</kbd>225 <kbd>n</kbd>
219 </dt>226 </dt>
220 <dd>merge request: next note in the diff</dd>227 <dd>merge request: next note in the diff</dd>
@@ -287,6 +294,8 @@
287 k-split from:body -> ermite.layout('split');294 k-split from:body -> ermite.layout('split');
288 k-unified from:body -> ermite.layout('unified');295 k-unified from:body -> ermite.layout('unified');
289 k-collapse from:body -> ermite.collapse();296 k-collapse from:body -> ermite.collapse();
297+ k-expand from:body -> ermite.expandFile();
298+ k-expandAll from:body -> ermite.expandAll();
290 k-permalink from:body -> ermite.permalink();299 k-permalink from:body -> ermite.permalink();
291 k-merge from:body -> window.ermiteMR?.openMerge();300 k-merge from:body -> window.ermiteMR?.openMerge();
292 k-read from:body -> window.ermiteMR?.markRead();301 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/not-found-page.html +9 -0
@@ -172,6 +172,13 @@
172172 </div>
173173 <div>
174174 <dt>
175+<kbd>e</kbd>
176+<kbd>E</kbd>
177+</dt>
178+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
179+</div>
180+<div>
181+<dt>
175182 <kbd>n</kbd>
176183 </dt>
177184 <dd>merge request: next note in the diff</dd>
@@ -232,6 +239,8 @@
232239 k-split from:body -> ermite.layout('split');
233240 k-unified from:body -> ermite.layout('unified');
234241 k-collapse from:body -> ermite.collapse();
242+ k-expand from:body -> ermite.expandFile();
243+ k-expandAll from:body -> ermite.expandAll();
235244 k-permalink from:body -> ermite.permalink();
236245 k-merge from:body -> window.ermiteMR?.openMerge();
237246 k-read from:body -> window.ermiteMR?.markRead();
@@ -172,6 +172,13 @@
172 </div>172 </div>
173 <div>173 <div>
174 <dt>174 <dt>
175+<kbd>e</kbd>
176+<kbd>E</kbd>
177+</dt>
178+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
179+</div>
180+<div>
181+<dt>
175 <kbd>n</kbd>182 <kbd>n</kbd>
176 </dt>183 </dt>
177 <dd>merge request: next note in the diff</dd>184 <dd>merge request: next note in the diff</dd>
@@ -232,6 +239,8 @@
232 k-split from:body -> ermite.layout('split');239 k-split from:body -> ermite.layout('split');
233 k-unified from:body -> ermite.layout('unified');240 k-unified from:body -> ermite.layout('unified');
234 k-collapse from:body -> ermite.collapse();241 k-collapse from:body -> ermite.collapse();
242+ k-expand from:body -> ermite.expandFile();
243+ k-expandAll from:body -> ermite.expandAll();
235 k-permalink from:body -> ermite.permalink();244 k-permalink from:body -> ermite.permalink();
236 k-merge from:body -> window.ermiteMR?.openMerge();245 k-merge from:body -> window.ermiteMR?.openMerge();
237 k-read from:body -> window.ermiteMR?.markRead();246 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/not-found.html +9 -0
@@ -172,6 +172,13 @@
172172 </div>
173173 <div>
174174 <dt>
175+<kbd>e</kbd>
176+<kbd>E</kbd>
177+</dt>
178+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
179+</div>
180+<div>
181+<dt>
175182 <kbd>n</kbd>
176183 </dt>
177184 <dd>merge request: next note in the diff</dd>
@@ -232,6 +239,8 @@
232239 k-split from:body -> ermite.layout('split');
233240 k-unified from:body -> ermite.layout('unified');
234241 k-collapse from:body -> ermite.collapse();
242+ k-expand from:body -> ermite.expandFile();
243+ k-expandAll from:body -> ermite.expandAll();
235244 k-permalink from:body -> ermite.permalink();
236245 k-merge from:body -> window.ermiteMR?.openMerge();
237246 k-read from:body -> window.ermiteMR?.markRead();
@@ -172,6 +172,13 @@
172 </div>172 </div>
173 <div>173 <div>
174 <dt>174 <dt>
175+<kbd>e</kbd>
176+<kbd>E</kbd>
177+</dt>
178+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
179+</div>
180+<div>
181+<dt>
175 <kbd>n</kbd>182 <kbd>n</kbd>
176 </dt>183 </dt>
177 <dd>merge request: next note in the diff</dd>184 <dd>merge request: next note in the diff</dd>
@@ -232,6 +239,8 @@
232 k-split from:body -> ermite.layout('split');239 k-split from:body -> ermite.layout('split');
233 k-unified from:body -> ermite.layout('unified');240 k-unified from:body -> ermite.layout('unified');
234 k-collapse from:body -> ermite.collapse();241 k-collapse from:body -> ermite.collapse();
242+ k-expand from:body -> ermite.expandFile();
243+ k-expandAll from:body -> ermite.expandAll();
235 k-permalink from:body -> ermite.permalink();244 k-permalink from:body -> ermite.permalink();
236 k-merge from:body -> window.ermiteMR?.openMerge();245 k-merge from:body -> window.ermiteMR?.openMerge();
237 k-read from:body -> window.ermiteMR?.markRead();246 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/refs.html +9 -0
@@ -302,6 +302,13 @@
302302 </div>
303303 <div>
304304 <dt>
305+<kbd>e</kbd>
306+<kbd>E</kbd>
307+</dt>
308+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
309+</div>
310+<div>
311+<dt>
305312 <kbd>n</kbd>
306313 </dt>
307314 <dd>merge request: next note in the diff</dd>
@@ -467,6 +474,8 @@
467474 k-split from:body -> ermite.layout('split');
468475 k-unified from:body -> ermite.layout('unified');
469476 k-collapse from:body -> ermite.collapse();
477+ k-expand from:body -> ermite.expandFile();
478+ k-expandAll from:body -> ermite.expandAll();
470479 k-permalink from:body -> ermite.permalink();
471480 k-merge from:body -> window.ermiteMR?.openMerge();
472481 k-read from:body -> window.ermiteMR?.markRead();
@@ -302,6 +302,13 @@
302 </div>302 </div>
303 <div>303 <div>
304 <dt>304 <dt>
305+<kbd>e</kbd>
306+<kbd>E</kbd>
307+</dt>
308+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
309+</div>
310+<div>
311+<dt>
305 <kbd>n</kbd>312 <kbd>n</kbd>
306 </dt>313 </dt>
307 <dd>merge request: next note in the diff</dd>314 <dd>merge request: next note in the diff</dd>
@@ -467,6 +474,8 @@
467 k-split from:body -> ermite.layout('split');474 k-split from:body -> ermite.layout('split');
468 k-unified from:body -> ermite.layout('unified');475 k-unified from:body -> ermite.layout('unified');
469 k-collapse from:body -> ermite.collapse();476 k-collapse from:body -> ermite.collapse();
477+ k-expand from:body -> ermite.expandFile();
478+ k-expandAll from:body -> ermite.expandAll();
470 k-permalink from:body -> ermite.permalink();479 k-permalink from:body -> ermite.permalink();
471 k-merge from:body -> window.ermiteMR?.openMerge();480 k-merge from:body -> window.ermiteMR?.openMerge();
472 k-read from:body -> window.ermiteMR?.markRead();481 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/summary.html +9 -0
@@ -331,6 +331,13 @@
331331 </div>
332332 <div>
333333 <dt>
334+<kbd>e</kbd>
335+<kbd>E</kbd>
336+</dt>
337+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
338+</div>
339+<div>
340+<dt>
334341 <kbd>n</kbd>
335342 </dt>
336343 <dd>merge request: next note in the diff</dd>
@@ -496,6 +503,8 @@
496503 k-split from:body -> ermite.layout('split');
497504 k-unified from:body -> ermite.layout('unified');
498505 k-collapse from:body -> ermite.collapse();
506+ k-expand from:body -> ermite.expandFile();
507+ k-expandAll from:body -> ermite.expandAll();
499508 k-permalink from:body -> ermite.permalink();
500509 k-merge from:body -> window.ermiteMR?.openMerge();
501510 k-read from:body -> window.ermiteMR?.markRead();
@@ -331,6 +331,13 @@
331 </div>331 </div>
332 <div>332 <div>
333 <dt>333 <dt>
334+<kbd>e</kbd>
335+<kbd>E</kbd>
336+</dt>
337+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
338+</div>
339+<div>
340+<dt>
334 <kbd>n</kbd>341 <kbd>n</kbd>
335 </dt>342 </dt>
336 <dd>merge request: next note in the diff</dd>343 <dd>merge request: next note in the diff</dd>
@@ -496,6 +503,8 @@
496 k-split from:body -> ermite.layout('split');503 k-split from:body -> ermite.layout('split');
497 k-unified from:body -> ermite.layout('unified');504 k-unified from:body -> ermite.layout('unified');
498 k-collapse from:body -> ermite.collapse();505 k-collapse from:body -> ermite.collapse();
506+ k-expand from:body -> ermite.expandFile();
507+ k-expandAll from:body -> ermite.expandAll();
499 k-permalink from:body -> ermite.permalink();508 k-permalink from:body -> ermite.permalink();
500 k-merge from:body -> window.ermiteMR?.openMerge();509 k-merge from:body -> window.ermiteMR?.openMerge();
501 k-read from:body -> window.ermiteMR?.markRead();510 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/tree-nested.html +9 -0
@@ -254,6 +254,13 @@
254254 </div>
255255 <div>
256256 <dt>
257+<kbd>e</kbd>
258+<kbd>E</kbd>
259+</dt>
260+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
261+</div>
262+<div>
263+<dt>
257264 <kbd>n</kbd>
258265 </dt>
259266 <dd>merge request: next note in the diff</dd>
@@ -403,6 +410,8 @@
403410 k-split from:body -> ermite.layout('split');
404411 k-unified from:body -> ermite.layout('unified');
405412 k-collapse from:body -> ermite.collapse();
413+ k-expand from:body -> ermite.expandFile();
414+ k-expandAll from:body -> ermite.expandAll();
406415 k-permalink from:body -> ermite.permalink();
407416 k-merge from:body -> window.ermiteMR?.openMerge();
408417 k-read from:body -> window.ermiteMR?.markRead();
@@ -254,6 +254,13 @@
254 </div>254 </div>
255 <div>255 <div>
256 <dt>256 <dt>
257+<kbd>e</kbd>
258+<kbd>E</kbd>
259+</dt>
260+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
261+</div>
262+<div>
263+<dt>
257 <kbd>n</kbd>264 <kbd>n</kbd>
258 </dt>265 </dt>
259 <dd>merge request: next note in the diff</dd>266 <dd>merge request: next note in the diff</dd>
@@ -403,6 +410,8 @@
403 k-split from:body -> ermite.layout('split');410 k-split from:body -> ermite.layout('split');
404 k-unified from:body -> ermite.layout('unified');411 k-unified from:body -> ermite.layout('unified');
405 k-collapse from:body -> ermite.collapse();412 k-collapse from:body -> ermite.collapse();
413+ k-expand from:body -> ermite.expandFile();
414+ k-expandAll from:body -> ermite.expandAll();
406 k-permalink from:body -> ermite.permalink();415 k-permalink from:body -> ermite.permalink();
407 k-merge from:body -> window.ermiteMR?.openMerge();416 k-merge from:body -> window.ermiteMR?.openMerge();
408 k-read from:body -> window.ermiteMR?.markRead();417 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/tree-root.html +9 -0
@@ -481,6 +481,13 @@
481481 </div>
482482 <div>
483483 <dt>
484+<kbd>e</kbd>
485+<kbd>E</kbd>
486+</dt>
487+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
488+</div>
489+<div>
490+<dt>
484491 <kbd>n</kbd>
485492 </dt>
486493 <dd>merge request: next note in the diff</dd>
@@ -630,6 +637,8 @@
630637 k-split from:body -> ermite.layout('split');
631638 k-unified from:body -> ermite.layout('unified');
632639 k-collapse from:body -> ermite.collapse();
640+ k-expand from:body -> ermite.expandFile();
641+ k-expandAll from:body -> ermite.expandAll();
633642 k-permalink from:body -> ermite.permalink();
634643 k-merge from:body -> window.ermiteMR?.openMerge();
635644 k-read from:body -> window.ermiteMR?.markRead();
@@ -481,6 +481,13 @@
481 </div>481 </div>
482 <div>482 <div>
483 <dt>483 <dt>
484+<kbd>e</kbd>
485+<kbd>E</kbd>
486+</dt>
487+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
488+</div>
489+<div>
490+<dt>
484 <kbd>n</kbd>491 <kbd>n</kbd>
485 </dt>492 </dt>
486 <dd>merge request: next note in the diff</dd>493 <dd>merge request: next note in the diff</dd>
@@ -630,6 +637,8 @@
630 k-split from:body -> ermite.layout('split');637 k-split from:body -> ermite.layout('split');
631 k-unified from:body -> ermite.layout('unified');638 k-unified from:body -> ermite.layout('unified');
632 k-collapse from:body -> ermite.collapse();639 k-collapse from:body -> ermite.collapse();
640+ k-expand from:body -> ermite.expandFile();
641+ k-expandAll from:body -> ermite.expandAll();
633 k-permalink from:body -> ermite.permalink();642 k-permalink from:body -> ermite.permalink();
634 k-merge from:body -> window.ermiteMR?.openMerge();643 k-merge from:body -> window.ermiteMR?.openMerge();
635 k-read from:body -> window.ermiteMR?.markRead();644 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/tree-slash-ref.html +9 -0
@@ -251,6 +251,13 @@
251251 </div>
252252 <div>
253253 <dt>
254+<kbd>e</kbd>
255+<kbd>E</kbd>
256+</dt>
257+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
258+</div>
259+<div>
260+<dt>
254261 <kbd>n</kbd>
255262 </dt>
256263 <dd>merge request: next note in the diff</dd>
@@ -400,6 +407,8 @@
400407 k-split from:body -> ermite.layout('split');
401408 k-unified from:body -> ermite.layout('unified');
402409 k-collapse from:body -> ermite.collapse();
410+ k-expand from:body -> ermite.expandFile();
411+ k-expandAll from:body -> ermite.expandAll();
403412 k-permalink from:body -> ermite.permalink();
404413 k-merge from:body -> window.ermiteMR?.openMerge();
405414 k-read from:body -> window.ermiteMR?.markRead();
@@ -251,6 +251,13 @@
251 </div>251 </div>
252 <div>252 <div>
253 <dt>253 <dt>
254+<kbd>e</kbd>
255+<kbd>E</kbd>
256+</dt>
257+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
258+</div>
259+<div>
260+<dt>
254 <kbd>n</kbd>261 <kbd>n</kbd>
255 </dt>262 </dt>
256 <dd>merge request: next note in the diff</dd>263 <dd>merge request: next note in the diff</dd>
@@ -400,6 +407,8 @@
400 k-split from:body -> ermite.layout('split');407 k-split from:body -> ermite.layout('split');
401 k-unified from:body -> ermite.layout('unified');408 k-unified from:body -> ermite.layout('unified');
402 k-collapse from:body -> ermite.collapse();409 k-collapse from:body -> ermite.collapse();
410+ k-expand from:body -> ermite.expandFile();
411+ k-expandAll from:body -> ermite.expandAll();
403 k-permalink from:body -> ermite.permalink();412 k-permalink from:body -> ermite.permalink();
404 k-merge from:body -> window.ermiteMR?.openMerge();413 k-merge from:body -> window.ermiteMR?.openMerge();
405 k-read from:body -> window.ermiteMR?.markRead();414 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/testdata/web/tree-subdir.html +9 -0
@@ -276,6 +276,13 @@
276276 </div>
277277 <div>
278278 <dt>
279+<kbd>e</kbd>
280+<kbd>E</kbd>
281+</dt>
282+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
283+</div>
284+<div>
285+<dt>
279286 <kbd>n</kbd>
280287 </dt>
281288 <dd>merge request: next note in the diff</dd>
@@ -425,6 +432,8 @@
425432 k-split from:body -> ermite.layout('split');
426433 k-unified from:body -> ermite.layout('unified');
427434 k-collapse from:body -> ermite.collapse();
435+ k-expand from:body -> ermite.expandFile();
436+ k-expandAll from:body -> ermite.expandAll();
428437 k-permalink from:body -> ermite.permalink();
429438 k-merge from:body -> window.ermiteMR?.openMerge();
430439 k-read from:body -> window.ermiteMR?.markRead();
@@ -276,6 +276,13 @@
276 </div>276 </div>
277 <div>277 <div>
278 <dt>278 <dt>
279+<kbd>e</kbd>
280+<kbd>E</kbd>
281+</dt>
282+<dd>diff: the whole file in view / every file, with the lines the hunks hide</dd>
283+</div>
284+<div>
285+<dt>
279 <kbd>n</kbd>286 <kbd>n</kbd>
280 </dt>287 </dt>
281 <dd>merge request: next note in the diff</dd>288 <dd>merge request: next note in the diff</dd>
@@ -425,6 +432,8 @@
425 k-split from:body -> ermite.layout('split');432 k-split from:body -> ermite.layout('split');
426 k-unified from:body -> ermite.layout('unified');433 k-unified from:body -> ermite.layout('unified');
427 k-collapse from:body -> ermite.collapse();434 k-collapse from:body -> ermite.collapse();
435+ k-expand from:body -> ermite.expandFile();
436+ k-expandAll from:body -> ermite.expandAll();
428 k-permalink from:body -> ermite.permalink();437 k-permalink from:body -> ermite.permalink();
429 k-merge from:body -> window.ermiteMR?.openMerge();438 k-merge from:body -> window.ermiteMR?.openMerge();
430 k-read from:body -> window.ermiteMR?.markRead();439 k-read from:body -> window.ermiteMR?.markRead();
M internal/app/web_commit_test.go +78 -0
@@ -185,3 +185,81 @@ func TestWebCommitPrivate(t *testing.T) {
185185 assert.Equal(t, normalise(missing), normalise(body), p)
186186 }
187187 }
188+
189+// The hidden lines of a diff: the page has a gap before a hunk and after the
190+// last one, and /context serves their rows for both layouts, numbered on both
191+// sides; a private repository's are the 404 page.
192+func TestWebDiffContext(t *testing.T) {
193+ h := newHarness(t, harnessOpts{})
194+ wt := t.TempDir()
195+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
196+ var src strings.Builder
197+ for i := 1; i <= 40; i++ {
198+ fmt.Fprintf(&src, "line %d <b>\n", i)
199+ }
200+ h.put(wt, "f.txt", []byte(src.String()), 0o644)
201+ h.commitAt(wt, "2026-02-01T10:00:00Z", "forty lines")
202+ h.put(wt, "f.txt", []byte(strings.Replace(src.String(), "line 20 <b>", "line twenty", 1)), 0o644)
203+ sha := h.commitAt(wt, "2026-02-02T10:00:00Z", "change line 20")
204+ h.createRepo("ctx", domain.VisibilityPublic)
205+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/ctx.git"), "main")
206+
207+ _, _, page := h.get("/ctx/commit/" + sha)
208+ gap := "/ctx/context/" + sha + "?key=g0&amp;n=16&amp;new=1&amp;old=1&amp;path=f.txt"
209+ assert.Contains(t, page, gap, "lines 1 to 16 before the hunk (17 to 23)")
210+ assert.Contains(t, page, "16 hidden lines")
211+ assert.Contains(t, page, "/ctx/context/"+sha+"?key=tail&amp;n=0&amp;new=24&amp;old=24&amp;path=f.txt")
212+
213+ st, _, body := h.get("/ctx/context/" + sha + "?key=g0&n=16&new=1&old=1&path=f.txt")
214+ require.Equal(t, 200, st)
215+ assert.Equal(t, 3, strings.Count(body, "line 16 &lt;b&gt;"), "unified once, split on both sides, escaped")
216+ assert.NotContains(t, body, "line 17")
217+ assert.NotContains(t, body, `class="gap"`, "nothing left")
218+
219+ st, _, body = h.get("/ctx/context/" + sha + "?key=tail&n=0&new=24&old=24&path=f.txt")
220+ require.Equal(t, 200, st)
221+ assert.Contains(t, body, "line 40 &lt;b&gt;")
222+ assert.NotContains(t, body, "line 23 ")
223+
224+ for _, bad := range []string{
225+ "/ctx/context/" + sha + "?n=1&new=1&old=1", // no path
226+ "/ctx/context/" + sha + "?n=1&new=0&old=1&path=f.txt", // line 0
227+ "/ctx/context/" + sha + "?n=-1&new=1&old=1&path=f.txt", // negative
228+ "/ctx/context/" + sha + "?n=1&new=99&old=99&path=f.txt", // past the end
229+ "/ctx/context/" + sha + "?n=1&new=1&old=1&path=nope.txt", // no such file
230+ "/ctx/context/" + sha[:7] + "?n=1&new=1&old=1&path=f.txt", // not a full id
231+ "/ctx/context/main?n=1&new=1&old=1&path=f.txt", // a ref
232+ } {
233+ st, _, _ := h.get(bad)
234+ assert.Equal(t, 404, st, bad)
235+ }
236+
237+ // Lines as Git numbers them: a lone CR is not a line break; the last line
238+ // of a file without a final newline keeps its marker.
239+ var cr strings.Builder
240+ cr.WriteString("a\rb\n")
241+ for i := 2; i <= 30; i++ {
242+ fmt.Fprintf(&cr, "row %d\n", i)
243+ }
244+ h.put(wt, "cr.txt", []byte(cr.String()+"last"), 0o644)
245+ h.commitAt(wt, "2026-02-03T10:00:00Z", "cr")
246+ h.put(wt, "cr.txt", []byte(strings.Replace(cr.String(), "row 15\n", "row fifteen\n", 1)+"last"), 0o644)
247+ crSHA := h.commitAt(wt, "2026-02-04T10:00:00Z", "change row 15")
248+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/ctx.git"), "main")
249+ _, _, crPage := h.get("/ctx/commit/" + crSHA)
250+ assert.Contains(t, crPage, "11 hidden lines", "lines 1 to 11 before the hunk at 12")
251+ st, _, body = h.get("/ctx/context/" + crSHA + "?n=11&new=1&old=1&path=cr.txt")
252+ require.Equal(t, 200, st)
253+ assert.Contains(t, body, "row 11")
254+ assert.NotContains(t, body, "row 12", "a lone CR does not shift the numbers")
255+ st, _, body = h.get("/ctx/context/" + crSHA + "?n=0&new=19&old=19&path=cr.txt")
256+ require.Equal(t, 200, st)
257+ assert.Contains(t, body, "last<span class=\"eol\"", "no newline at the end of the file")
258+
259+ // A private repository: the 404 page, nothing of the file.
260+ h.createRepo("ctx-hidden", domain.VisibilityPrivate)
261+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/ctx-hidden.git"), "main")
262+ st, _, body = h.get("/ctx-hidden/context/" + sha + "?n=0&new=1&old=1&path=f.txt")
263+ assert.Equal(t, 404, st)
264+ assert.NotContains(t, body, "line 1 ")
265+}
@@ -185,3 +185,81 @@ func TestWebCommitPrivate(t *testing.T) {
185 assert.Equal(t, normalise(missing), normalise(body), p)185 assert.Equal(t, normalise(missing), normalise(body), p)
186 }186 }
187 }187 }
188+
189+// The hidden lines of a diff: the page has a gap before a hunk and after the
190+// last one, and /context serves their rows for both layouts, numbered on both
191+// sides; a private repository's are the 404 page.
192+func TestWebDiffContext(t *testing.T) {
193+ h := newHarness(t, harnessOpts{})
194+ wt := t.TempDir()
195+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
196+ var src strings.Builder
197+ for i := 1; i <= 40; i++ {
198+ fmt.Fprintf(&src, "line %d <b>\n", i)
199+ }
200+ h.put(wt, "f.txt", []byte(src.String()), 0o644)
201+ h.commitAt(wt, "2026-02-01T10:00:00Z", "forty lines")
202+ h.put(wt, "f.txt", []byte(strings.Replace(src.String(), "line 20 <b>", "line twenty", 1)), 0o644)
203+ sha := h.commitAt(wt, "2026-02-02T10:00:00Z", "change line 20")
204+ h.createRepo("ctx", domain.VisibilityPublic)
205+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/ctx.git"), "main")
206+
207+ _, _, page := h.get("/ctx/commit/" + sha)
208+ gap := "/ctx/context/" + sha + "?key=g0&amp;n=16&amp;new=1&amp;old=1&amp;path=f.txt"
209+ assert.Contains(t, page, gap, "lines 1 to 16 before the hunk (17 to 23)")
210+ assert.Contains(t, page, "16 hidden lines")
211+ assert.Contains(t, page, "/ctx/context/"+sha+"?key=tail&amp;n=0&amp;new=24&amp;old=24&amp;path=f.txt")
212+
213+ st, _, body := h.get("/ctx/context/" + sha + "?key=g0&n=16&new=1&old=1&path=f.txt")
214+ require.Equal(t, 200, st)
215+ assert.Equal(t, 3, strings.Count(body, "line 16 &lt;b&gt;"), "unified once, split on both sides, escaped")
216+ assert.NotContains(t, body, "line 17")
217+ assert.NotContains(t, body, `class="gap"`, "nothing left")
218+
219+ st, _, body = h.get("/ctx/context/" + sha + "?key=tail&n=0&new=24&old=24&path=f.txt")
220+ require.Equal(t, 200, st)
221+ assert.Contains(t, body, "line 40 &lt;b&gt;")
222+ assert.NotContains(t, body, "line 23 ")
223+
224+ for _, bad := range []string{
225+ "/ctx/context/" + sha + "?n=1&new=1&old=1", // no path
226+ "/ctx/context/" + sha + "?n=1&new=0&old=1&path=f.txt", // line 0
227+ "/ctx/context/" + sha + "?n=-1&new=1&old=1&path=f.txt", // negative
228+ "/ctx/context/" + sha + "?n=1&new=99&old=99&path=f.txt", // past the end
229+ "/ctx/context/" + sha + "?n=1&new=1&old=1&path=nope.txt", // no such file
230+ "/ctx/context/" + sha[:7] + "?n=1&new=1&old=1&path=f.txt", // not a full id
231+ "/ctx/context/main?n=1&new=1&old=1&path=f.txt", // a ref
232+ } {
233+ st, _, _ := h.get(bad)
234+ assert.Equal(t, 404, st, bad)
235+ }
236+
237+ // Lines as Git numbers them: a lone CR is not a line break; the last line
238+ // of a file without a final newline keeps its marker.
239+ var cr strings.Builder
240+ cr.WriteString("a\rb\n")
241+ for i := 2; i <= 30; i++ {
242+ fmt.Fprintf(&cr, "row %d\n", i)
243+ }
244+ h.put(wt, "cr.txt", []byte(cr.String()+"last"), 0o644)
245+ h.commitAt(wt, "2026-02-03T10:00:00Z", "cr")
246+ h.put(wt, "cr.txt", []byte(strings.Replace(cr.String(), "row 15\n", "row fifteen\n", 1)+"last"), 0o644)
247+ crSHA := h.commitAt(wt, "2026-02-04T10:00:00Z", "change row 15")
248+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/ctx.git"), "main")
249+ _, _, crPage := h.get("/ctx/commit/" + crSHA)
250+ assert.Contains(t, crPage, "11 hidden lines", "lines 1 to 11 before the hunk at 12")
251+ st, _, body = h.get("/ctx/context/" + crSHA + "?n=11&new=1&old=1&path=cr.txt")
252+ require.Equal(t, 200, st)
253+ assert.Contains(t, body, "row 11")
254+ assert.NotContains(t, body, "row 12", "a lone CR does not shift the numbers")
255+ st, _, body = h.get("/ctx/context/" + crSHA + "?n=0&new=19&old=19&path=cr.txt")
256+ require.Equal(t, 200, st)
257+ assert.Contains(t, body, "last<span class=\"eol\"", "no newline at the end of the file")
258+
259+ // A private repository: the 404 page, nothing of the file.
260+ h.createRepo("ctx-hidden", domain.VisibilityPrivate)
261+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/ctx-hidden.git"), "main")
262+ st, _, body = h.get("/ctx-hidden/context/" + sha + "?n=0&new=1&old=1&path=f.txt")
263+ assert.Equal(t, 404, st)
264+ assert.NotContains(t, body, "line 1 ")
265+}

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 42

↑↓ moveenter openesc close