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