1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5// Commit is a commit: the message and facts, the diff tools, the diff.
6templ Commit(l Layout, p CommitPage) {
7 @Shell(l, commitRail(p)) {
8 <header class="show">
9 <h1>{ p.Subject }</h1>
10 if p.Body != "" {
11 <pre class="msg">{ p.Body }</pre>
12 }
13 <dl class="kv show-kv">
14 <dt>commit</dt>
15 <dd>{ p.SHA }</dd>
16 <dt>author</dt>
17 <dd>@personView(p.Author)</dd>
18 if p.Committer != nil {
19 <dt>committer</dt>
20 <dd>@personView(*p.Committer)</dd>
21 }
22 <dt>
23 if p.Merge {
24 merge of
25 } else {
26 parent
27 }
28 </dt>
29 <dd>
30 if p.Root {
31 none (root commit)
32 }
33 for i, par := range p.Parents {
34 if i > 0 {
35 { " + " }
36 }
37 <a href={ templ.SafeURL(par.Href) }>{ par.Label }</a>
38 }
39 if p.Merge {
40 { " · diff against the first parent" }
41 }
42 </dd>
43 if p.Checks.Total > 0 {
44 <dt>checks</dt>
45 <dd>@CommitChecks(p.Checks)</dd>
46 }
47 if len(p.Refs) > 0 {
48 <dt>refs</dt>
49 <dd>
50 for _, r := range p.Refs {
51 <span class="pill">{ r }</span>
52 }
53 </dd>
54 }
55 </dl>
56 </header>
57 <div class="diff-tools" role="group" aria-label="Diff view">
58 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
59 <span class="grow"></span>
60 @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
61 @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
62 @collapseButton()
63 </div>
64 if p.Invisible {
65 <p class="notice">
66 this diff holds invisible characters (direction controls, zero-width characters). They are shown as
67 <span class="inv"><U+XXXX></span> markers, never applied.
68 </p>
69 }
70 if p.Note != "" {
71 <p class="notice">{ p.Note }</p>
72 }
73 <div
74 id="diffs"
75 if p.Both {
76 data-diff={ p.View }
77 } else {
78 data-view={ p.View }
79 }
80 if p.Both && !p.Pinned {
81 hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
82 }
83 >
84 for _, f := range p.Files {
85 @DiffFileView(f, p.DiffView)
86 }
87 if len(p.Files) == 0 {
88 <p class="note">no changed files{ " " }
89 if p.Merge {
90 against the first parent.
91 } else {
92 in this commit.
93 }
94 </p>
95 }
96 </div>
97 }
98}
99
100// viewLink is a button of the diff tools. It is a link, so that the split
101// layout works without JavaScript; with it, and when both layouts are in the
102// page, a click only flips the layout.
103templ viewLink(p DiffView, view, label, key, href string) {
104 <a
105 href={ templ.SafeURL(href) }
106 class="btn vbtn"
107 data-view={ view }
108 aria-keyshortcuts={ key }
109 if p.View == view {
110 aria-current="true"
111 }
112 if p.Both {
113 data-fire={ view }
114 :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false"
115 }
116 >{ label }<kbd aria-hidden="true">{ key }</kbd></a>
117}
118
119// collapseButton folds every file of the diff, or opens them all again when
120// none is open; its label follows the state.
121templ collapseButton() {
122 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
123 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span><kbd aria-hidden="true">x</kbd>
124 </button>
125}
126
127templ personView(pr Person) {
128 { pr.Name }
129 if pr.WhenAt != "" {
130 { " · " }<time datetime={ pr.WhenAt } title={ pr.Abs }>{ pr.When }</time>{ " · " + pr.Abs }
131 }
132}
133
134templ commitRail(p CommitPage) {
135 <section aria-labelledby="r-files">
136 <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
137 @FileList(p.Files)
138 </section>
139}
140
141// FileList is the table of contents of a diff: status letter, path, +n -m
142// and an ASCII bar. The row of the file in view is marked.
143templ FileList(files []DiffFile) {
144 if len(files) > fileFilterMin {
145 <div class="ffilter">
146 <label for="files-filter" class="sr">filter the changed files</label>
147 <input
148 id="files-filter"
149 type="search"
150 placeholder="Filter files…"
151 autocomplete="off"
152 data-pick=".files > li"
153 hx-on:input="ermite.filter(this)"
154 />
155 </div>
156 }
157 <ol class="files" aria-label="Changed files">
158 for _, f := range files {
159 <li>
160 <a
161 href={ templ.SafeURL("#" + f.ID) }
162 title={ f.Path }
163 hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"
164 >
165 <span class="st" data-st={ f.Status } title={ statusName(f.Status) }>{ f.Status }</span>
166 <span class="nm"><span class="p">{ f.Name }</span>{ " " }<span class="d"><bdi>{ f.Dir }</bdi></span></span>
167 if f.OpenNotes > 0 {
168 <span class="cn" title={ plural(f.OpenNotes, "open comment", "open comments") }>{ itoa(f.OpenNotes) }</span>
169 } else if f.Notes > 0 {
170 <span class="cn done" title={ plural(f.Notes, "resolved comment", "resolved comments") }>{ itoa(f.Notes) }</span>
171 }
172 if f.Bin {
173 <span class="n mute">bin</span>
174 } else {
175 <span class="n"><span class="plus">+{ itoa(f.Add) }</span> <span class="minus">-{ itoa(f.Del) }</span></span>
176 }
177 </a>
178 </li>
179 }
180 </ol>
181}
182
183// DiffFileView is one file of the diff: a <details> (per-file collapse). The
184// unified and the split tables are both in it; #diffs[data-diff] shows one.
185// Every changed line keeps its +/- marker, so a change is never conveyed by
186// colour alone.
187templ DiffFileView(f DiffFile, p DiffView) {
188 <details
189 class={ "dfile", "lnw-" + itoa(f.Digits) }
190 id={ f.ID }
191 data-file
192 if f.Open {
193 open
194 }
195 >
196 <summary>
197 <span class="st">{ f.Status }</span>{ " " }<span class="p">{ f.Path }</span>{ " " }<span class="plus">+{ itoa(f.Add) }</span>{ " " }<span class="minus">-{ itoa(f.Del) }</span>
198 if f.Note != "" {
199 { " " }<span class="note">{ f.Note }</span>
200 }
201 </summary>
202 if f.Lazy {
203 // A placeholder: mr.js fetches the window of the file when it comes
204 // near the screen and swaps the files of the window in. Without
205 // scripts the link opens the page of that window.
206 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
207 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
208 </div>
209 } else if len(f.Hunks) > 0 {
210 <div class="dscroll">
211 if p.ShowUnified {
212 <table class="dt uni" aria-label={ f.Path + ", unified" }>
213 for _, h := range f.Hunks {
214 <tbody>
215 if h.Gap != nil {
216 @gapRow(*h.Gap, 4)
217 }
218 <tr class="hunk"><td colspan="4">{ h.Header }</td></tr>
219 for _, ln := range h.Lines {
220 <tr class={ ln.Kind }>
221 <td class="ln">@gutter(ln.Old, ln.OldHref)</td>
222 <td class="ln">@gutter(ln.New, ln.NewHref)</td>
223 <td class="m">{ ln.Mark }</td>
224 <td class="tx">@codeLine(ln)</td>
225 </tr>
226 if len(ln.Notes) > 0 || ln.Form != nil {
227 <tr class="fnote">
228 <td colspan="3"></td>
229 <td>@lineNotes(ln.Notes, ln.Form, "")</td>
230 </tr>
231 }
232 }
233 </tbody>
234 }
235 if f.Tail != nil {
236 <tbody>
237 @gapRow(*f.Tail, 4)
238 </tbody>
239 }
240 </table>
241 }
242 if p.ShowSplit {
243 <table class="dt spl" aria-label={ f.Path + ", split" }>
244 <colgroup><col class="c-ln"/><col class="c-m"/><col/><col class="c-ln"/><col class="c-m"/><col/></colgroup>
245 for _, h := range f.Hunks {
246 <tbody>
247 if h.Gap != nil {
248 @gapRow(*h.Gap, 6)
249 }
250 <tr class="hunk"><td colspan="6">{ h.Header }</td></tr>
251 for _, row := range h.Split {
252 <tr>
253 @splitSide(row.L, true)
254 @splitSide(row.R, false)
255 </tr>
256 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
257 <tr class="fnote">
258 <td colspan="6">@lineNotes(notes, form, "-s")</td>
259 </tr>
260 }
261 }
262 </tbody>
263 }
264 if f.Tail != nil {
265 <tbody>
266 @gapRow(*f.Tail, 6)
267 </tbody>
268 }
269 </table>
270 }
271 </div>
272 } else {
273 <p class="note pad">
274 if f.Note != "" {
275 { f.Note }
276 } else {
277 no textual change.
278 }
279 </p>
280 }
281 if len(f.Loose) > 0 {
282 <div class="dloose">
283 for _, c := range f.Loose {
284 @commentView(c, "")
285 }
286 </div>
287 }
288 </details>
289}
290
291// MRFiles is a window of files of a merge request, for the placeholders of
292// the page: the files only, which replace the placeholders of the same ids.
293templ MRFiles(p MRFilesPage) {
294 for _, f := range p.Files {
295 @DiffFileView(f, p.DiffView)
296 }
297}
298
299// MRFilesMoved answers a window of a diff that is not the one of the page any
300// more: the placeholder shows it instead of a file of another diff.
301templ MRFilesMoved() {
302 <p class="notice" data-moved>this merge request changed since the page was loaded — <a href="">reload</a></p>
303}
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.
308templ 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.
320templ 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
349// splitSide is one half of a row of the split view; a side without a line is
350// an empty cell.
351templ splitSide(ln *DiffLine, old bool) {
352 if ln == nil {
353 <td class="ln none"></td>
354 <td class="m none"></td>
355 <td class="tx none"></td>
356 } else {
357 <td class={ "ln", ln.Kind }>
358 if old {
359 @gutter(ln.Old, ln.OldHref)
360 } else {
361 @gutter(ln.New, ln.NewHref)
362 }
363 </td>
364 <td class={ "m", ln.Kind }>{ ln.Mark }</td>
365 <td class={ "tx", ln.Kind }>@codeLine(*ln)</td>
366 }
367}
368
369// gutter is a line number of the gutter. On a merge request that can be
370// commented on, it links to the form that comments on that line.
371templ gutter(n, href string) {
372 if href != "" {
373 <a href={ templ.SafeURL(href) } title="comment on this line">{ n }</a>
374 } else {
375 { n }
376 }
377}