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