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