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 <span class="grow"></span>
471 @MRChecksSum(p.ChecksSum)
472 if p.Box != nil {
473 @mergeMenu(p)
474 }
475 @moreMenu(p)
476 </div>
477}
478
479// mergeMenu is the merge of an open request, for the owner: the form (it
480// carries the head that was read), or "Ready for review" for a draft. The
481// mode and the branch deletion have keys (1-3, D) while it is open.
482//
483// The badge is a live slot (mr-mergeability); the modes and the button read
484// its data-* attributes (hx-live bindings), so that they follow the slot when
485// the stream replaces its content. The server renders the same state in the
486// attributes, for a page without scripts.
487templ mergeMenu(p MRPage) {
488 <details class="menu mmenu" id="merge-menu" data-state="open" open?={ p.Box.Error != "" }>
489 <summary class="btn primary" aria-keyshortcuts="Shift+M">
490 if p.Box.ReadyAction != "" {
491 Draft
492 } else {
493 Merge
494 }
495 <kbd aria-hidden="true">M</kbd>
496 </summary>
497 <div class="menu-pop" role="group" aria-label="Merge">
498 if p.Box.HeadGone {
499 <p class="dim">the source branch was deleted</p>
500 }
501 if p.Box.Error != "" {
502 <p class="d-err" role="alert">{ p.Box.Error }</p>
503 }
504 if p.Box.ReadyAction != "" {
505 <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
506 <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
507 <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>
508 </form>
509 } else if !p.Box.HeadGone {
510 @mrMergeabilitySlot(p.Box.Mergeability)
511 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
512 <input type="hidden" name="head_id" value={ p.Box.Head }/>
513 <fieldset class="d-modes">
514 <legend class="sr">merge mode</legend>
515 for i, m := range p.Box.Modes {
516 <label>
517 <input
518 type="radio"
519 name="mode"
520 value={ m.Value }
521 checked?={ i == p.Box.Checked() }
522 disabled?={ m.Disabled }
523 title={ m.Reason }
524 :disabled="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true"
525 :title="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason"
526 />
527 { m.Label }
528 if i < 9 {
529 <kbd aria-hidden="true">{ itoa(i + 1) }</kbd>
530 }
531 </label>
532 }
533 </fieldset>
534 if p.Box.CanDeleteBranch {
535 <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>
536 }
537 <div class="d-btns">
538 <button
539 type="submit"
540 class="btn primary"
541 data-default
542 disabled?={ !p.Box.CanMerge() }
543 title={ p.Box.MergeTitle() }
544 :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
545 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
546 >Merge into { p.Target }</button>
547 </div>
548 </form>
549 }
550 </div>
551 </details>
552}
553
554// MRMergeability is the content of the mergeability slot: the badge, and the
555// state the controls of the merge box read.
556templ MRMergeability(v MergeabilityView) {
557 <span
558 data-mergeability
559 data-state={ v.State }
560 data-ff-ok={ strconv.FormatBool(v.FFOk) }
561 data-merge-ok={ strconv.FormatBool(v.MergeOk) }
562 data-ff-reason={ v.FFReason }
563 data-merge-reason={ v.MergeReason }
564 >
565 <span class="badge" data-state={ v.State }>{ v.Label }</span>
566 if v.Detail != "" {
567 { " " }<span class="dim">{ v.Detail }</span>
568 }
569 </span>
570 if len(v.Paths) > 0 {
571 <ul class="mg-paths" aria-label="conflicting files">
572 for _, path := range v.Paths {
573 <li><code>{ path }</code></li>
574 }
575 </ul>
576 }
577 if v.PathsNote != "" {
578 <p class="dim">{ v.PathsNote }</p>
579 }
580}
581
582// mrMergeabilitySlot is the status region of the badge, with its content: the
583// stream replaces the children, never the element (see LiveSlot).
584templ mrMergeabilitySlot(v MergeabilityView) {
585 <div id="mr-mergeability" class="live-slot" role="status" data-slot>
586 @MRMergeability(v)
587 </div>
588}
589
590// MRMergeabilityFragment is the slot as the stream sends it.
591templ MRMergeabilityFragment(v MergeabilityView) {
592 @mrMergeabilitySlot(v)
593}
594
595// moreMenu shows read-only settings to every viewer, and keeps owner actions
596// behind the same merge-box permission as before.
597templ moreMenu(p MRPage) {
598 <details class="menu omenu">
599 <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>
600 <div class="menu-pop" role="group" aria-label="More actions">
601 <section class="merge-settings">
602 <h2>Merge settings</h2>
603 <dl class="kv">
604 <dt>modes</dt>
605 <dd>{ strings.Join(p.Modes, ", ") }</dd>
606 <dt>checks</dt>
607 <dd>
608 if p.ChecksBlock {
609 failing ones block the merge
610 } else {
611 do not block the merge
612 }
613 </dd>
614 </dl>
615 </section>
616 if p.Box != nil {
617 if p.Box.Signed {
618 <p class="d-note" id="mr-signing">
619 squash and merge commits are signed with ermite's own key (generated on first start).
620 if p.Box.Fingerprint != "" {
621 fingerprint <code>{ p.Box.Fingerprint }</code>
622 }
623 <code>ermite signing-key show</code> prints it.
624 </p>
625 }
626 if p.Box.CanMarkRead {
627 <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
628 <input type="hidden" name="head_id" value={ p.Box.Head }/>
629 <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>
630 </form>
631 }
632 <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
633 <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
634 </form>
635 if !p.Box.HeadGone && p.Box.ReadyAction == "" {
636 <details class="d-or">
637 <summary class="mute">or from a terminal</summary>
638 @CloneBlock(p.Box.CLI)
639 </details>
640 }
641 }
642 </div>
643 </details>
644}
645
646templ Compare(l Layout, p ComparePage) {
647 @Shell(l, compareRail(p)) {
648 <header class="show mr-h">
649 <h1>{ p.Source } → { p.Target }</h1>
650 <dl class="kv show-kv">
651 <dt>branch</dt>
652 <dd><a href={ templ.SafeURL(p.SourceHref) }>{ p.Source }</a></dd>
653 <dt>target</dt>
654 <dd><a href={ templ.SafeURL(p.TargetHref) }>{ p.Target }</a></dd>
655 <dt>ahead</dt>
656 <dd>{ p.AheadText }</dd>
657 </dl>
658 </header>
659 if p.Note != "" {
660 <p class="notice">{ p.Note }</p>
661 }
662 if p.Existing != nil {
663 <p class="notice">an open merge request already covers these branches: <a href={ templ.SafeURL(p.Existing.Href) }>{ p.Existing.Label }</a></p>
664 }
665 if p.Create != nil {
666 <form class="cform create" method="post" action={ templ.SafeURL(p.Create.Action) }>
667 <input type="hidden" name="source" value={ p.Create.Source }/>
668 <input type="hidden" name="target" value={ p.Create.Target }/>
669 <label for="mr-title">title</label>
670 <input id="mr-title" name="title" type="text" maxlength="120" required value={ p.Create.Title } autocomplete="off"/>
671 <label for="mr-desc">description</label>
672 <textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="Markdown, optional"></textarea>
673 <div class="f-btns">
674 <button type="submit" class="btn primary">Create merge request</button>
675 </div>
676 </form>
677 }
678 if p.HasDiff {
679 <div class="diff-tools" role="group" aria-label="Diff view">
680 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
681 <span class="grow"></span>
682 @diffViewMenu(p.DiffView)
683 </div>
684 if p.Invisible {
685 <p class="notice">
686 this diff holds invisible characters (direction controls, zero-width characters). They are shown as
687 <span class="inv"><U+XXXX></span> markers, never applied.
688 </p>
689 }
690 <div
691 id="diffs"
692 if p.Both {
693 data-diff={ p.View }
694 } else {
695 data-view={ p.View }
696 }
697 if p.Both && !p.Pinned {
698 hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
699 }
700 >
701 for _, f := range p.Files {
702 @DiffFileView(f, p.DiffView)
703 }
704 </div>
705 }
706 }
707}
708
709templ compareRail(p ComparePage) {
710 <section aria-labelledby="r-ahead">
711 <h2 id="r-ahead">Commits <span class="count">{ itoa(len(p.Ahead)) }</span></h2>
712 <ul class="pcommits">
713 for _, c := range p.Ahead {
714 <li>
715 <a href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
716 <span class="s">{ c.Subject }</span>
717 <span class="mute">
718 { c.Author }
719 if c.WhenAt != "" {
720 { " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
721 }
722 </span>
723 </li>
724 }
725 </ul>
726 if len(p.Ahead) == 0 {
727 <p class="dim">no commit ahead of { p.Target }.</p>
728 }
729 </section>
730 if p.HasDiff {
731 <section aria-labelledby="r-files">
732 <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
733 @FileList(p.Files)
734 </section>
735 }
736 <section aria-labelledby="r-cli">
737 <h2 id="r-cli">From a terminal</h2>
738 @CloneBlock(p.CLI)
739 </section>
740}