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