v0.16.0 924eaca

mr.templ RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import "strings"
6
7// MRList is the list of the merge requests of a repository: the open ones,
8// then the merged and closed ones.
9templ MRList(l Layout, p MRListPage) {
10	@Shell(l, mrListRail(p)) {
11		if p.Live {
12			@LiveSlot("mr-notice")
13		}
14		<div class="filter">
15			<label for="mr-filter" class="sr">filter</label>
16			<input
17				id="mr-filter"
18				type="search"
19				data-filter=".mrs tbody tr"
20				placeholder="Filter by title, branch, author or state…"
21				autocomplete="off"
22				hx-on:input="ermite.filter(this)"
23			/>
24		</div>
25		if p.Newest == "" {
26			<h2 class="rule-h"><span>Open</span><span class="count">{ itoa(len(p.Open)) }</span></h2>
27			@mrRows(p.Open, "Open merge requests")
28			if len(p.Open) == 0 {
29				<p class="lede dim">no open merge requests.</p>
30			}
31			if p.OpenNote != "" {
32				<p class="note">{ p.OpenNote }</p>
33			}
34		}
35		<h2 class="rule-h"><span>Merged and closed</span><span class="count">{ itoa(len(p.Done)) }</span></h2>
36		@mrRows(p.Done, "Merged and closed merge requests")
37		if len(p.Done) == 0 {
38			<p class="lede dim">nothing merged or closed yet.</p>
39		}
40		if p.Older != "" || p.Newest != "" {
41			<nav class="pager" aria-label="Pages">
42				if p.Newest != "" {
43					<a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a>
44				} else {
45					<span class="btn" aria-disabled="true">← Newest</span>
46				}
47				if p.Older != "" {
48					<a class="btn" href={ templ.SafeURL(p.Older) } rel="next">Older →</a>
49				} else {
50					<span class="btn" aria-disabled="true">Older →</span>
51				}
52			</nav>
53		}
54	}
55}
56
57templ mrRows(rows []MRRow, label string) {
58	if len(rows) > 0 {
59		<table class="list mrs" aria-label={ label }>
60			<tbody data-rows>
61				for _, m := range rows {
62					<tr data-cursor?={ m.Cursor } data-state={ m.State }>
63						<td class="cur" aria-hidden="true"></td>
64						<td class="c-n mute">{ m.Number }</td>
65						<td class="c-state">{ m.State }</td>
66						<td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <span class="mute">{ m.Branches }</span></td>
67						<td class="c-who dim">{ m.Author }</td>
68						<td class="c-when nowrap dim">
69							if m.WhenAt != "" {
70								<time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
71							}
72						</td>
73					</tr>
74				}
75			</tbody>
76		</table>
77	}
78}
79
80templ mrListRail(p MRListPage) {
81	<section aria-labelledby="r-states">
82		<h2 id="r-states">States</h2>
83		<dl class="kv">
84			<dt>open</dt>
85			<dd>awaits review</dd>
86			<dt>merged</dt>
87			<dd>the target took the branch</dd>
88			<dt>closed</dt>
89			<dd>refused, branch kept</dd>
90		</dl>
91	</section>
92	<section aria-labelledby="r-new">
93		<h2 id="r-new">Open one</h2>
94		if len(p.Branches) > 0 {
95			<p class="dim">compare a branch with { p.Default }:</p>
96			<nav class="outline" aria-label="Branches">
97				for _, b := range p.Branches {
98					<a href={ templ.SafeURL(b.Href) }>{ b.Name }</a>
99				}
100			</nav>
101		} else {
102			<p class="dim">there is no other branch to merge yet.</p>
103		}
104		@CloneBlock(p.CLI)
105	</section>
106}
107
108// MergeRequest is a merge request: its facts, its description, the diff with
109// the notes under their lines, the general comments; and in the rail the merge
110// form, the checks, the settings, the commits and the files.
111templ MergeRequest(l Layout, p MRPage) {
112	@Shell(l, mrRail(p)) {
113		<header class="show mr-h">
114			<h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
115			<dl class="kv show-kv">
116				<dt>state</dt>
117				<dd class="mr-state" data-state={ p.State }>
118					{ p.State }
119					if p.MergedShort != "" {
120						{ " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
121					}
122				</dd>
123				<dt>branch</dt>
124				<dd>
125					@branchLink(p.Source, p.SourceHref)
126					{ " → " }
127					@branchLink(p.Target, p.TargetHref)
128				</dd>
129				<dt>opened</dt>
130				<dd>
131					{ p.Opened.Name }
132					if p.Opened.WhenAt != "" {
133						{ " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
134					}
135				</dd>
136				if p.UpdatedAt != "" {
137					<dt>updated</dt>
138					<dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
139				}
140			</dl>
141		</header>
142		@LiveSlot("mr-notice")
143		if len(p.Desc) > 0 {
144			<p class="mr-desc">@userText(p.Desc)</p>
145		}
146		if p.Since != nil {
147			<nav class="since" aria-label="Diff range">
148				<a
149					href={ templ.SafeURL(p.Since.AllHref) }
150					if !p.Since.Current {
151						aria-current="page"
152					}
153				>all changes <span class="mute">vs { p.Target }</span></a>
154				<a
155					href={ templ.SafeURL(p.Since.SinceHref) }
156					if p.Since.Current {
157						aria-current="page"
158					}
159				>since your last read <span class="mute">{ p.Since.ReadShort } → { p.Since.HeadShort }</span></a>
160			</nav>
161		}
162		if p.SinceNote != "" {
163			<p class="notice">{ p.SinceNote }</p>
164		}
165		<div class="diff-tools" role="toolbar" aria-label="Diff view">
166			<span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
167			<span class="grow"></span>
168			@viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
169			@viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
170			@collapseButton()
171		</div>
172		if p.Invisible {
173			<p class="notice">
174				this diff holds invisible characters (direction controls, zero-width characters). They are shown as
175				<span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
176			</p>
177		}
178		if p.Note != "" {
179			<p class="notice">{ p.Note }</p>
180		}
181		<div
182			id="diffs"
183			if p.Both {
184				data-diff={ p.View }
185			} else {
186				data-view={ p.View }
187			}
188			if p.Both && !p.Pinned {
189				hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
190			}
191		>
192			for _, f := range p.Files {
193				@DiffFileView(f, p.DiffView)
194			}
195			if len(p.Files) == 0 {
196				<p class="note">no changed files.</p>
197			}
198		</div>
199		<h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
200		for _, c := range p.Loose {
201			@commentView(c, "")
202		}
203		for _, c := range p.General {
204			@commentView(c, "")
205		}
206		if p.NComments == 0 {
207			<p class="dim">no comments yet.</p>
208		}
209		if p.GeneralForm != nil {
210			@commentForm(*p.GeneralForm, "")
211		}
212	}
213}
214
215templ branchLink(name, href string) {
216	if href != "" {
217		<a href={ templ.SafeURL(href) }>{ name }</a>
218	} else {
219		{ name }
220	}
221}
222
223// lineNotes are the comments under a line of the diff, and the form to write
224// one when the reader asked for it. The diff is in the page twice (unified and
225// split); the suffix keeps the ids unique, the unified table having none.
226templ lineNotes(notes []CommentView, form *CommentForm, suffix string) {
227	for _, c := range notes {
228		@commentView(c, suffix)
229	}
230	if form != nil {
231		@commentForm(*form, suffix)
232	}
233}
234
235// commentView is a note: a ruled block, under its line in the diff or in the
236// list of comments. The text is plain: escaped, its line breaks kept, its
237// http(s) addresses linked.
238templ commentView(c CommentView, suffix string) {
239	<div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) }>
240		<p class="c-head">
241			<span class="c-who">{ c.Author }</span>
242			if c.WhenAt != "" {
243				{ " " }<time class="mute" datetime={ c.WhenAt } title={ c.Abs }>{ c.When }</time>
244			}
245			if c.Resolved {
246				{ " " }<span class="c-state">resolved</span>
247			}
248			if c.Outdated {
249				{ " " }<span class="c-state" title="written on an earlier head; the line may have moved">outdated</span>
250			}
251			if c.Where != "" {
252				{ " " }<span class="c-where mute">{ c.Where }</span>
253			}
254		</p>
255		<p class="c-text">@userText(c.Parts)</p>
256		if c.Resolve != nil {
257			<form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
258				<input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
259				<button type="submit" class="btn">{ c.Resolve.Label }</button>
260			</form>
261		}
262	</div>
263}
264
265func boolText(b bool) string {
266	if b {
267		return "true"
268	}
269	return "false"
270}
271
272// commentForm writes a comment, general or on a line.
273templ commentForm(f CommentForm, suffix string) {
274	<form class="cform" method="post" action={ templ.SafeURL(f.Action) }>
275		<label for={ f.ID + suffix }>{ f.Title }</label>
276		<textarea
277			id={ f.ID + suffix }
278			name="text"
279			rows="3"
280			maxlength="8000"
281			required
282			placeholder="plain text, 8000 characters at most"
283			if f.Focus {
284				autofocus
285			}
286		></textarea>
287		if f.Path != "" {
288			<input type="hidden" name="path" value={ f.Path }/>
289			<input type="hidden" name="line" value={ f.Line }/>
290			<input type="hidden" name="side" value={ f.Side }/>
291		}
292		<div class="f-btns">
293			<button type="submit" class="btn primary">Comment</button>
294			if f.Cancel != "" {
295				<a class="btn" href={ templ.SafeURL(f.Cancel) }>Cancel</a>
296			}
297		</div>
298	</form>
299}
300
301templ mrRail(p MRPage) {
302	if p.Box != nil {
303		@mergeBox(p)
304	}
305	@MRChecks(p.Checks)
306	<section aria-labelledby="r-settings">
307		<h2 id="r-settings">Settings</h2>
308		<dl class="kv">
309			<dt>modes</dt>
310			<dd>{ strings.Join(p.Modes, ", ") }</dd>
311			<dt>checks</dt>
312			<dd>
313				if p.ChecksBlock {
314					failing ones block the merge
315				} else {
316					do not block the merge
317				}
318			</dd>
319		</dl>
320	</section>
321	<section aria-labelledby="r-commits">
322		<h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
323		<ul class="pcommits">
324			for _, c := range p.Commits {
325				<li data-new?={ c.New }>
326					<a href={ templ.SafeURL(c.Href) }>{ c.Short }</a>
327					<span class="s">{ c.Subject }</span>
328					<span class="mute">
329						{ c.Author }
330						if c.WhenAt != "" {
331							{ " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
332						}
333						if c.New {
334							{ " · new" }
335						}
336					</span>
337				</li>
338			}
339		</ul>
340		if len(p.Commits) == 0 {
341			<p class="dim">no commit to merge.</p>
342		}
343		if p.CommitsNote != "" {
344			<p class="note">{ p.CommitsNote }</p>
345		}
346	</section>
347	<section aria-labelledby="r-files">
348		<h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
349		@FileList(p.Files)
350	</section>
351}
352
353// mergeBox is the first block of the rail of an open request, for the owner:
354// the merge form (it carries the head that was read), the terminal lines, and
355// the other things one does with the request.
356templ mergeBox(p MRPage) {
357	<section class="decide" aria-labelledby="r-merge" data-state="open">
358		<h2 id="r-merge">Merge</h2>
359		<dl class="kv">
360			<dt>head</dt>
361			<dd>
362				if p.Box.HeadGone {
363					gone: the source branch was deleted
364				} else {
365					{ p.HeadShort }
366				}
367			</dd>
368			<dt>into</dt>
369			<dd>{ p.Target }</dd>
370		</dl>
371		if !p.Box.HeadGone {
372			<form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
373				<input type="hidden" name="head_id" value={ p.Box.Head }/>
374				<label class="sr" for="merge-mode">merge mode</label>
375				<select id="merge-mode" name="mode">
376					for _, m := range p.Box.Modes {
377						<option value={ m.Value }>{ m.Label }</option>
378					}
379				</select>
380				if p.Box.CanDeleteBranch {
381					<label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge</label>
382				}
383				<div class="d-btns">
384					<button type="submit" class="btn primary">Merge</button>
385				</div>
386			</form>
387		}
388		<div class="d-btns">
389			if p.Box.CanMarkRead {
390				<form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
391					<input type="hidden" name="head_id" value={ p.Box.Head }/>
392					<button type="submit" title="the next visit can show only what changed after this head" class="btn">Mark read</button>
393				</form>
394			}
395			<form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
396				<button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
397			</form>
398		</div>
399		if !p.Box.HeadGone {
400			<p class="mute d-or">or from a terminal</p>
401			@CloneBlock(p.Box.CLI)
402		}
403	</section>
404}
405
406// Compare is a branch against the default branch: the commits it is ahead by,
407// its diff when the API can give one, and the form that opens a merge request.
408templ Compare(l Layout, p ComparePage) {
409	@Shell(l, compareRail(p)) {
410		<header class="show mr-h">
411			<h1>{ p.Source } → { p.Target }</h1>
412			<dl class="kv show-kv">
413				<dt>branch</dt>
414				<dd><a href={ templ.SafeURL(p.SourceHref) }>{ p.Source }</a></dd>
415				<dt>target</dt>
416				<dd><a href={ templ.SafeURL(p.TargetHref) }>{ p.Target }</a></dd>
417				<dt>ahead</dt>
418				<dd>{ p.AheadText }</dd>
419			</dl>
420		</header>
421		if p.Note != "" {
422			<p class="notice">{ p.Note }</p>
423		}
424		if p.Existing != nil {
425			<p class="notice">an open merge request already covers these branches: <a href={ templ.SafeURL(p.Existing.Href) }>{ p.Existing.Label }</a></p>
426		}
427		if p.Create != nil {
428			<form class="cform create" method="post" action={ templ.SafeURL(p.Create.Action) }>
429				<input type="hidden" name="source" value={ p.Create.Source }/>
430				<input type="hidden" name="target" value={ p.Create.Target }/>
431				<label for="mr-title">title</label>
432				<input id="mr-title" name="title" type="text" maxlength="120" required value={ p.Create.Title } autocomplete="off"/>
433				<label for="mr-desc">description</label>
434				<textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="plain text, optional"></textarea>
435				<div class="f-btns">
436					<button type="submit" class="btn primary">Create merge request</button>
437				</div>
438			</form>
439		}
440		if p.HasDiff {
441			<div class="diff-tools" role="toolbar" aria-label="Diff view">
442				<span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
443				<span class="grow"></span>
444				@viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
445				@viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
446				@collapseButton()
447			</div>
448			if p.Invisible {
449				<p class="notice">
450					this diff holds invisible characters (direction controls, zero-width characters). They are shown as
451					<span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
452				</p>
453			}
454			<div
455				id="diffs"
456				if p.Both {
457					data-diff={ p.View }
458				} else {
459					data-view={ p.View }
460				}
461				if p.Both && !p.Pinned {
462					hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
463				}
464			>
465				for _, f := range p.Files {
466					@DiffFileView(f, p.DiffView)
467				}
468			</div>
469		}
470	}
471}
472
473templ compareRail(p ComparePage) {
474	<section aria-labelledby="r-ahead">
475		<h2 id="r-ahead">Commits <span class="count">{ itoa(len(p.Ahead)) }</span></h2>
476		<ul class="pcommits">
477			for _, c := range p.Ahead {
478				<li>
479					<a href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
480					<span class="s">{ c.Subject }</span>
481					<span class="mute">
482						{ c.Author }
483						if c.WhenAt != "" {
484							{ " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
485						}
486					</span>
487				</li>
488			}
489		</ul>
490		if len(p.Ahead) == 0 {
491			<p class="dim">no commit ahead of { p.Target }.</p>
492		}
493	</section>
494	if p.HasDiff {
495		<section aria-labelledby="r-files">
496			<h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
497			@FileList(p.Files)
498		</section>
499	}
500	<section aria-labelledby="r-cli">
501		<h2 id="r-cli">From a terminal</h2>
502		@CloneBlock(p.CLI)
503	</section>
504}

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