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="cur" aria-hidden="true"></td>
64 <td class="c-n mute">{ m.Number }</td>
65 <td class="c-state">{ m.State }</td>
66 <td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <span class="mute">{ m.Branches }</span></td>
67 <td class="c-who dim">{ m.Author }</td>
68 <td class="c-when nowrap dim">
69 if m.WhenAt != "" {
70 <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
71 }
72 </td>
73 </tr>
74 }
75 </tbody>
76 </table>
77 }
78}
79
80templ mrListRail(p MRListPage) {
81 <section aria-labelledby="r-states">
82 <h2 id="r-states">States</h2>
83 <dl class="kv">
84 <dt>open</dt>
85 <dd>awaits review</dd>
86 <dt>merged</dt>
87 <dd>the target took the branch</dd>
88 <dt>closed</dt>
89 <dd>refused, branch kept</dd>
90 </dl>
91 </section>
92 <section aria-labelledby="r-new">
93 <h2 id="r-new">Open one</h2>
94 if len(p.Branches) > 0 {
95 <p class="dim">compare a branch with { p.Default }:</p>
96 <nav class="outline" aria-label="Branches">
97 for _, b := range p.Branches {
98 <a href={ templ.SafeURL(b.Href) }>{ b.Name }</a>
99 }
100 </nav>
101 } else {
102 <p class="dim">there is no other branch to merge yet.</p>
103 }
104 @CloneBlock(p.CLI)
105 </section>
106}
107
108// MergeRequest is a merge request: its facts, its description, the diff with
109// the notes under their lines, the general comments; and in the rail the merge
110// form, the checks, the settings, the commits and the files.
111templ MergeRequest(l Layout, p MRPage) {
112 @Shell(l, mrRail(p)) {
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 @LiveSlot("mr-notice")
143 if len(p.Desc) > 0 {
144 <p class="mr-desc">@userText(p.Desc)</p>
145 }
146 if p.Since != nil {
147 <nav class="since" aria-label="Diff range">
148 <a
149 href={ templ.SafeURL(p.Since.AllHref) }
150 if !p.Since.Current {
151 aria-current="page"
152 }
153 >all changes <span class="mute">vs { p.Target }</span></a>
154 <a
155 href={ templ.SafeURL(p.Since.SinceHref) }
156 if p.Since.Current {
157 aria-current="page"
158 }
159 >since your last read <span class="mute">{ p.Since.ReadShort } → { p.Since.HeadShort }</span></a>
160 </nav>
161 }
162 if p.SinceNote != "" {
163 <p class="notice">{ p.SinceNote }</p>
164 }
165 @mrBar(p)
166 if p.Invisible {
167 <p class="notice">
168 this diff holds invisible characters (direction controls, zero-width characters). They are shown as
169 <span class="inv"><U+XXXX></span> markers, never applied.
170 </p>
171 }
172 if p.Note != "" {
173 <p class="notice">{ p.Note }</p>
174 }
175 <div
176 id="diffs"
177 data-mr
178 if p.Both {
179 data-diff={ p.View }
180 } else {
181 data-view={ p.View }
182 }
183 if p.Both && !p.Pinned {
184 hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
185 }
186 >
187 for _, f := range p.Files {
188 @DiffFileView(f, p.DiffView)
189 }
190 if len(p.Files) == 0 {
191 <p class="note">no changed files.</p>
192 }
193 </div>
194 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
195 for _, c := range p.Loose {
196 @commentView(c, "")
197 }
198 for _, c := range p.General {
199 @commentView(c, "")
200 }
201 if p.NComments == 0 {
202 <p class="dim">no comments yet.</p>
203 }
204 if p.GeneralForm != nil {
205 @commentForm(*p.GeneralForm, "")
206 }
207 }
208}
209
210templ branchLink(name, href string) {
211 if href != "" {
212 <a href={ templ.SafeURL(href) }>{ name }</a>
213 } else {
214 { name }
215 }
216}
217
218// lineNotes are the comments under a line of the diff, and the form to write
219// one when the reader asked for it. The diff is in the page twice (unified and
220// split); the suffix keeps the ids unique, the unified table having none.
221templ lineNotes(notes []CommentView, form *CommentForm, suffix string) {
222 for _, c := range notes {
223 @commentView(c, suffix)
224 }
225 if form != nil {
226 @commentForm(*form, suffix)
227 }
228}
229
230// commentView is a note: a ruled block, under its line in the diff or in the
231// list of comments. The text is plain: escaped, its line breaks kept, its
232// http(s) addresses linked.
233templ commentView(c CommentView, suffix string) {
234 <div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) }>
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 <p class="c-text">@userText(c.Parts)</p>
251 if c.Resolve != nil {
252 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
253 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
254 if c.Resolve.From > 0 {
255 <input type="hidden" name="from" value={ itoa(c.Resolve.From) }/>
256 }
257 <button type="submit" class="btn">{ c.Resolve.Label }</button>
258 </form>
259 }
260 </div>
261}
262
263func boolText(b bool) string {
264 if b {
265 return "true"
266 }
267 return "false"
268}
269
270// commentForm writes a comment, general or on a line.
271templ commentForm(f CommentForm, suffix string) {
272 <form class="cform" method="post" action={ templ.SafeURL(f.Action) }>
273 <label for={ f.ID + suffix }>{ f.Title }</label>
274 <textarea
275 id={ f.ID + suffix }
276 name="text"
277 rows="3"
278 maxlength="8000"
279 required
280 placeholder="plain text, 8000 characters at most"
281 if f.Focus {
282 autofocus
283 }
284 ></textarea>
285 if f.Path != "" {
286 <input type="hidden" name="path" value={ f.Path }/>
287 <input type="hidden" name="line" value={ f.Line }/>
288 <input type="hidden" name="side" value={ f.Side }/>
289 }
290 if f.From > 0 {
291 <input type="hidden" name="from" value={ itoa(f.From) }/>
292 }
293 <div class="f-btns">
294 <button type="submit" class="btn primary">Comment</button>
295 if f.Cancel != "" {
296 <a class="btn" href={ templ.SafeURL(f.Cancel) }>Cancel</a>
297 }
298 </div>
299 </form>
300}
301
302templ mrRail(p MRPage) {
303 <section aria-labelledby="r-files" class="r-files">
304 <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd></h2>
305 @FileList(p.Files)
306 </section>
307 @MRChecks(p.Checks)
308 <section aria-labelledby="r-commits">
309 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
310 <ul class="pcommits">
311 for _, c := range p.Commits {
312 <li data-new?={ c.New }>
313 <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a>
314 <span class="s">{ c.Subject }</span>
315 <span class="mute">
316 { c.Author }
317 if c.WhenAt != "" {
318 { " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
319 }
320 if c.New {
321 { " · new" }
322 }
323 </span>
324 </li>
325 }
326 </ul>
327 if len(p.Commits) == 0 {
328 <p class="dim">no commit to merge.</p>
329 }
330 if p.CommitsNote != "" {
331 <p class="note">{ p.CommitsNote }</p>
332 }
333 </section>
334 <section aria-labelledby="r-settings">
335 <h2 id="r-settings">Settings</h2>
336 <dl class="kv">
337 <dt>modes</dt>
338 <dd>{ strings.Join(p.Modes, ", ") }</dd>
339 <dt>checks</dt>
340 <dd>
341 if p.ChecksBlock {
342 failing ones block the merge
343 } else {
344 do not block the merge
345 }
346 </dd>
347 </dl>
348 </section>
349}
350
351// mrBar is the bar above the diff, which follows the review: the totals, the
352// layout of the diff, the state of the checks and, for the owner of an open
353// request, the merge menu and the other actions. Each menu is a <details>: it
354// works without scripts; with them, M and the keys of the menu drive it.
355templ mrBar(p MRPage) {
356 <div class="diff-tools mr-bar" role="toolbar" aria-label="Diff view and merge">
357 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
358 @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
359 @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
360 @collapseButton()
361 <span class="grow"></span>
362 @MRChecksSum(p.ChecksSum)
363 if p.Box != nil {
364 @mergeMenu(p)
365 @moreMenu(p)
366 }
367 </div>
368}
369
370// mergeMenu is the merge of an open request, for the owner: the form (it
371// carries the head that was read), or "Ready for review" for a draft. The
372// mode and the branch deletion have keys (1-3, D) while it is open.
373templ mergeMenu(p MRPage) {
374 <details class="menu mmenu" id="merge-menu" data-state="open">
375 <summary class="btn primary" aria-keyshortcuts="Shift+M">
376 if p.Box.ReadyAction != "" {
377 Draft
378 } else {
379 Merge
380 }
381 <kbd aria-hidden="true">M</kbd>
382 </summary>
383 <div class="menu-pop" role="group" aria-label="Merge">
384 <dl class="kv">
385 <dt>head</dt>
386 <dd>
387 if p.Box.HeadGone {
388 gone: the source branch was deleted
389 } else {
390 { p.HeadShort }
391 }
392 </dd>
393 <dt>into</dt>
394 <dd>{ p.Target }</dd>
395 </dl>
396 if p.Box.ReadyAction != "" {
397 <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
398 <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
399 <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>
400 </form>
401 } else if !p.Box.HeadGone {
402 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
403 <input type="hidden" name="head_id" value={ p.Box.Head }/>
404 <fieldset class="d-modes">
405 <legend class="sr">merge mode</legend>
406 for i, m := range p.Box.Modes {
407 <label>
408 <input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/>
409 { m.Label }
410 if i < 9 {
411 <kbd aria-hidden="true">{ itoa(i + 1) }</kbd>
412 }
413 </label>
414 }
415 </fieldset>
416 if p.Box.CanDeleteBranch {
417 <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>
418 }
419 <div class="d-btns">
420 <button type="submit" class="btn primary" data-default>Merge into { p.Target }</button>
421 </div>
422 </form>
423 }
424 </div>
425 </details>
426}
427
428// moreMenu is what else the owner does with an open request: mark the head
429// read, close it, merge it from a terminal.
430templ moreMenu(p MRPage) {
431 <details class="menu omenu">
432 <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>
433 <div class="menu-pop" role="group" aria-label="More actions">
434 if p.Box.CanMarkRead {
435 <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
436 <input type="hidden" name="head_id" value={ p.Box.Head }/>
437 <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>
438 </form>
439 }
440 <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
441 <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
442 </form>
443 if !p.Box.HeadGone && p.Box.ReadyAction == "" {
444 <details class="d-or">
445 <summary class="mute">or from a terminal</summary>
446 @CloneBlock(p.Box.CLI)
447 </details>
448 }
449 </div>
450 </details>
451}
452
453templ Compare(l Layout, p ComparePage) {
454 @Shell(l, compareRail(p)) {
455 <header class="show mr-h">
456 <h1>{ p.Source } → { p.Target }</h1>
457 <dl class="kv show-kv">
458 <dt>branch</dt>
459 <dd><a href={ templ.SafeURL(p.SourceHref) }>{ p.Source }</a></dd>
460 <dt>target</dt>
461 <dd><a href={ templ.SafeURL(p.TargetHref) }>{ p.Target }</a></dd>
462 <dt>ahead</dt>
463 <dd>{ p.AheadText }</dd>
464 </dl>
465 </header>
466 if p.Note != "" {
467 <p class="notice">{ p.Note }</p>
468 }
469 if p.Existing != nil {
470 <p class="notice">an open merge request already covers these branches: <a href={ templ.SafeURL(p.Existing.Href) }>{ p.Existing.Label }</a></p>
471 }
472 if p.Create != nil {
473 <form class="cform create" method="post" action={ templ.SafeURL(p.Create.Action) }>
474 <input type="hidden" name="source" value={ p.Create.Source }/>
475 <input type="hidden" name="target" value={ p.Create.Target }/>
476 <label for="mr-title">title</label>
477 <input id="mr-title" name="title" type="text" maxlength="120" required value={ p.Create.Title } autocomplete="off"/>
478 <label for="mr-desc">description</label>
479 <textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="plain text, optional"></textarea>
480 <div class="f-btns">
481 <button type="submit" class="btn primary">Create merge request</button>
482 </div>
483 </form>
484 }
485 if p.HasDiff {
486 <div class="diff-tools" role="toolbar" aria-label="Diff view">
487 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
488 <span class="grow"></span>
489 @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
490 @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
491 @collapseButton()
492 </div>
493 if p.Invisible {
494 <p class="notice">
495 this diff holds invisible characters (direction controls, zero-width characters). They are shown as
496 <span class="inv"><U+XXXX></span> markers, never applied.
497 </p>
498 }
499 <div
500 id="diffs"
501 if p.Both {
502 data-diff={ p.View }
503 } else {
504 data-view={ p.View }
505 }
506 if p.Both && !p.Pinned {
507 hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
508 }
509 >
510 for _, f := range p.Files {
511 @DiffFileView(f, p.DiffView)
512 }
513 </div>
514 }
515 }
516}
517
518templ compareRail(p ComparePage) {
519 <section aria-labelledby="r-ahead">
520 <h2 id="r-ahead">Commits <span class="count">{ itoa(len(p.Ahead)) }</span></h2>
521 <ul class="pcommits">
522 for _, c := range p.Ahead {
523 <li>
524 <a href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
525 <span class="s">{ c.Subject }</span>
526 <span class="mute">
527 { c.Author }
528 if c.WhenAt != "" {
529 { " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
530 }
531 </span>
532 </li>
533 }
534 </ul>
535 if len(p.Ahead) == 0 {
536 <p class="dim">no commit ahead of { p.Target }.</p>
537 }
538 </section>
539 if p.HasDiff {
540 <section aria-labelledby="r-files">
541 <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
542 @FileList(p.Files)
543 </section>
544 }
545 <section aria-labelledby="r-cli">
546 <h2 id="r-cli">From a terminal</h2>
547 @CloneBlock(p.CLI)
548 </section>
549}