v0.32.0 53e25a3

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

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