detached 27e453c

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		<details id="mr-panels">
382			<summary>Commits <span class="count">{ p.CommitCount }</span> · Checks</summary>
383			<nav data-mr-tabs aria-label="Branch details">
384				<a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a>
385				<a id="mr-tab-checks" href="#mr-checks">
386					{ "Checks " }
387					<span data-checks-label>
388						if p.ChecksSum.Total > 0 {
389							{ p.ChecksSum.Title }
390						} else {
391							no checks
392						}
393					</span>
394				</a>
395			</nav>
396			<div id="mr-commits" data-mr-panel aria-labelledby="mr-tab-commits">
397				<table class="mr-commits">
398					<thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
399					<tbody>
400						for _, c := range p.Commits {
401							<tr data-new?={ c.New }>
402								<td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td>
403								<td>{ c.Subject }</td>
404								<td>{ c.Author }</td>
405								<td>
406									if c.WhenAt != "" {
407										<time datetime={ c.WhenAt }>{ c.When }</time>
408									}
409								</td>
410								<td>
411									if c.Sig != nil {
412										@signaturePill(c.Sig)
413									}
414								</td>
415								<td>
416									if c.New {
417										<span>new</span>
418									}
419								</td>
420							</tr>
421						}
422					</tbody>
423				</table>
424				if len(p.Commits) == 0 {
425					<p class="dim">no commit to merge.</p>
426				}
427				if p.CommitsNote != "" {
428					<p class="note">{ p.CommitsNote }</p>
429				}
430			</div>
431			<div id="mr-checks-panel" data-mr-panel aria-labelledby="mr-tab-checks">
432				@MRChecks(p.Checks)
433			</div>
434		</details>
435	</footer>
436}
437
438templ mrRail(p MRPage) {
439	<section aria-labelledby="r-files" class="r-files">
440		<h2 id="r-files">
441			Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd>
442			<button type="button" class="btn icon fold" data-js aria-expanded="true" title="fold the list of files" hx-on:click="ermiteMR.foldFiles()">
443				<span class="ico i-chevron" aria-hidden="true"></span><span class="sr">fold the list of files</span>
444			</button>
445		</h2>
446		@FileList(p.Files)
447	</section>
448	if len(p.Verdicts) > 0 {
449		<section>
450			<h2>Verdicts</h2>
451			<ul>
452				for _, v := range p.Verdicts {
453					<li>
454						<a href={ templ.SafeURL(v.Href) }>{ v.Author }: { v.Label }</a>
455						if v.Stale {
456							<span class="mute">(stale)</span>
457						}
458					</li>
459				}
460			</ul>
461		</section>
462	}
463}
464
465// mrBar is the bar above the diff, which follows the review: the totals, the
466// layout of the diff, the state of the checks and, for the owner of an open
467// request, the merge menu and the other actions. Each menu is a <details>: it
468// works without scripts; with them, M and the keys of the menu drive it.
469templ mrBar(p MRPage) {
470	<div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
471		<span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
472		@diffViewMenu(p.DiffView)
473		<span class="grow"></span>
474		@MRChecksSum(p.ChecksSum)
475		if p.Box != nil {
476			@mergeMenu(p)
477		}
478		@moreMenu(p)
479	</div>
480}
481
482// mergeMenu is the merge of an open request, for the owner: the form (it
483// carries the head that was read), or "Ready for review" for a draft. The
484// mode and the branch deletion have keys (1-3, D) while it is open.
485//
486// The badge is a live slot (mr-mergeability); the modes and the button read
487// its data-* attributes (hx-live bindings), so that they follow the slot when
488// the stream replaces its content. The server renders the same state in the
489// attributes, for a page without scripts.
490templ mergeMenu(p MRPage) {
491	<details class="menu mmenu" id="merge-menu" data-state="open" open?={ p.Box.Error != "" }>
492		<summary class="btn primary" aria-keyshortcuts="Shift+M">
493			if p.Box.ReadyAction != "" {
494				Draft
495			} else {
496				Merge
497			}
498			<kbd aria-hidden="true">M</kbd>
499		</summary>
500		<div class="menu-pop" role="group" aria-label="Merge">
501			if p.Box.HeadGone {
502				<p class="dim">the source branch was deleted</p>
503			}
504			if p.Box.Error != "" {
505				<p class="d-err" role="alert">{ p.Box.Error }</p>
506			}
507			if p.Box.ReadyAction != "" {
508				<p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
509				<form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
510					<button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>
511				</form>
512			} else if !p.Box.HeadGone {
513				@mrMergeabilitySlot(p.Box.Mergeability)
514				<form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
515					<input type="hidden" name="head_id" value={ p.Box.Head }/>
516					<fieldset class="d-modes">
517						<legend class="sr">merge mode</legend>
518						for i, m := range p.Box.Modes {
519							<label>
520								<input
521									type="radio"
522									name="mode"
523									value={ m.Value }
524									checked?={ i == p.Box.Checked() }
525									disabled?={ m.Disabled }
526									title={ m.Reason }
527									:disabled="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true"
528									:title="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason"
529								/>
530								{ m.Label }
531								if i < 9 {
532									<kbd aria-hidden="true">{ itoa(i + 1) }</kbd>
533								}
534							</label>
535						}
536					</fieldset>
537					if p.Box.CanDeleteBranch {
538						<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>
539					}
540					<div class="d-btns">
541						<button
542							type="submit"
543							class="btn primary"
544							data-default
545							disabled?={ !p.Box.CanMerge() }
546							title={ p.Box.MergeTitle() }
547							:disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
548							:title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
549						>Merge into { p.Target }</button>
550					</div>
551				</form>
552			}
553		</div>
554	</details>
555}
556
557// MRMergeability is the content of the mergeability slot: the badge, and the
558// state the controls of the merge box read.
559templ MRMergeability(v MergeabilityView) {
560	<span
561		data-mergeability
562		data-state={ v.State }
563		data-ff-ok={ strconv.FormatBool(v.FFOk) }
564		data-merge-ok={ strconv.FormatBool(v.MergeOk) }
565		data-ff-reason={ v.FFReason }
566		data-merge-reason={ v.MergeReason }
567	>
568		<span class="badge" data-state={ v.State }>{ v.Label }</span>
569		if v.Detail != "" {
570			{ " " }<span class="dim">{ v.Detail }</span>
571		}
572	</span>
573	if len(v.Paths) > 0 {
574		<ul class="mg-paths" aria-label="conflicting files">
575			for _, path := range v.Paths {
576				<li><code>{ path }</code></li>
577			}
578		</ul>
579	}
580	if v.PathsNote != "" {
581		<p class="dim">{ v.PathsNote }</p>
582	}
583}
584
585// mrMergeabilitySlot is the status region of the badge, with its content: the
586// stream replaces the children, never the element (see LiveSlot).
587templ mrMergeabilitySlot(v MergeabilityView) {
588	<div id="mr-mergeability" class="live-slot" role="status" data-slot>
589		@MRMergeability(v)
590	</div>
591}
592
593// MRMergeabilityFragment is the slot as the stream sends it.
594templ MRMergeabilityFragment(v MergeabilityView) {
595	@mrMergeabilitySlot(v)
596}
597
598// moreMenu shows read-only settings to every viewer, and keeps owner actions
599// behind the same merge-box permission as before.
600templ moreMenu(p MRPage) {
601	<details class="menu omenu">
602		<summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>
603		<div class="menu-pop" role="group" aria-label="More actions">
604			<section class="merge-settings">
605				<h2>Merge settings</h2>
606				<dl class="kv">
607					<dt>modes</dt>
608					<dd>{ strings.Join(p.Modes, ", ") }</dd>
609					<dt>checks</dt>
610					<dd>
611						if p.ChecksBlock {
612							failing ones block the merge
613						} else {
614							do not block the merge
615						}
616					</dd>
617				</dl>
618			</section>
619			if p.Box != nil {
620				if p.Box.Signed {
621					<p class="d-note" id="mr-signing">
622						squash and merge commits are signed with ermite's own key (generated on first start).
623						if p.Box.Fingerprint != "" {
624							fingerprint <code>{ p.Box.Fingerprint }</code>
625						}
626						<code>ermite signing-key show</code> prints it.
627					</p>
628				}
629				if p.Box.CanMarkRead {
630					<form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
631						<input type="hidden" name="head_id" value={ p.Box.Head }/>
632						<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>
633					</form>
634				}
635				<form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
636					<button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
637				</form>
638				if !p.Box.HeadGone && p.Box.ReadyAction == "" {
639					<details class="d-or">
640						<summary class="mute">or from a terminal</summary>
641						@CloneBlock(p.Box.CLI)
642					</details>
643				}
644			}
645		</div>
646	</details>
647}
648
649templ Compare(l Layout, p ComparePage) {
650	@Shell(l, compareRail(p)) {
651		<header class="show mr-h">
652			<h1>{ p.Source } → { p.Target }</h1>
653			<dl class="kv show-kv">
654				<dt>branch</dt>
655				<dd><a href={ templ.SafeURL(p.SourceHref) }>{ p.Source }</a></dd>
656				<dt>target</dt>
657				<dd><a href={ templ.SafeURL(p.TargetHref) }>{ p.Target }</a></dd>
658				<dt>ahead</dt>
659				<dd>{ p.AheadText }</dd>
660			</dl>
661		</header>
662		if p.Note != "" {
663			<p class="notice">{ p.Note }</p>
664		}
665		if p.Existing != nil {
666			<p class="notice">an open merge request already covers these branches: <a href={ templ.SafeURL(p.Existing.Href) }>{ p.Existing.Label }</a></p>
667		}
668		if p.Create != nil {
669			<form class="cform create" method="post" action={ templ.SafeURL(p.Create.Action) }>
670				<input type="hidden" name="source" value={ p.Create.Source }/>
671				<input type="hidden" name="target" value={ p.Create.Target }/>
672				<label for="mr-title">title</label>
673				<input id="mr-title" name="title" type="text" maxlength="120" required value={ p.Create.Title } autocomplete="off"/>
674				<label for="mr-desc">description</label>
675				<textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="Markdown, optional"></textarea>
676				<div class="f-btns">
677					<button type="submit" class="btn primary">Create merge request</button>
678				</div>
679			</form>
680		}
681		if p.HasDiff {
682			<div class="diff-tools" role="group" aria-label="Diff view">
683				<span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
684				<span class="grow"></span>
685				@diffViewMenu(p.DiffView)
686			</div>
687			if p.Invisible {
688				<p class="notice">
689					this diff holds invisible characters (direction controls, zero-width characters). They are shown as
690					<span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
691				</p>
692			}
693			<div
694				id="diffs"
695				if p.Both {
696					data-diff={ p.View }
697				} else {
698					data-view={ p.View }
699				}
700				if p.Both && !p.Pinned {
701					hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
702				}
703			>
704				for _, f := range p.Files {
705					@DiffFileView(f, p.DiffView)
706				}
707			</div>
708		}
709	}
710}
711
712templ compareRail(p ComparePage) {
713	<section aria-labelledby="r-ahead">
714		<h2 id="r-ahead">Commits <span class="count">{ itoa(len(p.Ahead)) }</span></h2>
715		<ul class="pcommits">
716			for _, c := range p.Ahead {
717				<li>
718					<a href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
719					<span class="s">{ c.Subject }</span>
720					<span class="mute">
721						{ c.Author }
722						if c.WhenAt != "" {
723							{ " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
724						}
725					</span>
726				</li>
727			}
728		</ul>
729		if len(p.Ahead) == 0 {
730			<p class="dim">no commit ahead of { p.Target }.</p>
731		}
732	</section>
733	if p.HasDiff {
734		<section aria-labelledby="r-files">
735			<h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
736			@FileList(p.Files)
737		</section>
738	}
739	<section aria-labelledby="r-cli">
740		<h2 id="r-cli">From a terminal</h2>
741		@CloneBlock(p.CLI)
742	</section>
743}

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