1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "strconv"
7 "strings"
8)
9
10// MRList is the list of the merge requests of a repository: the open ones,
11// then the merged and closed ones.
12templ MRList(l Layout, p MRListPage) {
13 @Shell(l, mrListRail(p)) {
14 if p.Live {
15 @LiveSlot("mr-notice")
16 }
17 <div class="filter">
18 <label for="mr-filter" class="sr">filter</label>
19 <input
20 id="mr-filter"
21 type="search"
22 data-filter=".mrs tbody tr"
23 placeholder="Filter by title, branch, author or state…"
24 autocomplete="off"
25 hx-on:input="ermite.filter(this)"
26 />
27 </div>
28 if p.Newest == "" {
29 <h2 class="rule-h"><span>Open</span><span class="count">{ itoa(len(p.Open)) }</span></h2>
30 @mrRows(p.Open, "Open merge requests")
31 if len(p.Open) == 0 {
32 <p class="lede dim">no open merge requests.</p>
33 }
34 if p.OpenNote != "" {
35 <p class="note">{ p.OpenNote }</p>
36 }
37 }
38 <h2 class="rule-h"><span>Merged and closed</span><span class="count">{ itoa(len(p.Done)) }</span></h2>
39 @mrRows(p.Done, "Merged and closed merge requests")
40 if len(p.Done) == 0 {
41 <p class="lede dim">nothing merged or closed yet.</p>
42 }
43 if p.Older != "" || p.Newest != "" {
44 <nav class="pager" aria-label="Pages">
45 if p.Newest != "" {
46 <a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a>
47 } else {
48 <span class="btn" aria-disabled="true">← Newest</span>
49 }
50 if p.Older != "" {
51 <a class="btn" href={ templ.SafeURL(p.Older) } rel="next">Older →</a>
52 } else {
53 <span class="btn" aria-disabled="true">Older →</span>
54 }
55 </nav>
56 }
57 }
58}
59
60templ mrRows(rows []MRRow, label string) {
61 if len(rows) > 0 {
62 <table class="list mrs" aria-label={ label }>
63 <tbody data-rows>
64 for _, m := range rows {
65 <tr data-cursor?={ m.Cursor } data-state={ m.State }>
66 <td class="c-n mute">{ m.Number }</td>
67 <td class="c-state">{ m.State }</td>
68 <td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <span class="mute">{ m.Branches }</span></td>
69 <td class="c-who dim">{ m.Author }</td>
70 <td class="c-when nowrap dim">
71 if m.WhenAt != "" {
72 <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
73 }
74 </td>
75 </tr>
76 }
77 </tbody>
78 </table>
79 }
80}
81
82templ mrListRail(p MRListPage) {
83 <section aria-labelledby="r-states">
84 <h2 id="r-states">States</h2>
85 <dl class="kv">
86 <dt>open</dt>
87 <dd>awaits review</dd>
88 <dt>merged</dt>
89 <dd>the target took the branch</dd>
90 <dt>closed</dt>
91 <dd>refused, branch kept</dd>
92 </dl>
93 </section>
94 <section aria-labelledby="r-new">
95 <h2 id="r-new">Open one</h2>
96 if len(p.Branches) > 0 {
97 <p class="dim">compare a branch with { p.Default }:</p>
98 <nav class="outline" aria-label="Branches">
99 for _, b := range p.Branches {
100 <a href={ templ.SafeURL(b.Href) }>{ b.Name }</a>
101 }
102 </nav>
103 } else {
104 <p class="dim">there is no other branch to merge yet.</p>
105 }
106 @CloneBlock(p.CLI)
107 </section>
108}
109
110// MergeRequest is a merge request: its facts, its description, the diff with
111// the notes under their lines, the general comments; and in the rail the merge
112// form, the checks, the settings, the commits and the files.
113templ MergeRequest(l Layout, p MRPage) {
114 @Shell(l, mrRail(p)) {
115 <section class="mr-card">
116 <header class="show mr-h">
117 <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
118 <dl class="kv show-kv">
119 <dt>state</dt>
120 <dd class="mr-state" data-state={ p.State }>
121 { p.State }
122 if p.MergedShort != "" {
123 { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
124 }
125 </dd>
126 <dt>branch</dt>
127 <dd>
128 @branchLink(p.Source, p.SourceHref)
129 { " → " }
130 @branchLink(p.Target, p.TargetHref)
131 </dd>
132 <dt>opened</dt>
133 <dd>
134 { p.Opened.Name }
135 if p.Opened.WhenAt != "" {
136 { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
137 }
138 </dd>
139 if p.UpdatedAt != "" {
140 <dt>updated</dt>
141 <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
142 }
143 </dl>
144 </header>
145 if p.Desc != "" {
146 <article class="mr-desc prose" aria-label="description" hx-ignore>
147 @templ.Raw(string(p.Desc))
148 </article>
149 }
150 </section>
151 if p.Deleting != "" {
152 <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>
153 }
154 if p.MergeError != "" {
155 <p class="d-err" role="alert">{ p.MergeError }</p>
156 }
157 @LiveSlot("mr-notice")
158 if p.Since != nil {
159 <nav class="since" aria-label="Diff range">
160 <a
161 href={ templ.SafeURL(p.Since.AllHref) }
162 if !p.Since.Current {
163 aria-current="page"
164 }
165 >all changes <span class="mute">vs { p.Target }</span></a>
166 <a
167 href={ templ.SafeURL(p.Since.SinceHref) }
168 if p.Since.Current {
169 aria-current="page"
170 }
171 >since your last read <span class="mute">{ p.Since.ReadShort } → { p.Since.HeadShort }</span></a>
172 </nav>
173 }
174 if p.SinceNote != "" {
175 <p class="notice">{ p.SinceNote }</p>
176 }
177 @mrBar(p)
178 if p.Invisible {
179 <p class="notice">
180 this diff holds invisible characters (direction controls, zero-width characters). They are shown as
181 <span class="inv"><U+XXXX></span> markers, never applied.
182 </p>
183 }
184 if p.Note != "" {
185 <p class="notice">{ p.Note }</p>
186 }
187 <div
188 id="diffs"
189 data-mr
190 if p.Canonical != "" {
191 data-canonical={ p.Canonical }
192 }
193 if p.Both {
194 data-diff={ p.View }
195 } else {
196 data-view={ p.View }
197 }
198 if p.Both && !p.Pinned {
199 hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
200 }
201 >
202 for _, f := range p.Files {
203 @DiffFileView(f, p.DiffView)
204 }
205 if len(p.Files) == 0 {
206 <p class="note">no changed files.</p>
207 }
208 </div>
209 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
210 for _, c := range p.Loose {
211 @commentView(c, "")
212 }
213 for _, c := range p.General {
214 @commentView(c, "")
215 }
216 if p.NComments == 0 {
217 <p class="dim">no comments yet.</p>
218 }
219 if p.GeneralForm != nil {
220 @commentForm(*p.GeneralForm, "")
221 }
222 }
223}
224
225templ branchLink(name, href string) {
226 if href != "" {
227 <a href={ templ.SafeURL(href) }>{ name }</a>
228 } else {
229 { name }
230 }
231}
232
233// lineNotes are the comments under a line of the diff, and the form to write
234// one when the reader asked for it. The diff is in the page twice (unified and
235// split); the suffix keeps the ids unique, the unified table having none.
236templ lineNotes(notes []CommentView, form *CommentForm, suffix string) {
237 for _, c := range notes {
238 @commentView(c, suffix)
239 }
240 if form != nil {
241 @commentForm(*form, suffix)
242 }
243}
244
245// commentView is a note: a ruled block, under its line in the diff or in the
246// list of comments. Body comes only from the safe Markdown renderer.
247templ commentView(c CommentView, suffix string) {
248 <div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) } data-thread?={ c.Reply != nil }>
249 <p class="c-head">
250 <span class="c-who">{ c.Author }</span>
251 if c.WhenAt != "" {
252 { " " }<time class="mute" datetime={ c.WhenAt } title={ c.Abs }>{ c.When }</time>
253 }
254 if c.Resolved {
255 { " " }<span class="c-state">resolved</span>
256 }
257 if c.Outdated {
258 { " " }<span class="c-state" title="written on an earlier head; the line may have moved">outdated</span>
259 }
260 if c.Where != "" {
261 { " " }<span class="c-where mute">{ c.Where }</span>
262 }
263 </p>
264 if len(c.Parts) > 0 {
265 <p class="c-text">@userText(c.Parts)</p>
266 } else if c.Deleted {
267 if c.Verdict != "" {
268 <p class="mute">text deleted</p>
269 } else {
270 <p class="mute">deleted note</p>
271 }
272 } else {
273 <div class="c-body prose">
274 if suffix == "-s" {
275 @templ.Raw(string(c.BodySplit))
276 } else {
277 @templ.Raw(string(c.Body))
278 }
279 </div>
280 }
281 if c.Edited {
282 <span class="mute">edited</span>
283 }
284 if c.Verdict != "" {
285 <span class="c-state">{ c.Verdict }</span>
286 }
287 if c.Range != "" {
288 <span class="mute">{ c.Range }</span>
289 }
290 if c.Quote != "" {
291 <button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
292 }
293 if c.Edit != nil {
294 <details><summary>Edit</summary><form method="post" action={ templ.SafeURL(c.Edit.Action) }><input type="hidden" name="from" value={ itoa(c.Edit.From) }/><textarea name="text" maxlength="8000">{ c.Edit.Text }</textarea><button class="btn" type="submit">Save</button></form></details>
295 }
296 if c.Delete != nil {
297 <details><summary>Delete</summary><form method="post" action={ templ.SafeURL(c.Delete.Action) }><input type="hidden" name="from" value={ itoa(c.Delete.From) }/><p>Delete this note?</p><button class="btn" type="submit">Confirm delete</button></form></details>
298 }
299 for _, reply := range c.Replies {
300 @commentView(reply, suffix)
301 }
302 if c.Reply != nil {
303 <details data-reply>
304 <summary>Reply</summary>@commentForm(*c.Reply, suffix)
305 </details>
306 }
307 if c.Resolve != nil {
308 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
309 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
310 if c.Resolve.From > 0 {
311 <input type="hidden" name="from" value={ itoa(c.Resolve.From) }/>
312 }
313 <button type="submit" class="btn">{ c.Resolve.Label }</button>
314 </form>
315 }
316 </div>
317}
318
319func boolText(b bool) string {
320 if b {
321 return "true"
322 }
323 return "false"
324}
325
326// commentForm writes a comment, general or on a line.
327templ commentForm(f CommentForm, suffix string) {
328 <form class="cform" method="post" action={ templ.SafeURL(f.Action) }>
329 <label for={ f.ID + suffix }>{ f.Title }</label>
330 <textarea
331 id={ f.ID + suffix }
332 name="text"
333 rows="3"
334 maxlength="8000"
335 if f.Plain {
336 required
337 placeholder="plain text, 8000 characters at most"
338 } else {
339 placeholder="Markdown, 8000 characters at most"
340 }
341 if f.Focus {
342 autofocus
343 }
344 ></textarea>
345 if f.Path != "" {
346 <input type="hidden" name="path" value={ f.Path }/>
347 <input type="hidden" name="line" value={ f.Line }/>
348 <input type="hidden" name="side" value={ f.Side }/>
349 <label>from line <input type="number" name="start_line" min="1" value={ f.StartLine }/></label>
350 }
351 if f.ReplyTo != "" {
352 <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
353 }
354 if f.Path == "" && f.ReplyTo == "" && !f.Plain {
355 <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>
356 }
357 if f.From > 0 {
358 <input type="hidden" name="from" value={ itoa(f.From) }/>
359 }
360 <div class="f-btns">
361 <button type="submit" class="btn primary">Comment</button>
362 if f.Cancel != "" {
363 <a class="btn" href={ templ.SafeURL(f.Cancel) }>Cancel</a>
364 }
365 </div>
366 </form>
367}
368
369templ mrRail(p MRPage) {
370 <section aria-labelledby="r-files" class="r-files">
371 <h2 id="r-files">
372 Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd>
373 <button type="button" class="btn icon fold" data-js aria-expanded="true" title="fold the list of files" hx-on:click="ermiteMR.foldFiles()">
374 <span class="ico i-chevron" aria-hidden="true"></span><span class="sr">fold the list of files</span>
375 </button>
376 </h2>
377 @FileList(p.Files)
378 </section>
379 if len(p.Verdicts) > 0 {
380 <section>
381 <h2>Verdicts</h2>
382 <ul>
383 for _, v := range p.Verdicts {
384 <li>
385 <a href={ templ.SafeURL(v.Href) }>{ v.Author }: { v.Label }</a>
386 if v.Stale {
387 <span class="mute">(stale)</span>
388 }
389 </li>
390 }
391 </ul>
392 </section>
393 }
394 @MRChecks(p.Checks)
395 <section aria-labelledby="r-commits">
396 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
397 <ul class="pcommits">
398 for _, c := range p.Commits {
399 <li data-new?={ c.New }>
400 <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a>
401 <span class="s">{ c.Subject }</span>
402 if c.Sig != nil {
403 @signaturePill(c.Sig)
404 }
405 <span class="mute">
406 { c.Author }
407 if c.WhenAt != "" {
408 { " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
409 }
410 if c.New {
411 { " · new" }
412 }
413 </span>
414 </li>
415 }
416 </ul>
417 if len(p.Commits) == 0 {
418 <p class="dim">no commit to merge.</p>
419 }
420 if p.CommitsNote != "" {
421 <p class="note">{ p.CommitsNote }</p>
422 }
423 </section>
424 <section aria-labelledby="r-settings">
425 <h2 id="r-settings">Settings</h2>
426 <dl class="kv">
427 <dt>modes</dt>
428 <dd>{ strings.Join(p.Modes, ", ") }</dd>
429 <dt>checks</dt>
430 <dd>
431 if p.ChecksBlock {
432 failing ones block the merge
433 } else {
434 do not block the merge
435 }
436 </dd>
437 </dl>
438 </section>
439}
440
441// mrBar is the bar above the diff, which follows the review: the totals, the
442// layout of the diff, the state of the checks and, for the owner of an open
443// request, the merge menu and the other actions. Each menu is a <details>: it
444// works without scripts; with them, M and the keys of the menu drive it.
445templ mrBar(p MRPage) {
446 <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
447 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
448 @diffViewMenu(p.DiffView)
449 <span class="grow"></span>
450 @MRChecksSum(p.ChecksSum)
451 if p.Box != nil {
452 @mergeMenu(p)
453 @moreMenu(p)
454 }
455 </div>
456}
457
458// mergeMenu is the merge of an open request, for the owner: the form (it
459// carries the head that was read), or "Ready for review" for a draft. The
460// mode and the branch deletion have keys (1-3, D) while it is open.
461//
462// The badge is a live slot (mr-mergeability); the modes and the button read
463// its data-* attributes (hx-live bindings), so that they follow the slot when
464// the stream replaces its content. The server renders the same state in the
465// attributes, for a page without scripts.
466templ mergeMenu(p MRPage) {
467 <details class="menu mmenu" id="merge-menu" data-state="open" open?={ p.Box.Error != "" }>
468 <summary class="btn primary" aria-keyshortcuts="Shift+M">
469 if p.Box.ReadyAction != "" {
470 Draft
471 } else {
472 Merge
473 }
474 <kbd aria-hidden="true">M</kbd>
475 </summary>
476 <div class="menu-pop" role="group" aria-label="Merge">
477 <dl class="kv">
478 <dt>head</dt>
479 <dd>
480 if p.Box.HeadGone {
481 gone: the source branch was deleted
482 } else {
483 { p.HeadShort }
484 }
485 </dd>
486 <dt>into</dt>
487 <dd>{ p.Target }</dd>
488 </dl>
489 if p.Box.Error != "" {
490 <p class="d-err" role="alert">{ p.Box.Error }</p>
491 }
492 if p.Box.ReadyAction != "" {
493 <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
494 <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
495 <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>
496 </form>
497 } else if !p.Box.HeadGone {
498 @mrMergeabilitySlot(p.Box.Mergeability)
499 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
500 <input type="hidden" name="head_id" value={ p.Box.Head }/>
501 <fieldset class="d-modes">
502 <legend class="sr">merge mode</legend>
503 for i, m := range p.Box.Modes {
504 <label>
505 <input
506 type="radio"
507 name="mode"
508 value={ m.Value }
509 checked?={ i == p.Box.Checked() }
510 disabled?={ m.Disabled }
511 title={ m.Reason }
512 :disabled="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true"
513 :title="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason"
514 />
515 { m.Label }
516 if i < 9 {
517 <kbd aria-hidden="true">{ itoa(i + 1) }</kbd>
518 }
519 </label>
520 }
521 </fieldset>
522 if p.Box.Signed {
523 <p class="d-note" id="mr-signing">
524 squash and merge commits are signed with ermite's own key (generated on first start).
525 if p.Box.Fingerprint != "" {
526 fingerprint <code>{ p.Box.Fingerprint }</code>
527 }
528 <code>ermite signing-key show</code> prints it.
529 </p>
530 }
531 if p.Box.CanDeleteBranch {
532 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>
533 }
534 <div class="d-btns">
535 <button
536 type="submit"
537 class="btn primary"
538 data-default
539 disabled?={ !p.Box.CanMerge() }
540 title={ p.Box.MergeTitle() }
541 :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
542 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
543 >Merge into { p.Target }</button>
544 </div>
545 </form>
546 }
547 </div>
548 </details>
549}
550
551// MRMergeability is the content of the mergeability slot: the badge, and the
552// state the controls of the merge box read.
553templ MRMergeability(v MergeabilityView) {
554 <span
555 data-mergeability
556 data-state={ v.State }
557 data-ff-ok={ strconv.FormatBool(v.FFOk) }
558 data-merge-ok={ strconv.FormatBool(v.MergeOk) }
559 data-ff-reason={ v.FFReason }
560 data-merge-reason={ v.MergeReason }
561 >
562 <span class="badge" data-state={ v.State }>{ v.Label }</span>
563 if v.Detail != "" {
564 { " " }<span class="dim">{ v.Detail }</span>
565 }
566 </span>
567 if len(v.Paths) > 0 {
568 <ul class="mg-paths" aria-label="conflicting files">
569 for _, path := range v.Paths {
570 <li><code>{ path }</code></li>
571 }
572 </ul>
573 }
574 if v.PathsNote != "" {
575 <p class="dim">{ v.PathsNote }</p>
576 }
577}
578
579// mrMergeabilitySlot is the status region of the badge, with its content: the
580// stream replaces the children, never the element (see LiveSlot).
581templ mrMergeabilitySlot(v MergeabilityView) {
582 <div id="mr-mergeability" class="live-slot" role="status" data-slot>
583 @MRMergeability(v)
584 </div>
585}
586
587// MRMergeabilityFragment is the slot as the stream sends it.
588templ MRMergeabilityFragment(v MergeabilityView) {
589 @mrMergeabilitySlot(v)
590}
591
592// moreMenu is what else the owner does with an open request: mark the head
593// read, close it, merge it from a terminal.
594templ moreMenu(p MRPage) {
595 <details class="menu omenu">
596 <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>
597 <div class="menu-pop" role="group" aria-label="More actions">
598 if p.Box.CanMarkRead {
599 <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
600 <input type="hidden" name="head_id" value={ p.Box.Head }/>
601 <button type="submit" title="the next visit can show only what changed after this head" class="btn" aria-keyshortcuts="Shift+R" data-read>Mark read <kbd aria-hidden="true">R</kbd></button>
602 </form>
603 }
604 <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
605 <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
606 </form>
607 if !p.Box.HeadGone && p.Box.ReadyAction == "" {
608 <details class="d-or">
609 <summary class="mute">or from a terminal</summary>
610 @CloneBlock(p.Box.CLI)
611 </details>
612 }
613 </div>
614 </details>
615}
616
617templ Compare(l Layout, p ComparePage) {
618 @Shell(l, compareRail(p)) {
619 <header class="show mr-h">
620 <h1>{ p.Source } → { p.Target }</h1>
621 <dl class="kv show-kv">
622 <dt>branch</dt>
623 <dd><a href={ templ.SafeURL(p.SourceHref) }>{ p.Source }</a></dd>
624 <dt>target</dt>
625 <dd><a href={ templ.SafeURL(p.TargetHref) }>{ p.Target }</a></dd>
626 <dt>ahead</dt>
627 <dd>{ p.AheadText }</dd>
628 </dl>
629 </header>
630 if p.Note != "" {
631 <p class="notice">{ p.Note }</p>
632 }
633 if p.Existing != nil {
634 <p class="notice">an open merge request already covers these branches: <a href={ templ.SafeURL(p.Existing.Href) }>{ p.Existing.Label }</a></p>
635 }
636 if p.Create != nil {
637 <form class="cform create" method="post" action={ templ.SafeURL(p.Create.Action) }>
638 <input type="hidden" name="source" value={ p.Create.Source }/>
639 <input type="hidden" name="target" value={ p.Create.Target }/>
640 <label for="mr-title">title</label>
641 <input id="mr-title" name="title" type="text" maxlength="120" required value={ p.Create.Title } autocomplete="off"/>
642 <label for="mr-desc">description</label>
643 <textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="Markdown, optional"></textarea>
644 <div class="f-btns">
645 <button type="submit" class="btn primary">Create merge request</button>
646 </div>
647 </form>
648 }
649 if p.HasDiff {
650 <div class="diff-tools" role="group" aria-label="Diff view">
651 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
652 <span class="grow"></span>
653 @diffViewMenu(p.DiffView)
654 </div>
655 if p.Invisible {
656 <p class="notice">
657 this diff holds invisible characters (direction controls, zero-width characters). They are shown as
658 <span class="inv"><U+XXXX></span> markers, never applied.
659 </p>
660 }
661 <div
662 id="diffs"
663 if p.Both {
664 data-diff={ p.View }
665 } else {
666 data-view={ p.View }
667 }
668 if p.Both && !p.Pinned {
669 hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
670 }
671 >
672 for _, f := range p.Files {
673 @DiffFileView(f, p.DiffView)
674 }
675 </div>
676 }
677 }
678}
679
680templ compareRail(p ComparePage) {
681 <section aria-labelledby="r-ahead">
682 <h2 id="r-ahead">Commits <span class="count">{ itoa(len(p.Ahead)) }</span></h2>
683 <ul class="pcommits">
684 for _, c := range p.Ahead {
685 <li>
686 <a href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
687 <span class="s">{ c.Subject }</span>
688 <span class="mute">
689 { c.Author }
690 if c.WhenAt != "" {
691 { " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
692 }
693 </span>
694 </li>
695 }
696 </ul>
697 if len(p.Ahead) == 0 {
698 <p class="dim">no commit ahead of { p.Target }.</p>
699 }
700 </section>
701 if p.HasDiff {
702 <section aria-labelledby="r-files">
703 <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
704 @FileList(p.Files)
705 </section>
706 }
707 <section aria-labelledby="r-cli">
708 <h2 id="r-cli">From a terminal</h2>
709 @CloneBlock(p.CLI)
710 </section>
711}