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 <div id="mr-notice"></div>
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
20 placeholder="Filter by title, branch, author or state…"
21 autocomplete="off"
22 hx-on:input="let t = this.value.toLowerCase(); q('.mrs tbody tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
23 let first = q('.mrs tbody tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.mrs tbody tr')"
24 />
25 </div>
26 if p.Newest == "" {
27 <h2 class="rule-h"><span>Open</span><span class="count">{ itoa(len(p.Open)) }</span></h2>
28 @mrRows(p.Open, "Open merge requests")
29 if len(p.Open) == 0 {
30 <p class="lede dim">no open merge requests.</p>
31 }
32 if p.OpenNote != "" {
33 <p class="note">{ p.OpenNote }</p>
34 }
35 }
36 <h2 class="rule-h"><span>Merged and closed</span><span class="count">{ itoa(len(p.Done)) }</span></h2>
37 @mrRows(p.Done, "Merged and closed merge requests")
38 if len(p.Done) == 0 {
39 <p class="lede dim">nothing merged or closed yet.</p>
40 }
41 if p.Older != "" || p.Newest != "" {
42 <nav class="pager" aria-label="Pages">
43 if p.Newest != "" {
44 <a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a>
45 } else {
46 <span class="btn" aria-disabled="true">← Newest</span>
47 }
48 if p.Older != "" {
49 <a class="btn" href={ templ.SafeURL(p.Older) } rel="next">Older →</a>
50 } else {
51 <span class="btn" aria-disabled="true">Older →</span>
52 }
53 </nav>
54 }
55 }
56}
57
58templ mrRows(rows []MRRow, label string) {
59 if len(rows) > 0 {
60 <table class="list mrs" aria-label={ label }>
61 <tbody data-rows>
62 for _, m := range rows {
63 <tr data-cursor?={ m.Cursor } data-state={ m.State }>
64 <td class="cur" aria-hidden="true"></td>
65 <td class="c-n mute">{ m.Number }</td>
66 <td class="c-state">{ m.State }</td>
67 <td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <span class="mute">{ m.Branches }</span></td>
68 <td class="c-who dim">{ m.Author }</td>
69 <td class="c-when nowrap dim">
70 if m.WhenAt != "" {
71 <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
72 }
73 </td>
74 </tr>
75 }
76 </tbody>
77 </table>
78 }
79}
80
81templ mrListRail(p MRListPage) {
82 <section aria-labelledby="r-states">
83 <h2 id="r-states">States</h2>
84 <dl class="kv">
85 <dt>open</dt>
86 <dd>awaits review</dd>
87 <dt>merged</dt>
88 <dd>the target took the branch</dd>
89 <dt>closed</dt>
90 <dd>refused, branch kept</dd>
91 </dl>
92 </section>
93 <section aria-labelledby="r-new">
94 <h2 id="r-new">Open one</h2>
95 if len(p.Branches) > 0 {
96 <p class="dim">compare a branch with { p.Default }:</p>
97 <nav class="outline" aria-label="Branches">
98 for _, b := range p.Branches {
99 <a href={ templ.SafeURL(b.Href) }>{ b.Name }</a>
100 }
101 </nav>
102 } else {
103 <p class="dim">there is no other branch to merge yet.</p>
104 }
105 @CloneBlock(p.CLI)
106 </section>
107}
108
109// MergeRequest is a merge request: its facts, its description, the diff with
110// the notes under their lines, the general comments; and in the rail the merge
111// form, the checks, the settings, the commits and the files.
112templ MergeRequest(l Layout, p MRPage) {
113 @Shell(l, mrRail(p)) {
114 <header class="show mr-h">
115 <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
116 <dl class="kv show-kv">
117 <dt>state</dt>
118 <dd class="mr-state" data-state={ p.State }>
119 { p.State }
120 if p.MergedShort != "" {
121 { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
122 }
123 </dd>
124 <dt>branch</dt>
125 <dd>
126 @branchLink(p.Source, p.SourceHref)
127 { " → " }
128 @branchLink(p.Target, p.TargetHref)
129 </dd>
130 <dt>opened</dt>
131 <dd>
132 { p.Opened.Name }
133 if p.Opened.WhenAt != "" {
134 { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
135 }
136 </dd>
137 if p.UpdatedAt != "" {
138 <dt>updated</dt>
139 <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
140 }
141 </dl>
142 </header>
143 <div id="mr-notice"></div>
144 if len(p.Desc) > 0 {
145 <p class="mr-desc">@userText(p.Desc)</p>
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 <div class="diff-tools" role="toolbar" aria-label="Diff view">
167 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
168 <span class="grow"></span>
169 @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
170 @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
171 @collapseButton()
172 </div>
173 if p.Invisible {
174 <p class="notice">
175 this diff holds invisible characters (direction controls, zero-width characters). They are shown as
176 <span class="inv"><U+XXXX></span> markers, never applied.
177 </p>
178 }
179 if p.Note != "" {
180 <p class="notice">{ p.Note }</p>
181 }
182 <div
183 id="diffs"
184 if p.Both {
185 data-diff={ p.View }
186 } else {
187 data-view={ p.View }
188 }
189 if p.Both && !p.Pinned {
190 hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
191 }
192 >
193 for _, f := range p.Files {
194 @DiffFileView(f, p.DiffView)
195 }
196 if len(p.Files) == 0 {
197 <p class="note">no changed files.</p>
198 }
199 </div>
200 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
201 for _, c := range p.Loose {
202 @commentView(c, "")
203 }
204 for _, c := range p.General {
205 @commentView(c, "")
206 }
207 if p.NComments == 0 {
208 <p class="dim">no comments yet.</p>
209 }
210 if p.GeneralForm != nil {
211 @commentForm(*p.GeneralForm, "")
212 }
213 }
214}
215
216templ branchLink(name, href string) {
217 if href != "" {
218 <a href={ templ.SafeURL(href) }>{ name }</a>
219 } else {
220 { name }
221 }
222}
223
224// lineNotes are the comments under a line of the diff, and the form to write
225// one when the reader asked for it. The diff is in the page twice (unified and
226// split); the suffix keeps the ids unique, the unified table having none.
227templ lineNotes(notes []CommentView, form *CommentForm, suffix string) {
228 for _, c := range notes {
229 @commentView(c, suffix)
230 }
231 if form != nil {
232 @commentForm(*form, suffix)
233 }
234}
235
236// commentView is a note: a ruled block, under its line in the diff or in the
237// list of comments. The text is plain: escaped, its line breaks kept, its
238// http(s) addresses linked.
239templ commentView(c CommentView, suffix string) {
240 <div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) }>
241 <p class="c-head">
242 <span class="c-who">{ c.Author }</span>
243 if c.WhenAt != "" {
244 { " " }<time class="mute" datetime={ c.WhenAt } title={ c.Abs }>{ c.When }</time>
245 }
246 if c.Resolved {
247 { " " }<span class="c-state">resolved</span>
248 }
249 if c.Outdated {
250 { " " }<span class="c-state" title="written on an earlier head; the line may have moved">outdated</span>
251 }
252 if c.Where != "" {
253 { " " }<span class="c-where mute">{ c.Where }</span>
254 }
255 </p>
256 <p class="c-text">@userText(c.Parts)</p>
257 if c.Resolve != nil {
258 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
259 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
260 <button type="submit" class="btn">{ c.Resolve.Label }</button>
261 </form>
262 }
263 </div>
264}
265
266func boolText(b bool) string {
267 if b {
268 return "true"
269 }
270 return "false"
271}
272
273// commentForm writes a comment, general or on a line.
274templ commentForm(f CommentForm, suffix string) {
275 <form class="cform" method="post" action={ templ.SafeURL(f.Action) }>
276 <label for={ f.ID + suffix }>{ f.Title }</label>
277 <textarea
278 id={ f.ID + suffix }
279 name="text"
280 rows="3"
281 maxlength="8000"
282 required
283 placeholder="plain text, 8000 characters at most"
284 if f.Focus {
285 autofocus
286 }
287 ></textarea>
288 if f.Path != "" {
289 <input type="hidden" name="path" value={ f.Path }/>
290 <input type="hidden" name="line" value={ f.Line }/>
291 <input type="hidden" name="side" value={ f.Side }/>
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 if p.Box != nil {
304 @mergeBox(p)
305 }
306 <section aria-labelledby="r-checks">
307 <h2 id="r-checks">Checks <span class="count">0</span></h2>
308 <p class="dim">no checks</p>
309 <p class="mute">there is no CI yet: nothing blocks the merge.</p>
310 </section>
311 <section aria-labelledby="r-settings">
312 <h2 id="r-settings">Settings</h2>
313 <dl class="kv">
314 <dt>modes</dt>
315 <dd>{ strings.Join(p.Modes, ", ") }</dd>
316 <dt>checks</dt>
317 <dd>
318 if p.ChecksBlock {
319 failing ones block the merge
320 } else {
321 do not block the merge
322 }
323 </dd>
324 </dl>
325 </section>
326 <section aria-labelledby="r-commits">
327 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
328 <ul class="pcommits">
329 for _, c := range p.Commits {
330 <li data-new?={ c.New }>
331 <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a>
332 <span class="s">{ c.Subject }</span>
333 <span class="mute">
334 { c.Author }
335 if c.WhenAt != "" {
336 { " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
337 }
338 if c.New {
339 { " · new" }
340 }
341 </span>
342 </li>
343 }
344 </ul>
345 if len(p.Commits) == 0 {
346 <p class="dim">no commit to merge.</p>
347 }
348 if p.CommitsNote != "" {
349 <p class="note">{ p.CommitsNote }</p>
350 }
351 </section>
352 <section aria-labelledby="r-files">
353 <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
354 @FileList(p.Files)
355 </section>
356}
357
358// mergeBox is the first block of the rail of an open request, for the owner:
359// the merge form (it carries the head that was read), the terminal lines, and
360// the other things one does with the request.
361templ mergeBox(p MRPage) {
362 <section class="decide" aria-labelledby="r-merge" data-state="open">
363 <h2 id="r-merge">Merge</h2>
364 <dl class="kv">
365 <dt>head</dt>
366 <dd>
367 if p.Box.HeadGone {
368 gone: the source branch was deleted
369 } else {
370 { p.HeadShort }
371 }
372 </dd>
373 <dt>into</dt>
374 <dd>{ p.Target }</dd>
375 </dl>
376 if !p.Box.HeadGone {
377 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
378 <input type="hidden" name="head_id" value={ p.Box.Head }/>
379 <label class="sr" for="merge-mode">merge mode</label>
380 <select id="merge-mode" name="mode">
381 for _, m := range p.Box.Modes {
382 <option value={ m.Value }>{ m.Label }</option>
383 }
384 </select>
385 if p.Box.CanDeleteBranch {
386 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge</label>
387 }
388 <div class="d-btns">
389 <button type="submit" class="btn primary">Merge</button>
390 </div>
391 </form>
392 }
393 <div class="d-btns">
394 if p.Box.CanMarkRead {
395 <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
396 <input type="hidden" name="head_id" value={ p.Box.Head }/>
397 <button type="submit" title="the next visit can show only what changed after this head" class="btn">Mark read</button>
398 </form>
399 }
400 <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
401 <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
402 </form>
403 </div>
404 if !p.Box.HeadGone {
405 <p class="mute d-or">or from a terminal</p>
406 @CloneBlock(p.Box.CLI)
407 }
408 </section>
409}
410
411// Compare is a branch against the default branch: the commits it is ahead by,
412// its diff when the API can give one, and the form that opens a merge request.
413templ Compare(l Layout, p ComparePage) {
414 @Shell(l, compareRail(p)) {
415 <header class="show mr-h">
416 <h1>{ p.Source } → { p.Target }</h1>
417 <dl class="kv show-kv">
418 <dt>branch</dt>
419 <dd><a href={ templ.SafeURL(p.SourceHref) }>{ p.Source }</a></dd>
420 <dt>target</dt>
421 <dd><a href={ templ.SafeURL(p.TargetHref) }>{ p.Target }</a></dd>
422 <dt>ahead</dt>
423 <dd>{ p.AheadText }</dd>
424 </dl>
425 </header>
426 if p.Note != "" {
427 <p class="notice">{ p.Note }</p>
428 }
429 if p.Existing != nil {
430 <p class="notice">an open merge request already covers these branches: <a href={ templ.SafeURL(p.Existing.Href) }>{ p.Existing.Label }</a></p>
431 }
432 if p.Create != nil {
433 <form class="cform create" method="post" action={ templ.SafeURL(p.Create.Action) }>
434 <input type="hidden" name="source" value={ p.Create.Source }/>
435 <input type="hidden" name="target" value={ p.Create.Target }/>
436 <label for="mr-title">title</label>
437 <input id="mr-title" name="title" type="text" maxlength="120" required value={ p.Create.Title } autocomplete="off"/>
438 <label for="mr-desc">description</label>
439 <textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="plain text, optional"></textarea>
440 <div class="f-btns">
441 <button type="submit" class="btn primary">Create merge request</button>
442 </div>
443 </form>
444 }
445 if p.HasDiff {
446 <div class="diff-tools" role="toolbar" aria-label="Diff view">
447 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
448 <span class="grow"></span>
449 @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
450 @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
451 @collapseButton()
452 </div>
453 if p.Invisible {
454 <p class="notice">
455 this diff holds invisible characters (direction controls, zero-width characters). They are shown as
456 <span class="inv"><U+XXXX></span> markers, never applied.
457 </p>
458 }
459 <div
460 id="diffs"
461 if p.Both {
462 data-diff={ p.View }
463 } else {
464 data-view={ p.View }
465 }
466 if p.Both && !p.Pinned {
467 hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
468 }
469 >
470 for _, f := range p.Files {
471 @DiffFileView(f, p.DiffView)
472 }
473 </div>
474 }
475 }
476}
477
478templ compareRail(p ComparePage) {
479 <section aria-labelledby="r-ahead">
480 <h2 id="r-ahead">Commits <span class="count">{ itoa(len(p.Ahead)) }</span></h2>
481 <ul class="pcommits">
482 for _, c := range p.Ahead {
483 <li>
484 <a href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
485 <span class="s">{ c.Subject }</span>
486 <span class="mute">
487 { c.Author }
488 if c.WhenAt != "" {
489 { " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
490 }
491 </span>
492 </li>
493 }
494 </ul>
495 if len(p.Ahead) == 0 {
496 <p class="dim">no commit ahead of { p.Target }.</p>
497 }
498 </section>
499 if p.HasDiff {
500 <section aria-labelledby="r-files">
501 <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
502 @FileList(p.Files)
503 </section>
504 }
505 <section aria-labelledby="r-cli">
506 <h2 id="r-cli">From a terminal</h2>
507 @CloneBlock(p.CLI)
508 </section>
509}