v0.33.0 cf26365

mr.templ RawHistoryFolder

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					@mrState(p)
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			@mrFooter(p)
148		</section>
149		if p.Deleting != "" {
150			<p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>
151		}
152		if p.MergeError != "" {
153			<p class="d-err" role="alert">{ p.MergeError }</p>
154		}
155		@LiveSlot("mr-notice")
156		<div id="mr-refresh-status" role="status" aria-live="polite"></div>
157		<div id="mr-range" hidden?={ p.Since == nil && p.SinceNote == "" }>
158			if p.Since != nil {
159				<nav class="since" aria-label="Diff range">
160					<a
161						href={ templ.SafeURL(p.Since.AllHref) }
162						if !p.Since.Current {
163							aria-current="page"
164						}
165					>all changes <span class="mute">vs { p.Target }</span></a>
166					<a
167						href={ templ.SafeURL(p.Since.SinceHref) }
168						if p.Since.Current {
169							aria-current="page"
170						}
171					>since your last read <span class="mute">{ p.Since.ReadShort } → { p.Since.HeadShort }</span></a>
172				</nav>
173			}
174			if p.SinceNote != "" {
175				<p class="notice">{ p.SinceNote }</p>
176			}
177		</div>
178		@mrBar(p)
179		<div id="mr-diff-notices" hidden?={ !p.Invisible && p.Note == "" }>
180			if p.Invisible {
181				<p class="notice">
182					this diff holds invisible characters (direction controls, zero-width characters). They are shown as
183					<span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
184				</p>
185			}
186			if p.Note != "" {
187				<p class="notice">{ p.Note }</p>
188			}
189		</div>
190		<div
191			id="diffs"
192			data-mr
193			if p.Canonical != "" {
194				data-canonical={ p.Canonical }
195			}
196			if p.Both {
197				data-diff={ p.View }
198			} else {
199				data-view={ p.View }
200			}
201			if p.Both && !p.Pinned {
202				hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
203			}
204		>
205			for _, f := range p.Files {
206				@DiffFileView(f, p.DiffView)
207			}
208			if len(p.Files) == 0 {
209				<p class="note">no changed files.</p>
210			}
211		</div>
212		<div id="mr-comments">
213			<h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
214			for _, c := range p.Loose {
215				@commentView(c, "")
216			}
217			for _, c := range p.General {
218				@commentView(c, "")
219			}
220			if p.NComments == 0 {
221				<p class="dim">no comments yet.</p>
222			}
223			if p.GeneralForm != nil {
224				@commentForm(*p.GeneralForm, "")
225			}
226		</div>
227	}
228}
229
230templ branchLink(name, href string) {
231	if href != "" {
232		<a href={ templ.SafeURL(href) }>{ name }</a>
233	} else {
234		{ name }
235	}
236}
237
238// lineNotes are the comments under a line of the diff, and the form to write
239// one when the reader asked for it. The diff is in the page twice (unified and
240// split); the suffix keeps the ids unique, the unified table having none.
241templ lineNotes(notes []CommentView, form *CommentForm, suffix string) {
242	for _, c := range notes {
243		@commentView(c, suffix)
244	}
245	if form != nil {
246		@commentForm(*form, suffix)
247	}
248}
249
250// commentView is a note: a ruled block, under its line in the diff or in the
251// list of comments. Body comes only from the safe Markdown renderer.
252templ commentView(c CommentView, suffix string) {
253	<div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) } data-thread?={ c.Reply != nil }>
254		<p class="c-head">
255			<span class="c-who">{ c.Author }</span>
256			if c.WhenAt != "" {
257				{ " " }<time class="mute" datetime={ c.WhenAt } title={ c.Abs }>{ c.When }</time>
258			}
259			if c.Resolved {
260				{ " " }<span class="c-state">resolved</span>
261			}
262			if c.Outdated {
263				{ " " }<span class="c-state" title="written on an earlier head; the line may have moved">outdated</span>
264			}
265			if c.Where != "" {
266				{ " " }<span class="c-where mute">{ c.Where }</span>
267			}
268		</p>
269		if len(c.Parts) > 0 {
270			<p class="c-text">@userText(c.Parts)</p>
271		} else if c.Deleted {
272			if c.Verdict != "" {
273				<p class="mute">text deleted</p>
274			} else {
275				<p class="mute">deleted note</p>
276			}
277		} else {
278			<div class="c-body prose">
279				if suffix == "-s" {
280					@templ.Raw(string(c.BodySplit))
281				} else {
282					@templ.Raw(string(c.Body))
283				}
284			</div>
285		}
286		if c.Edited {
287			<span class="mute">edited</span>
288		}
289		if c.Verdict != "" {
290			<span class="c-state">{ c.Verdict }</span>
291		}
292		if c.Range != "" {
293			<span class="mute">{ c.Range }</span>
294		}
295		if c.Quote != "" {
296			<button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
297		}
298		if c.Edit != nil {
299			<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>
300		}
301		if c.Delete != nil {
302			<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>
303		}
304		for _, reply := range c.Replies {
305			@commentView(reply, suffix)
306		}
307		if c.Reply != nil {
308			<details data-reply>
309				<summary>Reply</summary>@commentForm(*c.Reply, suffix)
310			</details>
311		}
312		if c.Resolve != nil {
313			<form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
314				<input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
315				if c.Resolve.From > 0 {
316					<input type="hidden" name="from" value={ itoa(c.Resolve.From) }/>
317				}
318				<button type="submit" class="btn">{ c.Resolve.Label }</button>
319			</form>
320		}
321	</div>
322}
323
324func boolText(b bool) string {
325	if b {
326		return "true"
327	}
328	return "false"
329}
330
331// commentForm writes a comment, general or on a line.
332templ commentForm(f CommentForm, suffix string) {
333	<form class="cform" method="post" action={ templ.SafeURL(f.Action) }>
334		<label for={ f.ID + suffix }>{ f.Title }</label>
335		<textarea
336			id={ f.ID + suffix }
337			name="text"
338			rows="3"
339			maxlength="8000"
340			if f.Plain {
341				required
342				placeholder="plain text, 8000 characters at most"
343			} else {
344				placeholder="Markdown, 8000 characters at most"
345			}
346			if f.Focus {
347				autofocus
348			}
349		></textarea>
350		if f.Path != "" {
351			<input type="hidden" name="path" value={ f.Path }/>
352			<input type="hidden" name="line" value={ f.Line }/>
353			<input type="hidden" name="side" value={ f.Side }/>
354			<label>from line <input type="number" name="start_line" min="1" value={ f.StartLine }/></label>
355		}
356		if f.ReplyTo != "" {
357			<input type="hidden" name="reply_to" value={ f.ReplyTo }/>
358		}
359		if f.Path == "" && f.ReplyTo == "" && !f.Plain {
360			<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>
361		}
362		if f.From > 0 {
363			<input type="hidden" name="from" value={ itoa(f.From) }/>
364		}
365		<div class="f-btns">
366			<button type="submit" class="btn primary">Comment</button>
367			if f.Cancel != "" {
368				<a class="btn" href={ templ.SafeURL(f.Cancel) }>Cancel</a>
369			}
370		</div>
371	</form>
372}
373
374templ mrFooter(p MRPage) {
375	<footer class="mr-footer">
376		<nav data-mr-tabs aria-label="Branch details">
377			<a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a>
378			<a id="mr-tab-checks" href="#mr-checks">
379				{ "Checks " }
380				<span data-checks-label>
381					if p.ChecksSum.Total > 0 {
382						{ p.ChecksSum.Title }
383					} else {
384						no checks
385					}
386				</span>
387			</a>
388		</nav>
389		<div id="mr-commits" data-mr-panel role="region" aria-labelledby="mr-tab-commits">
390			<table class="mr-commits">
391				<thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
392				<tbody>
393					for _, c := range p.Commits {
394						<tr data-new?={ c.New }>
395							<td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td>
396							<td>{ c.Subject }</td>
397							<td>{ c.Author }</td>
398							<td>
399								if c.WhenAt != "" {
400									<time datetime={ c.WhenAt }>{ c.When }</time>
401								}
402							</td>
403							<td>
404								if c.Sig != nil {
405									@signaturePill(c.Sig)
406								}
407							</td>
408							<td>
409								if c.New {
410									<span>new</span>
411								}
412							</td>
413						</tr>
414					}
415				</tbody>
416			</table>
417			if len(p.Commits) == 0 {
418				<p class="dim">no commit to merge.</p>
419			}
420			if p.CommitsNote != "" {
421				<p class="note">{ p.CommitsNote }</p>
422			}
423		</div>
424		<div id="mr-checks-panel" data-mr-panel role="region" aria-labelledby="mr-tab-checks">
425			@MRChecks(p.Checks)
426		</div>
427	</footer>
428}
429
430templ mrRail(p MRPage) {
431	<section aria-labelledby="r-files" class="r-files">
432		<h2 id="r-files">
433			Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd>
434			<button type="button" class="btn icon fold" data-js aria-expanded="true" title="fold the list of files" hx-on:click="ermiteMR.foldFiles()">
435				<span class="ico i-chevron" aria-hidden="true"></span><span class="sr">fold the list of files</span>
436			</button>
437		</h2>
438		@FileList(p.Files)
439	</section>
440	if len(p.Verdicts) > 0 {
441		<section>
442			<h2>Verdicts</h2>
443			<ul>
444				for _, v := range p.Verdicts {
445					<li>
446						<a href={ templ.SafeURL(v.Href) }>{ v.Author }: { v.Label }</a>
447						if v.Stale {
448							<span class="mute">(stale)</span>
449						}
450					</li>
451				}
452			</ul>
453		</section>
454	}
455}
456
457// mrBar is the bar above the diff, which follows the review: the totals, the
458// layout of the diff, the state of the checks and, for the owner of an open
459// request, the merge menu and the other actions. Each menu is a <details>: it
460// works without scripts; with them, M and the keys of the menu drive it.
461templ mrBar(p MRPage) {
462	<div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
463		<span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
464		@diffViewMenu(p.DiffView)
465		@mrActions(p, false)
466	</div>
467}
468
469// mergeMenu is the merge of an open request, for the owner: the form (it
470// carries the head that was read), or "Ready for review" for a draft. The
471// mode and the branch deletion have keys (1-3, D) while it is open.
472//
473// The badge is a live slot (mr-mergeability); the modes and the button read
474// its data-* attributes (hx-live bindings), so that they follow the slot when
475// the stream replaces its content. The server renders the same state in the
476// attributes, for a page without scripts.
477templ mergeMenu(p MRPage) {
478	<details class="menu mmenu" id="merge-menu" data-state="open" open?={ p.Box.Error != "" }>
479		<summary class="btn primary" aria-keyshortcuts="Shift+M">
480			if p.Box.ReadyAction != "" {
481				Draft
482			} else {
483				Merge
484			}
485			<kbd aria-hidden="true">M</kbd>
486		</summary>
487		<div class="menu-pop" role="group" aria-label="Merge">
488			if p.Box.HeadGone {
489				<p class="dim">the source branch was deleted</p>
490			}
491			if p.Box.Error != "" {
492				<p class="d-err" role="alert">{ p.Box.Error }</p>
493			}
494			if p.Box.ReadyAction != "" {
495				<p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
496				<form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
497					<button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>
498				</form>
499			} else if !p.Box.HeadGone {
500				@mrMergeabilitySlot(p.Box.Mergeability)
501				if p.Box.AutoMerge != nil {
502					<form class="d-acts" method="post" action={ templ.SafeURL(p.Box.CancelAutoAction) }>
503						<p class="dim" role="status">
504							Auto-merge: { p.Box.AutoMerge.Mode } when ready, enabled by { p.Box.AutoMerge.EnabledBy }
505							if p.Box.AutoMerge.When != "" {
506								{ " " }<time datetime={ p.Box.AutoMerge.WhenAt }>{ p.Box.AutoMerge.When }</time>
507							}
508							{ " " }(head { p.Box.AutoMerge.HeadShort })
509						</p>
510						<div class="d-btns">
511							<button type="submit" class="btn">Cancel auto-merge</button>
512						</div>
513					</form>
514				}
515				<form
516					class="d-acts"
517					method="post"
518					action={ templ.SafeURL(p.Box.MergeAction) }
519					hx-post={ p.Box.MergeAction }
520					hx-swap="none"
521					hx-sync="this:drop"
522					hx-disable="find button[type=submit]"
523				>
524					<input type="hidden" name="head_id" value={ p.Box.Head }/>
525					<fieldset class="d-modes">
526						<legend class="sr">merge mode</legend>
527						for i, m := range p.Box.Modes {
528							<label>
529								<input
530									type="radio"
531									name="mode"
532									value={ m.Value }
533									checked?={ i == p.Box.Checked() }
534									disabled?={ m.Disabled }
535									title={ m.Reason }
536									:disabled="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true"
537									:title="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason"
538								/>
539								{ m.Label }
540								if i < 9 {
541									<kbd aria-hidden="true">{ itoa(i + 1) }</kbd>
542								}
543							</label>
544						}
545					</fieldset>
546					@MRMergeChecks(p.ChecksSum)
547					if p.Box.CanDeleteBranch {
548						<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>
549					}
550					<div class="d-btns">
551						<button
552							type="submit"
553							class="btn primary"
554							data-default
555							disabled?={ !p.Box.CanMerge() }
556							title={ p.Box.MergeTitle() }
557							:disabled="this.form.classList.contains('htmx-request') || !this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
558							:title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
559						>Merge into { p.Target }</button>
560						<span class="htmx-indicator" role="status">Merging…</span>
561					</div>
562				</form>
563				if p.Box.CanAutoMerge {
564					<form class="d-acts" method="post" action={ templ.SafeURL(p.Box.AutoAction) }>
565						<input type="hidden" name="head_id" value={ p.Box.Head }/>
566						<fieldset class="d-modes">
567							<legend>Auto-merge mode</legend>
568							for i, m := range p.Box.Modes {
569								<label><input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/> { m.Label }</label>
570							}
571						</fieldset>
572						<div class="d-btns">
573							<button type="submit" class="btn" disabled?={ len(p.Box.Modes) == 0 } title="merge once the checks pass">Merge when ready</button>
574						</div>
575					</form>
576				}
577			}
578		</div>
579	</details>
580}
581
582// MRMergeability is the content of the mergeability slot: the badge, and the
583// state the controls of the merge box read.
584templ MRMergeability(v MergeabilityView) {
585	<span
586		data-mergeability
587		data-state={ v.State }
588		data-ff-ok={ strconv.FormatBool(v.FFOk) }
589		data-merge-ok={ strconv.FormatBool(v.MergeOk) }
590		data-ff-reason={ v.FFReason }
591		data-merge-reason={ v.MergeReason }
592	>
593		<span class="badge" data-state={ v.State }>{ v.Label }</span>
594		if v.Detail != "" {
595			{ " " }<span class="dim">{ v.Detail }</span>
596		}
597	</span>
598	if len(v.Paths) > 0 {
599		<ul class="mg-paths" aria-label="conflicting files">
600			for _, path := range v.Paths {
601				<li><code>{ path }</code></li>
602			}
603		</ul>
604	}
605	if v.PathsNote != "" {
606		<p class="dim">{ v.PathsNote }</p>
607	}
608}
609
610// mrMergeabilitySlot is the status region of the badge, with its content: the
611// stream replaces the children, never the element (see LiveSlot).
612templ mrMergeabilitySlot(v MergeabilityView) {
613	<div id="mr-mergeability" class="live-slot" role="status" data-slot>
614		@MRMergeability(v)
615	</div>
616}
617
618// MRMergeabilityFragment is the slot as the stream sends it.
619templ MRMergeabilityFragment(v MergeabilityView) {
620	@mrMergeabilitySlot(v)
621}
622
623// moreMenu shows read-only settings to every viewer, and keeps owner actions
624// behind the same merge-box permission as before.
625templ moreMenu(p MRPage) {
626	<details class="menu omenu">
627		<summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>
628		<div class="menu-pop" role="group" aria-label="More actions">
629			<section class="merge-settings">
630				<h2>Merge settings</h2>
631				<dl class="kv">
632					<dt>modes</dt>
633					<dd>{ strings.Join(p.Modes, ", ") }</dd>
634					<dt>checks</dt>
635					<dd>
636						if p.ChecksBlock {
637							failing or unfinished checks block the merge
638						} else {
639							do not block the merge
640						}
641					</dd>
642				</dl>
643			</section>
644			if p.Box != nil {
645				if p.Box.Signed {
646					<p class="d-note" id="mr-signing">
647						squash and merge commits are signed with ermite's own key (generated on first start).
648						if p.Box.Fingerprint != "" {
649							fingerprint <code>{ p.Box.Fingerprint }</code>
650						}
651						<code>ermite signing-key show</code> prints it.
652					</p>
653				}
654				if p.Box.CanMarkRead {
655					<form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
656						<input type="hidden" name="head_id" value={ p.Box.Head }/>
657						<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>
658					</form>
659				}
660				<form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
661					<button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
662				</form>
663				if !p.Box.HeadGone && p.Box.ReadyAction == "" {
664					<details class="d-or">
665						<summary class="mute">or from a terminal</summary>
666						@CloneBlock(p.Box.CLI)
667					</details>
668				}
669			}
670		</div>
671	</details>
672}
673
674templ Compare(l Layout, p ComparePage) {
675	@Shell(l, compareRail(p)) {
676		<header class="show mr-h">
677			<h1>{ p.Source } → { p.Target }</h1>
678			<dl class="kv show-kv">
679				<dt>branch</dt>
680				<dd><a href={ templ.SafeURL(p.SourceHref) }>{ p.Source }</a></dd>
681				<dt>target</dt>
682				<dd><a href={ templ.SafeURL(p.TargetHref) }>{ p.Target }</a></dd>
683				<dt>ahead</dt>
684				<dd>{ p.AheadText }</dd>
685			</dl>
686		</header>
687		if p.Note != "" {
688			<p class="notice">{ p.Note }</p>
689		}
690		if p.Existing != nil {
691			<p class="notice">an open merge request already covers these branches: <a href={ templ.SafeURL(p.Existing.Href) }>{ p.Existing.Label }</a></p>
692		}
693		if p.Create != nil {
694			<form class="cform create" method="post" action={ templ.SafeURL(p.Create.Action) }>
695				<input type="hidden" name="source" value={ p.Create.Source }/>
696				<input type="hidden" name="target" value={ p.Create.Target }/>
697				<label for="mr-title">title</label>
698				<input id="mr-title" name="title" type="text" maxlength="120" required value={ p.Create.Title } autocomplete="off"/>
699				<label for="mr-desc">description</label>
700				<textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="Markdown, optional"></textarea>
701				<div class="f-btns">
702					<button type="submit" class="btn primary">Create merge request</button>
703				</div>
704			</form>
705		}
706		if p.HasDiff {
707			<div class="diff-tools" role="group" aria-label="Diff view">
708				<span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
709				<span class="grow"></span>
710				@diffViewMenu(p.DiffView)
711			</div>
712			if p.Invisible {
713				<p class="notice">
714					this diff holds invisible characters (direction controls, zero-width characters). They are shown as
715					<span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
716				</p>
717			}
718			<div
719				id="diffs"
720				if p.Both {
721					data-diff={ p.View }
722				} else {
723					data-view={ p.View }
724				}
725				if p.Both && !p.Pinned {
726					hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
727				}
728			>
729				for _, f := range p.Files {
730					@DiffFileView(f, p.DiffView)
731				}
732			</div>
733		}
734	}
735}
736
737templ compareRail(p ComparePage) {
738	<section aria-labelledby="r-ahead">
739		<h2 id="r-ahead">Commits <span class="count">{ itoa(len(p.Ahead)) }</span></h2>
740		<ul class="pcommits">
741			for _, c := range p.Ahead {
742				<li>
743					<a href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
744					<span class="s">{ c.Subject }</span>
745					<span class="mute">
746						{ c.Author }
747						if c.WhenAt != "" {
748							{ " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
749						}
750					</span>
751				</li>
752			}
753		</ul>
754		if len(p.Ahead) == 0 {
755			<p class="dim">no commit ahead of { p.Target }.</p>
756		}
757	</section>
758	if p.HasDiff {
759		<section aria-labelledby="r-files">
760			<h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
761			@FileList(p.Files)
762		</section>
763	}
764	<section aria-labelledby="r-cli">
765		<h2 id="r-cli">From a terminal</h2>
766		@CloneBlock(p.CLI)
767	</section>
768}
769
770// MRMergeChecks is the part of the owner's merge form that depends on the
771// checks: a live update replaces it and keeps "Ignore checks" ticked.
772templ MRMergeChecks(c ChecksSummary) {
773	<div id="mr-merge-checks" data-keep-checks>
774		if c.Kind == "bad" || c.Kind == "running" {
775			<p class="d-note">{ c.State + ": a failing or unfinished check blocks the merge." }</p>
776			<label class="d-check"><input type="checkbox" name="ignore_checks"/> Ignore checks</label>
777		}
778	</div>
779}
780
781// MRStateFragment updates only state and actions, leaving review drafts alone.
782templ MRStateFragment(p MRPage) {
783	@mrState(p)
784	@mrActions(p, true)
785}
786
787templ mrState(p MRPage) {
788	<dd id="mr-state" hx-swap-oob="true" class="mr-state" data-state={ p.State }>
789		{ p.State }
790		if p.MergedShort != "" {
791			{ " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
792		}
793	</dd>
794}
795
796templ mrActions(p MRPage, fragment bool) {
797	<div id="mr-actions" hx-swap-oob="true" class="mr-acts">
798		@MRChecksSum(p.ChecksSum)
799		if p.Box != nil {
800			@mergeMenu(p)
801		}
802		@moreMenu(p)
803		if fragment && p.MergeError != "" {
804			<p class="d-err" role="alert">{ p.MergeError }</p>
805		}
806	</div>
807}
808
809// MRActionStatus remains visible even when the request detail cannot be read.
810templ MRActionStatus(message, href string) {
811 <div id="mr-actions" class="mr-acts" hx-swap-oob="true">
812 <p class="d-err" role="alert">{ message } <a href={ templ.SafeURL(href) }>reload</a></p>
813 </div>
814}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close