v0.35.2 80d5067

diff.templ RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5// Commit is a commit: the message and facts, the diff tools, the diff.
6templ Commit(l Layout, p CommitPage) {
7	@Shell(l, commitRail(p)) {
8		<header class="show">
9			<h1>{ p.Subject }</h1>
10			if p.Body != "" {
11				<pre class="msg">{ p.Body }</pre>
12			}
13			<dl class="kv show-kv">
14				<dt>commit</dt>
15				<dd>{ p.SHA }</dd>
16				<dt>author</dt>
17				<dd>@personView(p.Author)</dd>
18				if p.Committer != nil {
19					<dt>committer</dt>
20					<dd>@personView(*p.Committer)</dd>
21				}
22				<dt>
23					if p.Merge {
24						merge of
25					} else {
26						parent
27					}
28				</dt>
29				<dd>
30					if p.Root {
31						none (root commit)
32					}
33					for i, par := range p.Parents {
34						if i > 0 {
35							{ " + " }
36						}
37						<a href={ templ.SafeURL(par.Href) }>{ par.Label }</a>
38					}
39					if p.Merge {
40						{ " · diff against the first parent" }
41					}
42				</dd>
43				if p.Sig != nil {
44					<dt>signature</dt>
45					<dd>
46						@signaturePill(p.Sig)
47						if p.Verification.Fingerprint != "" {
48							<code>{ p.Verification.Fingerprint }</code>
49						}
50						if p.Verification.KeyTitle != "" {
51							<span class="dim">{ "· " + visibleText(p.Verification.KeyTitle) }</span>
52						}
53						if p.Sig.Class != "sig-none" {
54							<span class="sig-why dim">{ p.Sig.Reason }</span>
55						}
56					</dd>
57				}
58				if p.Checks.Total > 0 {
59					<dt>checks</dt>
60					<dd>@CommitChecks(p.Checks)</dd>
61				}
62				if len(p.Refs) > 0 {
63					<dt>refs</dt>
64					<dd>
65						for _, r := range p.Refs {
66							<span class="pill">{ r }</span>
67						}
68					</dd>
69				}
70			</dl>
71		</header>
72		<div class="diff-tools" role="group" aria-label="Diff view">
73			<span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
74			<span class="grow"></span>
75			@diffViewMenu(p.DiffView)
76		</div>
77		if p.Invisible {
78			<p class="notice">
79				this diff holds invisible characters (direction controls, zero-width characters). They are shown as
80				<span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
81			</p>
82		}
83		if p.Note != "" {
84			<p class="notice">{ p.Note }</p>
85		}
86		<div
87			id="diffs"
88			if p.Both {
89				data-diff={ p.View }
90			} else {
91				data-view={ p.View }
92			}
93			if p.Both && !p.Pinned {
94				hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
95			}
96		>
97			for _, f := range p.Files {
98				@DiffFileView(f, p.DiffView)
99			}
100			if len(p.Files) == 0 {
101				<p class="note">no changed files{ " " }
102					if p.Merge {
103						against the first parent.
104					} else {
105						in this commit.
106					}
107				</p>
108			}
109		</div>
110	}
111}
112
113// diffViewMenu is the layout of the diff (unified, the default, or split) and
114// the fold of its files, in one menu: a <details> that works without scripts;
115// u, d and x still act without opening it. Its button names the layout shown.
116templ diffViewMenu(p DiffView) {
117	<details class="menu vmenu">
118		<summary class="btn" aria-label="Diff view">
119			if p.Both {
120				<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">{ viewLabel(p.View) }</span>
121			} else {
122				{ viewLabel(p.View) }
123			}
124		</summary>
125		<div class="menu-pop" role="group" aria-label="Diff view">
126			@viewLink(p, "unified", "Unified", "u", p.UnifiedHref)
127			@viewLink(p, "split", "Split", "d", p.SplitHref)
128			@collapseButton()
129		</div>
130	</details>
131}
132
133// viewLink is a button of the diff tools. It is a link, so that the split
134// layout works without JavaScript; with it, and when both layouts are in the
135// page, a click only flips the layout.
136templ viewLink(p DiffView, view, label, key, href string) {
137	<a
138		href={ templ.SafeURL(href) }
139		class="btn vbtn"
140		data-view={ view }
141		aria-keyshortcuts={ key }
142		if view == "split" {
143			:aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false"
144			:title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'"
145		}
146		if p.View == view {
147			aria-current="true"
148		}
149		if p.Both {
150			data-fire={ view }
151			:aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false"
152		}
153	>{ label }<kbd aria-hidden="true">{ key }</kbd></a>
154}
155
156// collapseButton folds every file of the diff, or opens them all again when
157// none is open; its label follows the state.
158templ collapseButton() {
159	<button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
160		<span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span><kbd aria-hidden="true">x</kbd>
161	</button>
162}
163
164templ personView(pr Person) {
165	{ pr.Name }
166	if pr.WhenAt != "" {
167		{ " · " }<time datetime={ pr.WhenAt } title={ pr.Abs }>{ pr.When }</time>{ " · " + pr.Abs }
168	}
169}
170
171templ commitRail(p CommitPage) {
172	<section aria-labelledby="r-files">
173		<h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
174		@FileList(p.Files)
175	</section>
176}
177
178// FileList is the table of contents of a diff: status letter, path, +n -m
179// and an ASCII bar. The row of the file in view is marked.
180templ FileList(files []DiffFile) {
181	if len(files) > fileFilterMin {
182		<div class="ffilter">
183			<label for="files-filter" class="sr">filter the changed files</label>
184			<input
185				id="files-filter"
186				type="search"
187				placeholder="Filter files…"
188				autocomplete="off"
189				data-pick=".files > li"
190				hx-on:input="ermite.filter(this)"
191			/>
192		</div>
193	}
194	<ol class="files" aria-label="Changed files">
195		for _, f := range files {
196			<li>
197				<a
198					href={ templ.SafeURL("#" + f.ID) }
199					title={ f.Path }
200					hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"
201				>
202					<span class="st" data-st={ f.Status } title={ statusName(f.Status) }>{ f.Status }</span>
203					<span class="nm"><span class="p">{ f.Name }</span>{ " " }<span class="d"><bdi>{ f.Dir }</bdi></span></span>
204					if f.OpenNotes > 0 {
205						<span class="cn" title={ plural(f.OpenNotes, "open comment", "open comments") }>{ itoa(f.OpenNotes) }</span>
206					} else if f.Notes > 0 {
207						<span class="cn done" title={ plural(f.Notes, "resolved comment", "resolved comments") }>{ itoa(f.Notes) }</span>
208					}
209					if f.Bin {
210						<span class="n mute">bin</span>
211					} else {
212						<span class="n"><span class="plus">+{ itoa(f.Add) }</span> <span class="minus">-{ itoa(f.Del) }</span></span>
213					}
214				</a>
215			</li>
216		}
217	</ol>
218}
219
220// DiffFileView is one file of the diff: a <details> (per-file collapse). The
221// unified and the split tables are both in it; #diffs[data-diff] shows one.
222// Every changed line keeps its +/- marker, so a change is never conveyed by
223// colour alone.
224templ DiffFileView(f DiffFile, p DiffView) {
225	<details
226		class={ "dfile", "lnw-" + itoa(f.Digits) }
227		id={ f.ID }
228		data-file={ f.Key }
229		if f.Open {
230			open
231		}
232	>
233		<summary>
234			<span class="st">{ f.Status }</span>{ " " }<span class="p">{ f.Path }</span>{ " " }<span class="plus">+{ itoa(f.Add) }</span>{ " " }<span class="minus">-{ itoa(f.Del) }</span>
235			if f.Note != "" {
236				{ " " }<span class="note">{ f.Note }</span>
237			}
238		</summary>
239		if f.Lazy {
240			// A placeholder: lazy.js fetches the window of the file when it comes
241			// near the screen and swaps the files of the window in. Without
242			// scripts the link opens the page of that window.
243			<div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
244				<a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
245			</div>
246		} else if f.Manual {
247			// A collapsed file (generated, large, over the page budget): no
248			// rows. "load diff" reads this one file and swaps it in place
249			// (lazy.js); without scripts the link reloads the page with the file.
250			<div class="dlazy" data-src={ f.Src } data-manual>
251				<a href={ templ.SafeURL(f.Href) }>load diff</a>
252			</div>
253		} else if len(f.Hunks) > 0 {
254			<div class="dscroll">
255				if p.ShowUnified {
256					<table class="dt uni" aria-label={ f.Path + ", unified" }>
257						for _, h := range f.Hunks {
258							<tbody>
259								if h.Gap != nil {
260									@gapRow(*h.Gap, 4)
261								}
262								<tr class="hunk"><td colspan="4">{ h.Header }</td></tr>
263								for _, ln := range h.Lines {
264									<tr class={ ln.Kind, templ.KV("sel", ln.Selected) }>
265										<td class="ln">@gutter(ln.Old, ln.OldHref)</td>
266										<td class="ln">@gutter(ln.New, ln.NewHref)</td>
267										<td class="m">{ ln.Mark }</td>
268										<td class="tx chroma">@codeLine(ln)</td>
269									</tr>
270									if len(ln.Notes) > 0 || ln.Form != nil {
271										<tr class="fnote">
272											<td colspan="3"></td>
273											<td>@lineNotes(ln.Notes, ln.Form, "")</td>
274										</tr>
275									}
276								}
277							</tbody>
278						}
279						if f.Tail != nil {
280							<tbody>
281								@gapRow(*f.Tail, 4)
282							</tbody>
283						}
284					</table>
285				}
286				if p.ShowSplit {
287					<table class="dt spl" aria-label={ f.Path + ", split" }>
288						<colgroup><col class="c-ln"/><col class="c-m"/><col/><col class="c-ln"/><col class="c-m"/><col/></colgroup>
289						for _, h := range f.Hunks {
290							<tbody>
291								if h.Gap != nil {
292									@gapRow(*h.Gap, 6)
293								}
294								<tr class="hunk"><td colspan="6">{ h.Header }</td></tr>
295								for _, row := range h.Split {
296									<tr>
297										@splitSide(row.L, true)
298										@splitSide(row.R, false)
299									</tr>
300									if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
301										<tr class="fnote">
302											<td colspan="6">@lineNotes(notes, form, "-s")</td>
303										</tr>
304									}
305								}
306							</tbody>
307						}
308						if f.Tail != nil {
309							<tbody>
310								@gapRow(*f.Tail, 6)
311							</tbody>
312						}
313					</table>
314				}
315			</div>
316		} else if f.Raw != "" {
317			<p class="note pad">{ f.Note } <a href={ templ.SafeURL(f.Raw) }>view raw</a></p>
318		} else {
319			<p class="note pad">
320				if f.Note != "" {
321					{ f.Note }
322				} else {
323					no textual change.
324				}
325			</p>
326		}
327		if len(f.Loose) > 0 {
328			<div class="dloose">
329				for _, c := range f.Loose {
330					@commentView(c, "")
331				}
332			</div>
333		}
334	</details>
335}
336
337// MRFiles is a window of files of a merge request, for the placeholders of
338// the page: the files only, which replace the placeholders of the same ids.
339templ MRFiles(p MRFilesPage) {
340	for _, f := range p.Files {
341		@DiffFileView(f, p.DiffView)
342	}
343}
344
345// MRFilesMoved answers a window of a diff that is not the one of the page any
346// more: the placeholder shows it instead of a file of another diff.
347templ MRFilesMoved() {
348	<p class="notice" data-moved>this merge request changed since the page was loaded — <a href="">reload</a></p>
349}
350
351// gapRow is the row of the hidden lines before a hunk, or of the rest of the
352// file: a button that loads them in their place (ermite.js, ermite.expand).
353// Without scripts nothing loads them: hidden.
354templ gapRow(g DiffGap, cols int) {
355	<tr class="gap" data-js data-gap={ g.Key }>
356		<td colspan={ itoa(cols) }>
357			<button type="button" data-src={ g.Src }>
358				<span aria-hidden="true">⋯</span> { g.Label() }
359			</button>
360		</td>
361	</tr>
362}
363
364// DiffContext is the hidden lines of a gap, for /context: one body a layout,
365// whose rows replace the rows of the gap in the table of that layout.
366templ DiffContext(c ContextRows) {
367	<table>
368		<tbody data-for="uni">
369			for _, ln := range c.Lines {
370				<tr class="ctx">
371					<td class="ln">{ ln.Old }</td>
372					<td class="ln">{ ln.New }</td>
373					<td class="m">{ ln.Mark }</td>
374					<td class="tx chroma">@codeLine(ln)</td>
375				</tr>
376			}
377			if c.More != nil {
378				@gapRow(*c.More, 4)
379			}
380		</tbody>
381		<tbody data-for="spl">
382			for i := range c.Lines {
383				<tr>
384					@splitSide(&c.Lines[i], true)
385					@splitSide(&c.Lines[i], false)
386				</tr>
387			}
388			if c.More != nil {
389				@gapRow(*c.More, 6)
390			}
391		</tbody>
392	</table>
393}
394
395// splitSide is one half of a row of the split view; a side without a line is
396// an empty cell.
397templ splitSide(ln *DiffLine, old bool) {
398	if ln == nil {
399		<td class="ln none"></td>
400		<td class="m none"></td>
401		<td class="tx none"></td>
402	} else {
403		<td class={ "ln", ln.Kind, templ.KV("sel", ln.Selected) }>
404			if old {
405				@gutter(ln.Old, ln.OldHref)
406			} else {
407				@gutter(ln.New, ln.NewHref)
408			}
409		</td>
410		<td class={ "m", ln.Kind, templ.KV("sel", ln.Selected) }>{ ln.Mark }</td>
411		<td class={ "tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected) }>@codeLine(*ln)</td>
412	}
413}
414
415// gutter is a line number of the gutter. On a merge request that can be
416// commented on, it links to the form that comments on that line.
417templ gutter(n, href string) {
418	if href != "" {
419		<a href={ templ.SafeURL(href) } title="comment on this line">{ n }</a>
420	} else {
421		{ n }
422	}
423}

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