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.Sig != nil {
44 <dt>signature</dt>
45 <dd>
46 @signaturePill(p.Sig)
47 if p.Verification.Fingerprint != "" {
48 <code>{ p.Verification.Fingerprint }</code>
49 }
50 if p.Verification.KeyTitle != "" {
51 <span class="dim">{ "· " + visibleText(p.Verification.KeyTitle) }</span>
52 }
53 if p.Sig.Class != "sig-none" {
54 <span class="sig-why dim">{ p.Sig.Reason }</span>
55 }
56 </dd>
57 }
58 if p.Checks.Total > 0 {
59 <dt>checks</dt>
60 <dd>@CommitChecks(p.Checks)</dd>
61 }
62 if len(p.Refs) > 0 {
63 <dt>refs</dt>
64 <dd>
65 for _, r := range p.Refs {
66 <span class="pill">{ r }</span>
67 }
68 </dd>
69 }
70 </dl>
71 </header>
72 <div class="diff-tools" role="group" aria-label="Diff view">
73 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
74 <span class="grow"></span>
75 @diffViewMenu(p.DiffView)
76 </div>
77 if p.Invisible {
78 <p class="notice">
79 this diff holds invisible characters (direction controls, zero-width characters). They are shown as
80 <span class="inv"><U+XXXX></span> markers, never applied.
81 </p>
82 }
83 if p.Note != "" {
84 <p class="notice">{ p.Note }</p>
85 }
86 <div
87 id="diffs"
88 if p.Both {
89 data-diff={ p.View }
90 } else {
91 data-view={ p.View }
92 }
93 if p.Both && !p.Pinned {
94 hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
95 }
96 >
97 for _, f := range p.Files {
98 @DiffFileView(f, p.DiffView)
99 }
100 if len(p.Files) == 0 {
101 <p class="note">no changed files{ " " }
102 if p.Merge {
103 against the first parent.
104 } else {
105 in this commit.
106 }
107 </p>
108 }
109 </div>
110 }
111}
112
113// diffViewMenu is the layout of the diff (unified, the default, or split) and
114// the fold of its files, in one menu: a <details> that works without scripts;
115// u, d and x still act without opening it. Its button names the layout shown.
116templ diffViewMenu(p DiffView) {
117 <details class="menu vmenu">
118 <summary class="btn" aria-label="Diff view">
119 if p.Both {
120 <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">{ viewLabel(p.View) }</span>
121 } else {
122 { viewLabel(p.View) }
123 }
124 </summary>
125 <div class="menu-pop" role="group" aria-label="Diff view">
126 @viewLink(p, "unified", "Unified", "u", p.UnifiedHref)
127 @viewLink(p, "split", "Split", "d", p.SplitHref)
128 @collapseButton()
129 </div>
130 </details>
131}
132
133// viewLink is a button of the diff tools. It is a link, so that the split
134// layout works without JavaScript; with it, and when both layouts are in the
135// page, a click only flips the layout.
136templ viewLink(p DiffView, view, label, key, href string) {
137 <a
138 href={ templ.SafeURL(href) }
139 class="btn vbtn"
140 data-view={ view }
141 aria-keyshortcuts={ key }
142 if view == "split" {
143 :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false"
144 :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'"
145 }
146 if p.View == view {
147 aria-current="true"
148 }
149 if p.Both {
150 data-fire={ view }
151 :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false"
152 }
153 >{ label }<kbd aria-hidden="true">{ key }</kbd></a>
154}
155
156// collapseButton folds every file of the diff, or opens them all again when
157// none is open; its label follows the state.
158templ collapseButton() {
159 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
160 <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>
161 </button>
162}
163
164templ personView(pr Person) {
165 { pr.Name }
166 if pr.WhenAt != "" {
167 { " · " }<time datetime={ pr.WhenAt } title={ pr.Abs }>{ pr.When }</time>{ " · " + pr.Abs }
168 }
169}
170
171templ commitRail(p CommitPage) {
172 <section aria-labelledby="r-files">
173 <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
174 @FileList(p.Files)
175 </section>
176}
177
178// FileList is the table of contents of a diff: status letter, path, +n -m
179// and an ASCII bar. The row of the file in view is marked.
180templ FileList(files []DiffFile) {
181 if len(files) > fileFilterMin {
182 <div class="ffilter">
183 <label for="files-filter" class="sr">filter the changed files</label>
184 <input
185 id="files-filter"
186 type="search"
187 placeholder="Filter files…"
188 autocomplete="off"
189 data-pick=".files > li"
190 hx-on:input="ermite.filter(this)"
191 />
192 </div>
193 }
194 <ol class="files" aria-label="Changed files">
195 for _, f := range files {
196 <li>
197 <a
198 href={ templ.SafeURL("#" + f.ID) }
199 title={ f.Path }
200 hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"
201 >
202 <span class="st" data-st={ f.Status } title={ statusName(f.Status) }>{ f.Status }</span>
203 <span class="nm"><span class="p">{ f.Name }</span>{ " " }<span class="d"><bdi>{ f.Dir }</bdi></span></span>
204 if f.OpenNotes > 0 {
205 <span class="cn" title={ plural(f.OpenNotes, "open comment", "open comments") }>{ itoa(f.OpenNotes) }</span>
206 } else if f.Notes > 0 {
207 <span class="cn done" title={ plural(f.Notes, "resolved comment", "resolved comments") }>{ itoa(f.Notes) }</span>
208 }
209 if f.Bin {
210 <span class="n mute">bin</span>
211 } else {
212 <span class="n"><span class="plus">+{ itoa(f.Add) }</span> <span class="minus">-{ itoa(f.Del) }</span></span>
213 }
214 </a>
215 </li>
216 }
217 </ol>
218}
219
220// DiffFileView is one file of the diff: a <details> (per-file collapse). The
221// unified and the split tables are both in it; #diffs[data-diff] shows one.
222// Every changed line keeps its +/- marker, so a change is never conveyed by
223// colour alone.
224templ DiffFileView(f DiffFile, p DiffView) {
225 <details
226 class={ "dfile", "lnw-" + itoa(f.Digits) }
227 id={ f.ID }
228 data-file={ f.Key }
229 if f.Open {
230 open
231 }
232 >
233 <summary>
234 <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>
235 if f.Note != "" {
236 { " " }<span class="note">{ f.Note }</span>
237 }
238 </summary>
239 if f.Lazy {
240 // A placeholder: lazy.js fetches the window of the file when it comes
241 // near the screen and swaps the files of the window in. Without
242 // scripts the link opens the page of that window.
243 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
244 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
245 </div>
246 } else if f.Manual {
247 // A collapsed file (generated, large, over the page budget): no
248 // rows. "load diff" reads this one file and swaps it in place
249 // (lazy.js); without scripts the link reloads the page with the file.
250 <div class="dlazy" data-src={ f.Src } data-manual>
251 <a href={ templ.SafeURL(f.Href) }>load diff</a>
252 </div>
253 } else if len(f.Hunks) > 0 {
254 <div class="dscroll">
255 if p.ShowUnified {
256 <table class="dt uni" aria-label={ f.Path + ", unified" }>
257 for _, h := range f.Hunks {
258 <tbody>
259 if h.Gap != nil {
260 @gapRow(*h.Gap, 4)
261 }
262 <tr class="hunk"><td colspan="4">{ h.Header }</td></tr>
263 for _, ln := range h.Lines {
264 <tr class={ ln.Kind, templ.KV("sel", ln.Selected) }>
265 <td class="ln">@gutter(ln.Old, ln.OldHref)</td>
266 <td class="ln">@gutter(ln.New, ln.NewHref)</td>
267 <td class="m">{ ln.Mark }</td>
268 <td class="tx chroma">@codeLine(ln)</td>
269 </tr>
270 if len(ln.Notes) > 0 || ln.Form != nil {
271 <tr class="fnote">
272 <td colspan="3"></td>
273 <td>@lineNotes(ln.Notes, ln.Form, "")</td>
274 </tr>
275 }
276 }
277 </tbody>
278 }
279 if f.Tail != nil {
280 <tbody>
281 @gapRow(*f.Tail, 4)
282 </tbody>
283 }
284 </table>
285 }
286 if p.ShowSplit {
287 <table class="dt spl" aria-label={ f.Path + ", split" }>
288 <colgroup><col class="c-ln"/><col class="c-m"/><col/><col class="c-ln"/><col class="c-m"/><col/></colgroup>
289 for _, h := range f.Hunks {
290 <tbody>
291 if h.Gap != nil {
292 @gapRow(*h.Gap, 6)
293 }
294 <tr class="hunk"><td colspan="6">{ h.Header }</td></tr>
295 for _, row := range h.Split {
296 <tr>
297 @splitSide(row.L, true)
298 @splitSide(row.R, false)
299 </tr>
300 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
301 <tr class="fnote">
302 <td colspan="6">@lineNotes(notes, form, "-s")</td>
303 </tr>
304 }
305 }
306 </tbody>
307 }
308 if f.Tail != nil {
309 <tbody>
310 @gapRow(*f.Tail, 6)
311 </tbody>
312 }
313 </table>
314 }
315 </div>
316 } else if f.Raw != "" {
317 <p class="note pad">{ f.Note } <a href={ templ.SafeURL(f.Raw) }>view raw</a></p>
318 } else {
319 <p class="note pad">
320 if f.Note != "" {
321 { f.Note }
322 } else {
323 no textual change.
324 }
325 </p>
326 }
327 if len(f.Loose) > 0 {
328 <div class="dloose">
329 for _, c := range f.Loose {
330 @commentView(c, "")
331 }
332 </div>
333 }
334 </details>
335}
336
337// MRFiles is a window of files of a merge request, for the placeholders of
338// the page: the files only, which replace the placeholders of the same ids.
339templ MRFiles(p MRFilesPage) {
340 for _, f := range p.Files {
341 @DiffFileView(f, p.DiffView)
342 }
343}
344
345// MRFilesMoved answers a window of a diff that is not the one of the page any
346// more: the placeholder shows it instead of a file of another diff.
347templ MRFilesMoved() {
348 <p class="notice" data-moved>this merge request changed since the page was loaded — <a href="">reload</a></p>
349}
350
351// gapRow is the row of the hidden lines before a hunk, or of the rest of the
352// file: a button that loads them in their place (ermite.js, ermite.expand).
353// Without scripts nothing loads them: hidden.
354templ gapRow(g DiffGap, cols int) {
355 <tr class="gap" data-js data-gap={ g.Key }>
356 <td colspan={ itoa(cols) }>
357 <button type="button" data-src={ g.Src }>
358 <span aria-hidden="true">⋯</span> { g.Label() }
359 </button>
360 </td>
361 </tr>
362}
363
364// DiffContext is the hidden lines of a gap, for /context: one body a layout,
365// whose rows replace the rows of the gap in the table of that layout.
366templ DiffContext(c ContextRows) {
367 <table>
368 <tbody data-for="uni">
369 for _, ln := range c.Lines {
370 <tr class="ctx">
371 <td class="ln">{ ln.Old }</td>
372 <td class="ln">{ ln.New }</td>
373 <td class="m">{ ln.Mark }</td>
374 <td class="tx chroma">@codeLine(ln)</td>
375 </tr>
376 }
377 if c.More != nil {
378 @gapRow(*c.More, 4)
379 }
380 </tbody>
381 <tbody data-for="spl">
382 for i := range c.Lines {
383 <tr>
384 @splitSide(&c.Lines[i], true)
385 @splitSide(&c.Lines[i], false)
386 </tr>
387 }
388 if c.More != nil {
389 @gapRow(*c.More, 6)
390 }
391 </tbody>
392 </table>
393}
394
395// splitSide is one half of a row of the split view; a side without a line is
396// an empty cell.
397templ splitSide(ln *DiffLine, old bool) {
398 if ln == nil {
399 <td class="ln none"></td>
400 <td class="m none"></td>
401 <td class="tx none"></td>
402 } else {
403 <td class={ "ln", ln.Kind, templ.KV("sel", ln.Selected) }>
404 if old {
405 @gutter(ln.Old, ln.OldHref)
406 } else {
407 @gutter(ln.New, ln.NewHref)
408 }
409 </td>
410 <td class={ "m", ln.Kind, templ.KV("sel", ln.Selected) }>{ ln.Mark }</td>
411 <td class={ "tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected) }>@codeLine(*ln)</td>
412 }
413}
414
415// gutter is a line number of the gutter. On a merge request that can be
416// commented on, it links to the form that comments on that line.
417templ gutter(n, href string) {
418 if href != "" {
419 <a href={ templ.SafeURL(href) } title="comment on this line">{ n }</a>
420 } else {
421 { n }
422 }
423}