detached 98d36bf

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

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