v0.5.0 bfab294

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

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