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