detached 388d3be

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="c-n mute">{ m.Number }</td>
64						<td class="c-state">{ m.State }</td>
65						<td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <span class="mute">{ m.Branches }</span></td>
66						<td class="c-who dim">{ m.Author }</td>
67						<td class="c-when nowrap dim">
68							if m.WhenAt != "" {
69								<time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
70							}
71						</td>
72					</tr>
73				}
74			</tbody>
75		</table>
76	}
77}
78
79templ mrListRail(p MRListPage) {
80	<section aria-labelledby="r-states">
81		<h2 id="r-states">States</h2>
82		<dl class="kv">
83			<dt>open</dt>
84			<dd>awaits review</dd>
85			<dt>merged</dt>
86			<dd>the target took the branch</dd>
87			<dt>closed</dt>
88			<dd>refused, branch kept</dd>
89		</dl>
90	</section>
91	<section aria-labelledby="r-new">
92		<h2 id="r-new">Open one</h2>
93		if len(p.Branches) > 0 {
94			<p class="dim">compare a branch with { p.Default }:</p>
95			<nav class="outline" aria-label="Branches">
96				for _, b := range p.Branches {
97					<a href={ templ.SafeURL(b.Href) }>{ b.Name }</a>
98				}
99			</nav>
100		} else {
101			<p class="dim">there is no other branch to merge yet.</p>
102		}
103		@CloneBlock(p.CLI)
104	</section>
105}
106
107// MergeRequest is a merge request: its facts, its description, the diff with
108// the notes under their lines, the general comments; and in the rail the merge
109// form, the checks, the settings, the commits and the files.
110templ MergeRequest(l Layout, p MRPage) {
111	@Shell(l, mrRail(p)) {
112		<section class="mr-card">
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			if p.Desc != "" {
143				<article class="mr-desc prose" aria-label="description" hx-ignore>
144					@templ.Raw(string(p.Desc))
145				</article>
146			}
147		</section>
148		@LiveSlot("mr-notice")
149		if p.Since != nil {
150			<nav class="since" aria-label="Diff range">
151				<a
152					href={ templ.SafeURL(p.Since.AllHref) }
153					if !p.Since.Current {
154						aria-current="page"
155					}
156				>all changes <span class="mute">vs { p.Target }</span></a>
157				<a
158					href={ templ.SafeURL(p.Since.SinceHref) }
159					if p.Since.Current {
160						aria-current="page"
161					}
162				>since your last read <span class="mute">{ p.Since.ReadShort } → { p.Since.HeadShort }</span></a>
163			</nav>
164		}
165		if p.SinceNote != "" {
166			<p class="notice">{ p.SinceNote }</p>
167		}
168		@mrBar(p)
169		if p.Invisible {
170			<p class="notice">
171				this diff holds invisible characters (direction controls, zero-width characters). They are shown as
172				<span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
173			</p>
174		}
175		if p.Note != "" {
176			<p class="notice">{ p.Note }</p>
177		}
178		<div
179			id="diffs"
180			data-mr
181			if p.Both {
182				data-diff={ p.View }
183			} else {
184				data-view={ p.View }
185			}
186			if p.Both && !p.Pinned {
187				hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
188			}
189		>
190			for _, f := range p.Files {
191				@DiffFileView(f, p.DiffView)
192			}
193			if len(p.Files) == 0 {
194				<p class="note">no changed files.</p>
195			}
196		</div>
197		<h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
198		for _, c := range p.Loose {
199			@commentView(c, "")
200		}
201		for _, c := range p.General {
202			@commentView(c, "")
203		}
204		if p.NComments == 0 {
205			<p class="dim">no comments yet.</p>
206		}
207		if p.GeneralForm != nil {
208			@commentForm(*p.GeneralForm, "")
209		}
210	}
211}
212
213templ branchLink(name, href string) {
214	if href != "" {
215		<a href={ templ.SafeURL(href) }>{ name }</a>
216	} else {
217		{ name }
218	}
219}
220
221// lineNotes are the comments under a line of the diff, and the form to write
222// one when the reader asked for it. The diff is in the page twice (unified and
223// split); the suffix keeps the ids unique, the unified table having none.
224templ lineNotes(notes []CommentView, form *CommentForm, suffix string) {
225	for _, c := range notes {
226		@commentView(c, suffix)
227	}
228	if form != nil {
229		@commentForm(*form, suffix)
230	}
231}
232
233// commentView is a note: a ruled block, under its line in the diff or in the
234// list of comments. Body comes only from the safe Markdown renderer.
235templ commentView(c CommentView, suffix string) {
236	<div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) } data-thread?={ c.Reply != nil }>
237		<p class="c-head">
238			<span class="c-who">{ c.Author }</span>
239			if c.WhenAt != "" {
240				{ " " }<time class="mute" datetime={ c.WhenAt } title={ c.Abs }>{ c.When }</time>
241			}
242			if c.Resolved {
243				{ " " }<span class="c-state">resolved</span>
244			}
245			if c.Outdated {
246				{ " " }<span class="c-state" title="written on an earlier head; the line may have moved">outdated</span>
247			}
248			if c.Where != "" {
249				{ " " }<span class="c-where mute">{ c.Where }</span>
250			}
251		</p>
252		if len(c.Parts) > 0 {
253			<p class="c-text">@userText(c.Parts)</p>
254		} else if c.Deleted {
255			if c.Verdict != "" {
256				<p class="mute">text deleted</p>
257			} else {
258				<p class="mute">deleted note</p>
259			}
260		} else {
261			<div class="c-body prose">
262				if suffix == "-s" {
263					@templ.Raw(string(c.BodySplit))
264				} else {
265					@templ.Raw(string(c.Body))
266				}
267			</div>
268		}
269		if c.Edited {
270			<span class="mute">edited</span>
271		}
272		if c.Verdict != "" {
273			<span class="c-state">{ c.Verdict }</span>
274		}
275		if c.Range != "" {
276			<span class="mute">{ c.Range }</span>
277		}
278		if c.Quote != "" {
279			<button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
280		}
281		if c.Edit != nil {
282			<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>
283		}
284		if c.Delete != nil {
285			<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>
286		}
287		for _, reply := range c.Replies {
288			@commentView(reply, suffix)
289		}
290		if c.Reply != nil {
291			<details data-reply>
292				<summary>Reply</summary>@commentForm(*c.Reply, suffix)
293			</details>
294		}
295		if c.Resolve != nil {
296			<form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
297				<input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
298				if c.Resolve.From > 0 {
299					<input type="hidden" name="from" value={ itoa(c.Resolve.From) }/>
300				}
301				<button type="submit" class="btn">{ c.Resolve.Label }</button>
302			</form>
303		}
304	</div>
305}
306
307func boolText(b bool) string {
308	if b {
309		return "true"
310	}
311	return "false"
312}
313
314// commentForm writes a comment, general or on a line.
315templ commentForm(f CommentForm, suffix string) {
316	<form class="cform" method="post" action={ templ.SafeURL(f.Action) }>
317		<label for={ f.ID + suffix }>{ f.Title }</label>
318		<textarea
319			id={ f.ID + suffix }
320			name="text"
321			rows="3"
322			maxlength="8000"
323			if f.Plain {
324				required
325				placeholder="plain text, 8000 characters at most"
326			} else {
327				placeholder="Markdown, 8000 characters at most"
328			}
329			if f.Focus {
330				autofocus
331			}
332		></textarea>
333		if f.Path != "" {
334			<input type="hidden" name="path" value={ f.Path }/>
335			<input type="hidden" name="line" value={ f.Line }/>
336			<input type="hidden" name="side" value={ f.Side }/>
337			<label>from line <input type="number" name="start_line" min="1" value={ f.StartLine }/></label>
338		}
339		if f.ReplyTo != "" {
340			<input type="hidden" name="reply_to" value={ f.ReplyTo }/>
341		}
342		if f.Path == "" && f.ReplyTo == "" && !f.Plain {
343			<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>
344		}
345		if f.From > 0 {
346			<input type="hidden" name="from" value={ itoa(f.From) }/>
347		}
348		<div class="f-btns">
349			<button type="submit" class="btn primary">Comment</button>
350			if f.Cancel != "" {
351				<a class="btn" href={ templ.SafeURL(f.Cancel) }>Cancel</a>
352			}
353		</div>
354	</form>
355}
356
357templ mrRail(p MRPage) {
358	<section aria-labelledby="r-files" class="r-files">
359		<h2 id="r-files">
360			Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd>
361			<button type="button" class="btn icon fold" data-js aria-expanded="true" title="fold the list of files" hx-on:click="ermiteMR.foldFiles()">
362				<span class="ico i-chevron" aria-hidden="true"></span><span class="sr">fold the list of files</span>
363			</button>
364		</h2>
365		@FileList(p.Files)
366	</section>
367	if len(p.Verdicts) > 0 {
368		<section>
369			<h2>Verdicts</h2>
370			<ul>
371				for _, v := range p.Verdicts {
372					<li>
373						<a href={ templ.SafeURL(v.Href) }>{ v.Author }: { v.Label }</a>
374						if v.Stale {
375							<span class="mute">(stale)</span>
376						}
377					</li>
378				}
379			</ul>
380		</section>
381	}
382	@MRChecks(p.Checks)
383	<section aria-labelledby="r-commits">
384		<h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
385		<ul class="pcommits">
386			for _, c := range p.Commits {
387				<li data-new?={ c.New }>
388					<a href={ templ.SafeURL(c.Href) }>{ c.Short }</a>
389					<span class="s">{ c.Subject }</span>
390					if c.Sig != nil {
391						@signaturePill(c.Sig)
392					}
393					<span class="mute">
394						{ c.Author }
395						if c.WhenAt != "" {
396							{ " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
397						}
398						if c.New {
399							{ " · new" }
400						}
401					</span>
402				</li>
403			}
404		</ul>
405		if len(p.Commits) == 0 {
406			<p class="dim">no commit to merge.</p>
407		}
408		if p.CommitsNote != "" {
409			<p class="note">{ p.CommitsNote }</p>
410		}
411	</section>
412	<section aria-labelledby="r-settings">
413		<h2 id="r-settings">Settings</h2>
414		<dl class="kv">
415			<dt>modes</dt>
416			<dd>{ strings.Join(p.Modes, ", ") }</dd>
417			<dt>checks</dt>
418			<dd>
419				if p.ChecksBlock {
420					failing ones block the merge
421				} else {
422					do not block the merge
423				}
424			</dd>
425		</dl>
426	</section>
427}
428
429// mrBar is the bar above the diff, which follows the review: the totals, the
430// layout of the diff, the state of the checks and, for the owner of an open
431// request, the merge menu and the other actions. Each menu is a <details>: it
432// works without scripts; with them, M and the keys of the menu drive it.
433templ mrBar(p MRPage) {
434	<div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
435		<span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
436		@diffViewMenu(p.DiffView)
437		<span class="grow"></span>
438		@MRChecksSum(p.ChecksSum)
439		if p.Box != nil {
440			@mergeMenu(p)
441			@moreMenu(p)
442		}
443	</div>
444}
445
446// mergeMenu is the merge of an open request, for the owner: the form (it
447// carries the head that was read), or "Ready for review" for a draft. The
448// mode and the branch deletion have keys (1-3, D) while it is open.
449templ mergeMenu(p MRPage) {
450	<details class="menu mmenu" id="merge-menu" data-state="open">
451		<summary class="btn primary" aria-keyshortcuts="Shift+M">
452			if p.Box.ReadyAction != "" {
453				Draft
454			} else {
455				Merge
456			}
457			<kbd aria-hidden="true">M</kbd>
458		</summary>
459		<div class="menu-pop" role="group" aria-label="Merge">
460			<dl class="kv">
461				<dt>head</dt>
462				<dd>
463					if p.Box.HeadGone {
464						gone: the source branch was deleted
465					} else {
466						{ p.HeadShort }
467					}
468				</dd>
469				<dt>into</dt>
470				<dd>{ p.Target }</dd>
471			</dl>
472			if p.Box.ReadyAction != "" {
473				<p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
474				<form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
475					<button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>
476				</form>
477			} else if !p.Box.HeadGone {
478				<form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
479					<input type="hidden" name="head_id" value={ p.Box.Head }/>
480					<fieldset class="d-modes">
481						<legend class="sr">merge mode</legend>
482						for i, m := range p.Box.Modes {
483							<label>
484								<input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/>
485								{ m.Label }
486								if i < 9 {
487									<kbd aria-hidden="true">{ itoa(i + 1) }</kbd>
488								}
489							</label>
490						}
491					</fieldset>
492					if p.Box.CanDeleteBranch {
493						<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>
494					}
495					<div class="d-btns">
496						<button type="submit" class="btn primary" data-default>Merge into { p.Target }</button>
497					</div>
498				</form>
499			}
500		</div>
501	</details>
502}
503
504// moreMenu is what else the owner does with an open request: mark the head
505// read, close it, merge it from a terminal.
506templ moreMenu(p MRPage) {
507	<details class="menu omenu">
508		<summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>
509		<div class="menu-pop" role="group" aria-label="More actions">
510			if p.Box.CanMarkRead {
511				<form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
512					<input type="hidden" name="head_id" value={ p.Box.Head }/>
513					<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>
514				</form>
515			}
516			<form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
517				<button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
518			</form>
519			if !p.Box.HeadGone && p.Box.ReadyAction == "" {
520				<details class="d-or">
521					<summary class="mute">or from a terminal</summary>
522					@CloneBlock(p.Box.CLI)
523				</details>
524			}
525		</div>
526	</details>
527}
528
529templ Compare(l Layout, p ComparePage) {
530	@Shell(l, compareRail(p)) {
531		<header class="show mr-h">
532			<h1>{ p.Source } → { p.Target }</h1>
533			<dl class="kv show-kv">
534				<dt>branch</dt>
535				<dd><a href={ templ.SafeURL(p.SourceHref) }>{ p.Source }</a></dd>
536				<dt>target</dt>
537				<dd><a href={ templ.SafeURL(p.TargetHref) }>{ p.Target }</a></dd>
538				<dt>ahead</dt>
539				<dd>{ p.AheadText }</dd>
540			</dl>
541		</header>
542		if p.Note != "" {
543			<p class="notice">{ p.Note }</p>
544		}
545		if p.Existing != nil {
546			<p class="notice">an open merge request already covers these branches: <a href={ templ.SafeURL(p.Existing.Href) }>{ p.Existing.Label }</a></p>
547		}
548		if p.Create != nil {
549			<form class="cform create" method="post" action={ templ.SafeURL(p.Create.Action) }>
550				<input type="hidden" name="source" value={ p.Create.Source }/>
551				<input type="hidden" name="target" value={ p.Create.Target }/>
552				<label for="mr-title">title</label>
553				<input id="mr-title" name="title" type="text" maxlength="120" required value={ p.Create.Title } autocomplete="off"/>
554				<label for="mr-desc">description</label>
555				<textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="Markdown, optional"></textarea>
556				<div class="f-btns">
557					<button type="submit" class="btn primary">Create merge request</button>
558				</div>
559			</form>
560		}
561		if p.HasDiff {
562			<div class="diff-tools" role="group" aria-label="Diff view">
563				<span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
564				<span class="grow"></span>
565				@diffViewMenu(p.DiffView)
566			</div>
567			if p.Invisible {
568				<p class="notice">
569					this diff holds invisible characters (direction controls, zero-width characters). They are shown as
570					<span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
571				</p>
572			}
573			<div
574				id="diffs"
575				if p.Both {
576					data-diff={ p.View }
577				} else {
578					data-view={ p.View }
579				}
580				if p.Both && !p.Pinned {
581					hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
582				}
583			>
584				for _, f := range p.Files {
585					@DiffFileView(f, p.DiffView)
586				}
587			</div>
588		}
589	}
590}
591
592templ compareRail(p ComparePage) {
593	<section aria-labelledby="r-ahead">
594		<h2 id="r-ahead">Commits <span class="count">{ itoa(len(p.Ahead)) }</span></h2>
595		<ul class="pcommits">
596			for _, c := range p.Ahead {
597				<li>
598					<a href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
599					<span class="s">{ c.Subject }</span>
600					<span class="mute">
601						{ c.Author }
602						if c.WhenAt != "" {
603							{ " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
604						}
605					</span>
606				</li>
607			}
608		</ul>
609		if len(p.Ahead) == 0 {
610			<p class="dim">no commit ahead of { p.Target }.</p>
611		}
612	</section>
613	if p.HasDiff {
614		<section aria-labelledby="r-files">
615			<h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
616			@FileList(p.Files)
617		</section>
618	}
619	<section aria-labelledby="r-cli">
620		<h2 id="r-cli">From a terminal</h2>
621		@CloneBlock(p.CLI)
622	</section>
623}

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