detached 973e682

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		}
17		<div class="filter">
18			<label for="mr-filter" class="sr">filter</label>
19			<input
20				id="mr-filter"
21				type="search"
22				data-filter=".mrs tbody tr"
23				placeholder="Filter by title, branch, author or state…"
24				autocomplete="off"
25				hx-on:input="ermite.filter(this)"
26			/>
27		</div>
28		if p.Newest == "" {
29			<h2 class="rule-h"><span>Open</span><span class="count">{ itoa(len(p.Open)) }</span></h2>
30			@mrRows(p.Open, "Open merge requests")
31			if len(p.Open) == 0 {
32				<p class="lede dim">no open merge requests.</p>
33			}
34			if p.OpenNote != "" {
35				<p class="note">{ p.OpenNote }</p>
36			}
37		}
38		<h2 class="rule-h"><span>Merged and closed</span><span class="count">{ itoa(len(p.Done)) }</span></h2>
39		@mrRows(p.Done, "Merged and closed merge requests")
40		if len(p.Done) == 0 {
41			<p class="lede dim">nothing merged or closed yet.</p>
42		}
43		if p.Older != "" || p.Newest != "" {
44			<nav class="pager" aria-label="Pages">
45				if p.Newest != "" {
46					<a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a>
47				} else {
48					<span class="btn" aria-disabled="true">← Newest</span>
49				}
50				if p.Older != "" {
51					<a class="btn" href={ templ.SafeURL(p.Older) } rel="next">Older →</a>
52				} else {
53					<span class="btn" aria-disabled="true">Older →</span>
54				}
55			</nav>
56		}
57	}
58}
59
60templ mrRows(rows []MRRow, label string) {
61	if len(rows) > 0 {
62		<table class="list mrs" aria-label={ label }>
63			<tbody data-rows>
64				for _, m := range rows {
65					<tr data-cursor?={ m.Cursor } data-state={ m.State }>
66						<td class="c-n mute">{ m.Number }</td>
67						<td class="c-state">{ m.State }</td>
68						<td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <span class="mute">{ m.Branches }</span></td>
69						<td class="c-who dim">{ m.Author }</td>
70						<td class="c-when nowrap dim">
71							if m.WhenAt != "" {
72								<time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
73							}
74						</td>
75					</tr>
76				}
77			</tbody>
78		</table>
79	}
80}
81
82templ mrListRail(p MRListPage) {
83	<section aria-labelledby="r-states">
84		<h2 id="r-states">States</h2>
85		<dl class="kv">
86			<dt>open</dt>
87			<dd>awaits review</dd>
88			<dt>merged</dt>
89			<dd>the target took the branch</dd>
90			<dt>closed</dt>
91			<dd>refused, branch kept</dd>
92		</dl>
93	</section>
94	<section aria-labelledby="r-new">
95		<h2 id="r-new">Open one</h2>
96		if len(p.Branches) > 0 {
97			<p class="dim">compare a branch with { p.Default }:</p>
98			<nav class="outline" aria-label="Branches">
99				for _, b := range p.Branches {
100					<a href={ templ.SafeURL(b.Href) }>{ b.Name }</a>
101				}
102			</nav>
103		} else {
104			<p class="dim">there is no other branch to merge yet.</p>
105		}
106		@CloneBlock(p.CLI)
107	</section>
108}
109
110// MergeRequest is a merge request: its facts, its description, the diff with
111// the notes under their lines, the general comments; and in the rail the merge
112// form, the checks, the settings, the commits and the files.
113templ MergeRequest(l Layout, p MRPage) {
114	@Shell(l, mrRail(p)) {
115		<section class="mr-card">
116			<header class="show mr-h">
117				<h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
118				<dl class="kv show-kv">
119					<dt>state</dt>
120					<dd class="mr-state" data-state={ p.State }>
121						{ p.State }
122						if p.MergedShort != "" {
123							{ " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
124						}
125					</dd>
126					<dt>branch</dt>
127					<dd>
128						@branchLink(p.Source, p.SourceHref)
129						{ " → " }
130						@branchLink(p.Target, p.TargetHref)
131					</dd>
132					<dt>opened</dt>
133					<dd>
134						{ p.Opened.Name }
135						if p.Opened.WhenAt != "" {
136							{ " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
137						}
138					</dd>
139					if p.UpdatedAt != "" {
140						<dt>updated</dt>
141						<dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
142					}
143				</dl>
144			</header>
145			if p.Desc != "" {
146				<article class="mr-desc prose" aria-label="description" hx-ignore>
147					@templ.Raw(string(p.Desc))
148				</article>
149			}
150		</section>
151		if p.Deleting != "" {
152			<p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>
153		}
154		if p.MergeError != "" {
155			<p class="d-err" role="alert">{ p.MergeError }</p>
156		}
157		@LiveSlot("mr-notice")
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		@mrBar(p)
178		if p.Invisible {
179			<p class="notice">
180				this diff holds invisible characters (direction controls, zero-width characters). They are shown as
181				<span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
182			</p>
183		}
184		if p.Note != "" {
185			<p class="notice">{ p.Note }</p>
186		}
187		<div
188			id="diffs"
189			data-mr
190			if p.Canonical != "" {
191				data-canonical={ p.Canonical }
192			}
193			if p.Both {
194				data-diff={ p.View }
195			} else {
196				data-view={ p.View }
197			}
198			if p.Both && !p.Pinned {
199				hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
200			}
201		>
202			for _, f := range p.Files {
203				@DiffFileView(f, p.DiffView)
204			}
205			if len(p.Files) == 0 {
206				<p class="note">no changed files.</p>
207			}
208		</div>
209		<h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
210		for _, c := range p.Loose {
211			@commentView(c, "")
212		}
213		for _, c := range p.General {
214			@commentView(c, "")
215		}
216		if p.NComments == 0 {
217			<p class="dim">no comments yet.</p>
218		}
219		if p.GeneralForm != nil {
220			@commentForm(*p.GeneralForm, "")
221		}
222	}
223}
224
225templ branchLink(name, href string) {
226	if href != "" {
227		<a href={ templ.SafeURL(href) }>{ name }</a>
228	} else {
229		{ name }
230	}
231}
232
233// lineNotes are the comments under a line of the diff, and the form to write
234// one when the reader asked for it. The diff is in the page twice (unified and
235// split); the suffix keeps the ids unique, the unified table having none.
236templ lineNotes(notes []CommentView, form *CommentForm, suffix string) {
237	for _, c := range notes {
238		@commentView(c, suffix)
239	}
240	if form != nil {
241		@commentForm(*form, suffix)
242	}
243}
244
245// commentView is a note: a ruled block, under its line in the diff or in the
246// list of comments. Body comes only from the safe Markdown renderer.
247templ commentView(c CommentView, suffix string) {
248	<div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) } data-thread?={ c.Reply != nil }>
249		<p class="c-head">
250			<span class="c-who">{ c.Author }</span>
251			if c.WhenAt != "" {
252				{ " " }<time class="mute" datetime={ c.WhenAt } title={ c.Abs }>{ c.When }</time>
253			}
254			if c.Resolved {
255				{ " " }<span class="c-state">resolved</span>
256			}
257			if c.Outdated {
258				{ " " }<span class="c-state" title="written on an earlier head; the line may have moved">outdated</span>
259			}
260			if c.Where != "" {
261				{ " " }<span class="c-where mute">{ c.Where }</span>
262			}
263		</p>
264		if len(c.Parts) > 0 {
265			<p class="c-text">@userText(c.Parts)</p>
266		} else if c.Deleted {
267			if c.Verdict != "" {
268				<p class="mute">text deleted</p>
269			} else {
270				<p class="mute">deleted note</p>
271			}
272		} else {
273			<div class="c-body prose">
274				if suffix == "-s" {
275					@templ.Raw(string(c.BodySplit))
276				} else {
277					@templ.Raw(string(c.Body))
278				}
279			</div>
280		}
281		if c.Edited {
282			<span class="mute">edited</span>
283		}
284		if c.Verdict != "" {
285			<span class="c-state">{ c.Verdict }</span>
286		}
287		if c.Range != "" {
288			<span class="mute">{ c.Range }</span>
289		}
290		if c.Quote != "" {
291			<button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
292		}
293		if c.Edit != nil {
294			<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>
295		}
296		if c.Delete != nil {
297			<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>
298		}
299		for _, reply := range c.Replies {
300			@commentView(reply, suffix)
301		}
302		if c.Reply != nil {
303			<details data-reply>
304				<summary>Reply</summary>@commentForm(*c.Reply, suffix)
305			</details>
306		}
307		if c.Resolve != nil {
308			<form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
309				<input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
310				if c.Resolve.From > 0 {
311					<input type="hidden" name="from" value={ itoa(c.Resolve.From) }/>
312				}
313				<button type="submit" class="btn">{ c.Resolve.Label }</button>
314			</form>
315		}
316	</div>
317}
318
319func boolText(b bool) string {
320	if b {
321		return "true"
322	}
323	return "false"
324}
325
326// commentForm writes a comment, general or on a line.
327templ commentForm(f CommentForm, suffix string) {
328	<form class="cform" method="post" action={ templ.SafeURL(f.Action) }>
329		<label for={ f.ID + suffix }>{ f.Title }</label>
330		<textarea
331			id={ f.ID + suffix }
332			name="text"
333			rows="3"
334			maxlength="8000"
335			if f.Plain {
336				required
337				placeholder="plain text, 8000 characters at most"
338			} else {
339				placeholder="Markdown, 8000 characters at most"
340			}
341			if f.Focus {
342				autofocus
343			}
344		></textarea>
345		if f.Path != "" {
346			<input type="hidden" name="path" value={ f.Path }/>
347			<input type="hidden" name="line" value={ f.Line }/>
348			<input type="hidden" name="side" value={ f.Side }/>
349			<label>from line <input type="number" name="start_line" min="1" value={ f.StartLine }/></label>
350		}
351		if f.ReplyTo != "" {
352			<input type="hidden" name="reply_to" value={ f.ReplyTo }/>
353		}
354		if f.Path == "" && f.ReplyTo == "" && !f.Plain {
355			<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>
356		}
357		if f.From > 0 {
358			<input type="hidden" name="from" value={ itoa(f.From) }/>
359		}
360		<div class="f-btns">
361			<button type="submit" class="btn primary">Comment</button>
362			if f.Cancel != "" {
363				<a class="btn" href={ templ.SafeURL(f.Cancel) }>Cancel</a>
364			}
365		</div>
366	</form>
367}
368
369templ mrRail(p MRPage) {
370	<section aria-labelledby="r-files" class="r-files">
371		<h2 id="r-files">
372			Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd>
373			<button type="button" class="btn icon fold" data-js aria-expanded="true" title="fold the list of files" hx-on:click="ermiteMR.foldFiles()">
374				<span class="ico i-chevron" aria-hidden="true"></span><span class="sr">fold the list of files</span>
375			</button>
376		</h2>
377		@FileList(p.Files)
378	</section>
379	if len(p.Verdicts) > 0 {
380		<section>
381			<h2>Verdicts</h2>
382			<ul>
383				for _, v := range p.Verdicts {
384					<li>
385						<a href={ templ.SafeURL(v.Href) }>{ v.Author }: { v.Label }</a>
386						if v.Stale {
387							<span class="mute">(stale)</span>
388						}
389					</li>
390				}
391			</ul>
392		</section>
393	}
394	@MRChecks(p.Checks)
395	<section aria-labelledby="r-commits">
396		<h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
397		<ul class="pcommits">
398			for _, c := range p.Commits {
399				<li data-new?={ c.New }>
400					<a href={ templ.SafeURL(c.Href) }>{ c.Short }</a>
401					<span class="s">{ c.Subject }</span>
402					if c.Sig != nil {
403						@signaturePill(c.Sig)
404					}
405					<span class="mute">
406						{ c.Author }
407						if c.WhenAt != "" {
408							{ " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
409						}
410						if c.New {
411							{ " · new" }
412						}
413					</span>
414				</li>
415			}
416		</ul>
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	</section>
424	<section aria-labelledby="r-settings">
425		<h2 id="r-settings">Settings</h2>
426		<dl class="kv">
427			<dt>modes</dt>
428			<dd>{ strings.Join(p.Modes, ", ") }</dd>
429			<dt>checks</dt>
430			<dd>
431				if p.ChecksBlock {
432					failing ones block the merge
433				} else {
434					do not block the merge
435				}
436			</dd>
437		</dl>
438	</section>
439}
440
441// mrBar is the bar above the diff, which follows the review: the totals, the
442// layout of the diff, the state of the checks and, for the owner of an open
443// request, the merge menu and the other actions. Each menu is a <details>: it
444// works without scripts; with them, M and the keys of the menu drive it.
445templ mrBar(p MRPage) {
446	<div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
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		@diffViewMenu(p.DiffView)
449		<span class="grow"></span>
450		@MRChecksSum(p.ChecksSum)
451		if p.Box != nil {
452			@mergeMenu(p)
453			@moreMenu(p)
454		}
455	</div>
456}
457
458// mergeMenu is the merge of an open request, for the owner: the form (it
459// carries the head that was read), or "Ready for review" for a draft. The
460// mode and the branch deletion have keys (1-3, D) while it is open.
461//
462// The badge is a live slot (mr-mergeability); the modes and the button read
463// its data-* attributes (hx-live bindings), so that they follow the slot when
464// the stream replaces its content. The server renders the same state in the
465// attributes, for a page without scripts.
466templ mergeMenu(p MRPage) {
467	<details class="menu mmenu" id="merge-menu" data-state="open" open?={ p.Box.Error != "" }>
468		<summary class="btn primary" aria-keyshortcuts="Shift+M">
469			if p.Box.ReadyAction != "" {
470				Draft
471			} else {
472				Merge
473			}
474			<kbd aria-hidden="true">M</kbd>
475		</summary>
476		<div class="menu-pop" role="group" aria-label="Merge">
477			<dl class="kv">
478				<dt>head</dt>
479				<dd>
480					if p.Box.HeadGone {
481						gone: the source branch was deleted
482					} else {
483						{ p.HeadShort }
484					}
485				</dd>
486				<dt>into</dt>
487				<dd>{ p.Target }</dd>
488			</dl>
489			if p.Box.Error != "" {
490				<p class="d-err" role="alert">{ p.Box.Error }</p>
491			}
492			if p.Box.ReadyAction != "" {
493				<p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
494				<form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
495					<button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>
496				</form>
497			} else if !p.Box.HeadGone {
498				@mrMergeabilitySlot(p.Box.Mergeability)
499				<form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
500					<input type="hidden" name="head_id" value={ p.Box.Head }/>
501					<fieldset class="d-modes">
502						<legend class="sr">merge mode</legend>
503						for i, m := range p.Box.Modes {
504							<label>
505								<input
506									type="radio"
507									name="mode"
508									value={ m.Value }
509									checked?={ i == p.Box.Checked() }
510									disabled?={ m.Disabled }
511									title={ m.Reason }
512									:disabled="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true"
513									:title="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason"
514								/>
515								{ m.Label }
516								if i < 9 {
517									<kbd aria-hidden="true">{ itoa(i + 1) }</kbd>
518								}
519							</label>
520						}
521					</fieldset>
522					if p.Box.Signed {
523						<p class="d-note" id="mr-signing">
524							squash and merge commits are signed with ermite's own key (generated on first start).
525							if p.Box.Fingerprint != "" {
526								fingerprint <code>{ p.Box.Fingerprint }</code>
527							}
528							<code>ermite signing-key show</code> prints it.
529						</p>
530					}
531					if p.Box.CanDeleteBranch {
532						<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>
533					}
534					<div class="d-btns">
535						<button
536							type="submit"
537							class="btn primary"
538							data-default
539							disabled?={ !p.Box.CanMerge() }
540							title={ p.Box.MergeTitle() }
541							:disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
542							:title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
543						>Merge into { p.Target }</button>
544					</div>
545				</form>
546			}
547		</div>
548	</details>
549}
550
551// MRMergeability is the content of the mergeability slot: the badge, and the
552// state the controls of the merge box read.
553templ MRMergeability(v MergeabilityView) {
554	<span
555		data-mergeability
556		data-state={ v.State }
557		data-ff-ok={ strconv.FormatBool(v.FFOk) }
558		data-merge-ok={ strconv.FormatBool(v.MergeOk) }
559		data-ff-reason={ v.FFReason }
560		data-merge-reason={ v.MergeReason }
561	>
562		<span class="badge" data-state={ v.State }>{ v.Label }</span>
563		if v.Detail != "" {
564			{ " " }<span class="dim">{ v.Detail }</span>
565		}
566	</span>
567	if len(v.Paths) > 0 {
568		<ul class="mg-paths" aria-label="conflicting files">
569			for _, path := range v.Paths {
570				<li><code>{ path }</code></li>
571			}
572		</ul>
573	}
574	if v.PathsNote != "" {
575		<p class="dim">{ v.PathsNote }</p>
576	}
577}
578
579// mrMergeabilitySlot is the status region of the badge, with its content: the
580// stream replaces the children, never the element (see LiveSlot).
581templ mrMergeabilitySlot(v MergeabilityView) {
582	<div id="mr-mergeability" class="live-slot" role="status" data-slot>
583		@MRMergeability(v)
584	</div>
585}
586
587// MRMergeabilityFragment is the slot as the stream sends it.
588templ MRMergeabilityFragment(v MergeabilityView) {
589	@mrMergeabilitySlot(v)
590}
591
592// moreMenu is what else the owner does with an open request: mark the head
593// read, close it, merge it from a terminal.
594templ moreMenu(p MRPage) {
595	<details class="menu omenu">
596		<summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>
597		<div class="menu-pop" role="group" aria-label="More actions">
598			if p.Box.CanMarkRead {
599				<form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
600					<input type="hidden" name="head_id" value={ p.Box.Head }/>
601					<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>
602				</form>
603			}
604			<form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
605				<button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
606			</form>
607			if !p.Box.HeadGone && p.Box.ReadyAction == "" {
608				<details class="d-or">
609					<summary class="mute">or from a terminal</summary>
610					@CloneBlock(p.Box.CLI)
611				</details>
612			}
613		</div>
614	</details>
615}
616
617templ Compare(l Layout, p ComparePage) {
618	@Shell(l, compareRail(p)) {
619		<header class="show mr-h">
620			<h1>{ p.Source } → { p.Target }</h1>
621			<dl class="kv show-kv">
622				<dt>branch</dt>
623				<dd><a href={ templ.SafeURL(p.SourceHref) }>{ p.Source }</a></dd>
624				<dt>target</dt>
625				<dd><a href={ templ.SafeURL(p.TargetHref) }>{ p.Target }</a></dd>
626				<dt>ahead</dt>
627				<dd>{ p.AheadText }</dd>
628			</dl>
629		</header>
630		if p.Note != "" {
631			<p class="notice">{ p.Note }</p>
632		}
633		if p.Existing != nil {
634			<p class="notice">an open merge request already covers these branches: <a href={ templ.SafeURL(p.Existing.Href) }>{ p.Existing.Label }</a></p>
635		}
636		if p.Create != nil {
637			<form class="cform create" method="post" action={ templ.SafeURL(p.Create.Action) }>
638				<input type="hidden" name="source" value={ p.Create.Source }/>
639				<input type="hidden" name="target" value={ p.Create.Target }/>
640				<label for="mr-title">title</label>
641				<input id="mr-title" name="title" type="text" maxlength="120" required value={ p.Create.Title } autocomplete="off"/>
642				<label for="mr-desc">description</label>
643				<textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="Markdown, optional"></textarea>
644				<div class="f-btns">
645					<button type="submit" class="btn primary">Create merge request</button>
646				</div>
647			</form>
648		}
649		if p.HasDiff {
650			<div class="diff-tools" role="group" aria-label="Diff view">
651				<span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
652				<span class="grow"></span>
653				@diffViewMenu(p.DiffView)
654			</div>
655			if p.Invisible {
656				<p class="notice">
657					this diff holds invisible characters (direction controls, zero-width characters). They are shown as
658					<span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
659				</p>
660			}
661			<div
662				id="diffs"
663				if p.Both {
664					data-diff={ p.View }
665				} else {
666					data-view={ p.View }
667				}
668				if p.Both && !p.Pinned {
669					hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
670				}
671			>
672				for _, f := range p.Files {
673					@DiffFileView(f, p.DiffView)
674				}
675			</div>
676		}
677	}
678}
679
680templ compareRail(p ComparePage) {
681	<section aria-labelledby="r-ahead">
682		<h2 id="r-ahead">Commits <span class="count">{ itoa(len(p.Ahead)) }</span></h2>
683		<ul class="pcommits">
684			for _, c := range p.Ahead {
685				<li>
686					<a href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
687					<span class="s">{ c.Subject }</span>
688					<span class="mute">
689						{ c.Author }
690						if c.WhenAt != "" {
691							{ " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
692						}
693					</span>
694				</li>
695			}
696		</ul>
697		if len(p.Ahead) == 0 {
698			<p class="dim">no commit ahead of { p.Target }.</p>
699		}
700	</section>
701	if p.HasDiff {
702		<section aria-labelledby="r-files">
703			<h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
704			@FileList(p.Files)
705		</section>
706	}
707	<section aria-labelledby="r-cli">
708		<h2 id="r-cli">From a terminal</h2>
709		@CloneBlock(p.CLI)
710	</section>
711}

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 4

Tags 45

↑↓ moveenter openesc close