main 602baa7

replies.go RawHistoryFolder

1package main
2
3import (
4	"fmt"
5	"path/filepath"
6	"sort"
7	"strconv"
8	"strings"
9)
10
11// member is one wire member of a decoded container (reply or variable struct
12// view). Static is the offset from the container's byte 0, or -1 when it
13// follows a variable-length list and must be recomputed from earlier members.
14type member struct {
15	XName, Method, Kind, GoType string
16	Elem                        string // annotated element Go type for list views
17	Type                        *Type
18	Size, Align, Static         int
19	Count                       *Node
20	ListKind                    string // bytes, scalar, struct, iter
21}
22
23// container is the lowered layout shared by replies and variable struct views.
24type container struct {
25	Name, Recv string
26	Members    []member
27	Fixed      int // minimum byte length (static prefix)
28	Dynamic    int // index of the first variable list, -1 when none
29	Reply      bool
30	Module     *Module
31}
32
33const replyLifetime = "It borrows the reply bytes: valid only until the Handler callback returns, because the read ring is reused. Copy what you keep."
34
35func isByteElem(xml string) bool {
36	return xml == "void" || xml == "BYTE" || xml == "CARD8" || xml == "char"
37}
38
39func annotatedType(m *Module, n *Node, typ *Type) (string, error) {
40	goType := qualified(m, typ.Module, typ.GoType)
41	for _, attr := range []string{"enum", "mask"} {
42		if name := n.Attr[attr]; name != "" {
43			e, owner := findEnum(m, name)
44			if e == nil {
45				return "", n.fail("unknown %s %s", attr, name)
46			}
47			goType = qualified(m, owner, e.Name)
48		}
49	}
50	return goType, nil
51}
52
53// elemName names the per-package list view for an element type.
54func elemName(goType string) string {
55	if i := strings.LastIndex(goType, "."); i >= 0 {
56		goType = goType[i+1:]
57	}
58	return strings.ToUpper(goType[:1]) + goType[1:]
59}
60
61// layout selects where a container's XML members land on the wire.
62type layout int
63
64const (
65	layoutStruct     layout = iota // members from byte 0
66	layoutReply                    // byte 1, then byte 8 (after seq and length)
67	layoutEvent                    // byte 1, then byte 4 (after seq)
68	layoutEventNoSeq               // contiguous from byte 1
69	layoutXGE                      // from byte 10 (after extension, seq, length, evtype)
70)
71
72// headerJump returns the offset following the header data byte, or 0 when the
73// layout has no data byte.
74func (l layout) headerJump() int {
75	switch l {
76	case layoutReply:
77		return 8
78	case layoutEvent:
79		return 4
80	}
81	return 0
82}
83
84// buildContainer lowers XML members. Replies and events start at byte 1 (the
85// first XML member occupies the header data byte) and continue after the
86// sequence (and, for replies, length) header.
87func buildContainer(m *Module, name, recv string, nodes []*Node, lay layout, reserved string) (*container, error) {
88	reply := lay == layoutReply
89	c := &container{Name: name, Recv: recv, Reply: reply, Dynamic: -1, Module: m}
90	off := 0
91	switch lay {
92	case layoutReply, layoutEvent, layoutEventNoSeq:
93		off = 1
94	case layoutXGE:
95		off = 10
96	}
97	what := map[layout]string{layoutStruct: "struct", layoutReply: "reply", layoutEvent: "event", layoutEventNoSeq: "event", layoutXGE: "event"}[lay]
98	first := true
99	methods := map[string]bool{}
100	for _, n := range nodes {
101		if n.Kind == "doc" {
102			continue
103		}
104		mem := member{XName: n.Attr["name"], Kind: n.Kind, Static: off}
105		if c.Dynamic >= 0 {
106			mem.Static = -1
107		}
108		switch n.Kind {
109		case "pad":
110			if a := n.Attr["align"]; a != "" {
111				mem.Align, _ = strconv.Atoi(a)
112				if c.Dynamic < 0 {
113					mem.Size = (-off) & (mem.Align - 1)
114				}
115			} else {
116				mem.Size, _ = strconv.Atoi(n.Attr["bytes"])
117			}
118		case "field", "list":
119			typ, err := lookup(m, n.Attr["type"])
120			if err != nil {
121				return nil, n.fail("%v", err)
122			}
123			mem.Type = typ
124			mem.Method = goName(mem.XName)
125			if contains(reserved, mem.Method) {
126				mem.Method += "_"
127			}
128			if methods[mem.Method] {
129				return nil, n.fail("duplicate getter %s", mem.Method)
130			}
131			methods[mem.Method] = true
132			elem, err := annotatedType(m, n, typ)
133			if err != nil {
134				return nil, err
135			}
136			mem.Elem = elem
137			if n.Kind == "field" {
138				if typ.Variable {
139					return nil, n.fail("variable-size field outside a list is unsupported")
140				}
141				mem.GoType = elem
142				mem.Size = typ.Size
143				break
144			}
145			if len(n.Children) != 1 {
146				return nil, n.fail("reply-side list requires a length expression")
147			}
148			mem.Count = n.Children[0]
149			switch {
150			case isByteElem(n.Attr["type"]):
151				mem.ListKind, mem.GoType = "bytes", "[]byte"
152			case typ.Variable:
153				if typ.Size == 0 {
154					return nil, n.fail("variable struct %s has an empty fixed prefix", typ.Name)
155				}
156				mem.ListKind, mem.GoType = "iter", qualified(m, typ.Module, typ.GoType+"Iter")
157			case isScalar(typ):
158				mem.ListKind, mem.GoType = "scalar", elemName(elem)+"List"
159			default:
160				mem.ListKind, mem.GoType = "struct", elemName(elem)+"List"
161			}
162			constant := mem.Count.Kind == "value" && !typ.Variable
163			if constant {
164				count, _ := strconv.Atoi(mem.Count.Text)
165				mem.Size = count * typ.Size
166			} else if c.Dynamic < 0 {
167				c.Dynamic = len(c.Members)
168			}
169		default:
170			return nil, n.fail("unsupported %s member %s", what, n.Kind)
171		}
172		if jump := lay.headerJump(); jump > 0 && first {
173			if mem.Size != 1 {
174				return nil, n.fail("first %s member must occupy the header data byte", what)
175			}
176			off = jump
177			first = false
178			c.Members = append(c.Members, mem)
179			continue
180		}
181		first = false
182		if c.Dynamic < 0 {
183			off += mem.Size
184		}
185		c.Members = append(c.Members, mem)
186	}
187	if c.Dynamic >= 0 {
188		c.Fixed = c.Members[c.Dynamic].Static
189	} else {
190		c.Fixed = off
191	}
192	return c, nil
193}
194
195// ref renders a field reference as a getter call on the receiver. Replies may
196// refer to the implicit header length word.
197func (c *container) ref(s string) string {
198	for _, mem := range c.Members {
199		if mem.Kind == "field" && mem.XName == s {
200			return "int(" + c.Recv + "." + mem.Method + "())"
201		}
202	}
203	if c.Reply && s == "length" {
204		return "int(binary.LittleEndian.Uint32(" + c.Recv + ".raw[4:]))"
205	}
206	return "<unresolved " + s + ">"
207}
208
209func (c *container) count(mem member) (string, error) {
210	s, err := renderExpr(mem.Count, c.ref, c.Module)
211	if err != nil {
212		return "", err
213	}
214	if strings.Contains(s, "<unresolved") {
215		return "", mem.Count.fail("list length references an unknown field")
216	}
217	return s, nil
218}
219
220// advance emits code moving off past member i. With fail != "" every step is
221// bounds-checked against raw.
222func (c *container) advance(b *strings.Builder, i int, fail string) error {
223	mem := c.Members[i]
224	raw := c.Recv + ".raw"
225	switch mem.Kind {
226	case "pad":
227		if mem.Align > 0 {
228			fmt.Fprintf(b, "off=(off+%d)&^%d;", mem.Align-1, mem.Align-1)
229		} else {
230			fmt.Fprintf(b, "off+=%d;", mem.Size)
231		}
232	case "field":
233		fmt.Fprintf(b, "off+=%d;", mem.Size)
234	case "list":
235		n, err := c.count(mem)
236		if err != nil {
237			return err
238		}
239		if mem.ListKind == "iter" {
240			fmt.Fprintf(b, "{ n:=%s;", n)
241			if fail != "" {
242				fmt.Fprintf(b, "if off>len(%s) || n<0 || n>len(%s) { %s };", raw, raw, fail)
243			}
244			fmt.Fprintf(b, "it:=%s(%s[off:],n); for it.Next() { off+=it.Value().Size() };", qualified(c.Module, mem.Type.Module, "New"+mem.Type.GoType+"Iter"), raw)
245			if fail != "" {
246				fmt.Fprintf(b, "if it.Err() { %s };", fail)
247			}
248			b.WriteString("};")
249			return nil
250		}
251		fmt.Fprintf(b, "{ n:=%s;", n)
252		if fail != "" {
253			fmt.Fprintf(b, "if n<0 || n>len(%s) { %s };", raw, fail)
254		}
255		fmt.Fprintf(b, "off+=n*%d };", mem.Type.Size)
256		if fail != "" {
257			fmt.Fprintf(b, "if off>len(%s) { %s };", raw, fail)
258		}
259	}
260	return nil
261}
262
263// offset emits statements leaving member i's offset in off.
264func (c *container) offset(b *strings.Builder, i int) error {
265	if c.Members[i].Static >= 0 {
266		fmt.Fprintf(b, "off:=%d;", c.Members[i].Static)
267		return nil
268	}
269	fmt.Fprintf(b, "off:=%d;", c.Fixed)
270	for j := c.Dynamic; j < i; j++ {
271		if err := c.advance(b, j, ""); err != nil {
272			return err
273		}
274	}
275	return nil
276}
277
278// validate emits the full-length walk used by Parse and Iter.Next.
279func (c *container) validate(b *strings.Builder, fail string) error {
280	if c.Dynamic < 0 {
281		return nil
282	}
283	fmt.Fprintf(b, "off:=%d;", c.Fixed)
284	for j := c.Dynamic; j < len(c.Members); j++ {
285		if err := c.advance(b, j, fail); err != nil {
286			return err
287		}
288	}
289	fmt.Fprintf(b, "if off>len(%s.raw) { %s };", c.Recv, fail)
290	return nil
291}
292
293// getters emits one method per field and list.
294func (c *container) getters(b *strings.Builder, typ string, lists map[string]string) error {
295	r := c.Recv
296	for i, mem := range c.Members {
297		if mem.Kind == "pad" {
298			continue
299		}
300		fmt.Fprintf(b, "func (%s %s) %s() %s {", r, typ, mem.Method, mem.GoType)
301		static := mem.Static >= 0
302		at := fmt.Sprintf("%s.raw[%d:]", r, mem.Static)
303		if !static {
304			if err := c.offset(b, i); err != nil {
305				return err
306			}
307			at = r + ".raw[off:]"
308		}
309		if mem.Kind == "field" {
310			if isScalar(mem.Type) {
311				fmt.Fprintf(b, "return %s };\n", getScalar(mem.Type, mem.GoType, at))
312			} else {
313				fmt.Fprintf(b, "return %s(%s) };\n", qualified(c.Module, mem.Type.Module, "Get"+mem.Type.GoType), at)
314			}
315			continue
316		}
317		if static {
318			fmt.Fprintf(b, "off:=%d;", mem.Static)
319		}
320		n, err := c.count(mem)
321		if err != nil {
322			return err
323		}
324		switch mem.ListKind {
325		case "iter":
326			fmt.Fprintf(b, "return %s(%s.raw[off:],%s) };\n", qualified(c.Module, mem.Type.Module, "New"+mem.Type.GoType+"Iter"), r, n)
327		case "bytes":
328			fmt.Fprintf(b, "end:=off+%s; return %s.raw[off:end:end] };\n", n, r)
329		default:
330			fmt.Fprintf(b, "end:=off+(%s)*%d; return %s{b:%s.raw[off:end:end]} };\n", n, mem.Type.Size, mem.GoType, r)
331			lists[mem.GoType] = listDecl(c.Module, mem.GoType, mem.Elem, mem.Type)
332		}
333	}
334	return nil
335}
336
337func listDecl(m *Module, name, goType string, elem *Type) string {
338	var b strings.Builder
339	fmt.Fprintf(&b, "// %s is a borrowed view over a wire list of %s. %s\n", name, goType, "It shares the lifetime of the bytes it was taken from; copy what you keep.")
340	fmt.Fprintf(&b, "type %s struct { b []byte };\n", name)
341	fmt.Fprintf(&b, "func (l %s) Len() int { return len(l.b)/%d };\n", name, elem.Size)
342	src := fmt.Sprintf("l.b[i*%d:]", elem.Size)
343	if isScalar(elem) {
344		fmt.Fprintf(&b, "func (l %s) At(i int) %s { return %s };\n", name, goType, getScalar(elem, goType, src))
345	} else {
346		fmt.Fprintf(&b, "func (l %s) At(i int) %s { return %s(%s) };\n", name, goType, qualified(m, elem.Module, "Get"+elem.GoType), src)
347	}
348	return b.String()
349}
350
351type replyIR struct {
352	Req string
353	C   *container
354}
355
356func resolveReplies(m *Module) ([]replyIR, []*container, error) {
357	var replies []replyIR
358	for _, n := range m.Root.Children {
359		if n.Kind != "request" {
360			continue
361		}
362		for _, r := range n.Children {
363			if r.Kind != "reply" {
364				continue
365			}
366			name := goName(n.Attr["name"])
367			for _, s := range []string{name + "Reply", "Parse" + name + "Reply"} {
368				if err := m.claim(s, r); err != nil {
369					return nil, nil, err
370				}
371			}
372			c, err := buildContainer(m, name+"Reply", "r", r.Children, layoutReply, "Raw")
373			if err != nil {
374				return nil, nil, err
375			}
376			replies = append(replies, replyIR{Req: name, C: c})
377		}
378	}
379	var views []*container
380	for _, t := range m.Types {
381		if t.Kind != "struct" || !t.Variable {
382			continue
383		}
384		for _, s := range []string{t.GoType + "View", t.GoType + "Iter", "New" + t.GoType + "Iter"} {
385			if err := m.claim(s, t.Node); err != nil {
386				return nil, nil, err
387			}
388		}
389		c, err := buildContainer(m, t.GoType+"View", "v", t.Node.Children, layoutStruct, "Raw Size")
390		if err != nil {
391			return nil, nil, err
392		}
393		views = append(views, c)
394	}
395	return replies, views, nil
396}
397
398func replyDecl(r replyIR, lists map[string]string) (string, error) {
399	c := r.C
400	var b strings.Builder
401	fmt.Fprintf(&b, "// %s is a zero-copy view over a %s reply; offsets count from reply byte 0.\n// %s\n", c.Name, r.Req, replyLifetime)
402	fmt.Fprintf(&b, "type %s struct { raw []byte };\n", c.Name)
403	fmt.Fprintf(&b, "// Parse%s validates raw (the whole reply, 32+4*length bytes) once; getters do not re-check.\n", c.Name)
404	fmt.Fprintf(&b, "func Parse%s(raw []byte) (%s, bool) {", c.Name, c.Name)
405	fixed := max(c.Fixed, 32)
406	fmt.Fprintf(&b, "if len(raw)<%d { return %s{}, false };", fixed, c.Name)
407	fmt.Fprintf(&b, "r:=%s{raw:raw};", c.Name)
408	if err := c.validate(&b, "return "+c.Name+"{}, false"); err != nil {
409		return "", err
410	}
411	b.WriteString("return r, true };\n")
412	fmt.Fprintf(&b, "func (r %s) Raw() []byte { return r.raw };\n", c.Name)
413	if err := c.getters(&b, c.Name, lists); err != nil {
414		return "", err
415	}
416	return b.String(), nil
417}
418
419func viewDecl(c *container, lists map[string]string) (string, error) {
420	base := strings.TrimSuffix(c.Name, "View")
421	iter := base + "Iter"
422	var b strings.Builder
423	fmt.Fprintf(&b, "// %s is a zero-copy view over one wire-encoded %s, yielded by %s.\n// It shares the lifetime of the bytes it was taken from (for replies: until the Handler callback returns). Copy what you keep.\n", c.Name, base, iter)
424	fmt.Fprintf(&b, "type %s struct { raw []byte };\n", c.Name)
425	fmt.Fprintf(&b, "func (v %s) Raw() []byte { return v.raw };\n", c.Name)
426	fmt.Fprintf(&b, "// Size is the element's wire size, including trailing alignment.\nfunc (v %s) Size() int { return len(v.raw) };\n", c.Name)
427	// size%s returns the element's wire size at the start of p, -1 when malformed.
428	fmt.Fprintf(&b, "func size%s(p []byte) int { if len(p)<%d { return -1 }; v:=%s{raw:p}; _=v;", base, c.Fixed, c.Name)
429	if c.Dynamic < 0 {
430		fmt.Fprintf(&b, "return %d };\n", c.Fixed)
431	} else {
432		if err := c.validate(&b, "return -1"); err != nil {
433			return "", err
434		}
435		b.WriteString("return off };\n")
436	}
437	if err := c.getters(&b, c.Name, lists); err != nil {
438		return "", err
439	}
440	fmt.Fprintf(&b, "// %s walks n consecutive %s elements; Err reports a malformed length.\n// It shares the lifetime of the bytes it walks.\n", iter, base)
441	fmt.Fprintf(&b, "type %s struct { b []byte; n int; v %s; err bool };\n", iter, c.Name)
442	fmt.Fprintf(&b, "// New%s iterates n elements encoded at the start of b.\nfunc New%s(b []byte, n int) %s { return %s{b:b, n:n} };\n", iter, iter, iter, iter)
443	fmt.Fprintf(&b, "func (it *%s) Next() bool { if it.n<=0 || it.err { return false }; s:=size%s(it.b); if s<0 { it.err=true; it.n=0; return false }; it.v=%s{raw:it.b[:s:s]}; it.b=it.b[s:]; it.n--; return true };\n", iter, base, c.Name)
444	fmt.Fprintf(&b, "func (it *%s) Value() %s { return it.v };\n", iter, c.Name)
445	fmt.Fprintf(&b, "func (it *%s) Err() bool { return it.err };\n", iter)
446	return b.String(), nil
447}
448
449// touch emits fuzz statements calling every getter and walking every list,
450// recursing into variable struct views.
451func touch(b *strings.Builder, c *container, expr string, depth int, all map[*Type]*container, failIter string) {
452	for _, mem := range c.Members {
453		switch {
454		case mem.Kind == "field":
455			fmt.Fprintf(b, "_ = %s.%s();", expr, mem.Method)
456		case mem.Kind == "list" && mem.ListKind == "bytes":
457			fmt.Fprintf(b, "_ = len(%s.%s());", expr, mem.Method)
458		case mem.Kind == "list" && mem.ListKind == "iter":
459			it := fmt.Sprintf("it%d", depth)
460			fmt.Fprintf(b, "{ %s := %s.%s(); for %s.Next() {", it, expr, mem.Method, it)
461			if child := all[mem.Type]; child != nil {
462				touch(b, child, it+".Value()", depth+1, all, failIter)
463			}
464			fmt.Fprintf(b, "}; if %s.Err() { %s } };", it, failIter)
465		case mem.Kind == "list":
466			l := fmt.Sprintf("l%d", depth)
467			fmt.Fprintf(b, "{ %s := %s.%s(); for i := 0; i < %s.Len(); i++ { _ = %s.At(i) } };", l, expr, mem.Method, l, l)
468		}
469	}
470}
471
472func emitReplies(modules []*Module) error {
473	type pending struct {
474		m       *Module
475		replies []replyIR
476		views   []*container
477	}
478	var all []pending
479	views := map[*Type]*container{}
480	for _, m := range modules {
481		replies, vs, err := resolveReplies(m)
482		if err != nil {
483			return err
484		}
485		for _, v := range vs {
486			for _, t := range m.Types {
487				if t.GoType+"View" == v.Name {
488					views[t] = v
489				}
490			}
491		}
492		all = append(all, pending{m, replies, vs})
493	}
494	for _, p := range all {
495		m := p.m
496		lists := map[string]string{}
497		var decls []string
498		for _, r := range p.replies {
499			d, err := replyDecl(r, lists)
500			if err != nil {
501				return err
502			}
503			decls = append(decls, d)
504		}
505		for _, v := range p.views {
506			d, err := viewDecl(v, lists)
507			if err != nil {
508				return err
509			}
510			decls = append(decls, d)
511		}
512		var names []string
513		for name := range lists {
514			if err := m.claim(name, m.Root); err != nil {
515				return err
516			}
517			names = append(names, name)
518		}
519		sort.Strings(names)
520		for _, name := range names {
521			decls = append(decls, lists[name])
522		}
523		if len(decls) == 0 {
524			continue
525		}
526		decls = append([]string{importsFor(m, strings.Join(decls, "\n"))}, decls...)
527		data := struct {
528			Pkg   string
529			Decls []string
530		}{Pkg: m.Pkg, Decls: decls}
531		if err := writeTemplate(filepath.Join(m.Pkg, "replies_generated.go"), "replies", data); err != nil {
532			return err
533		}
534		if err := emitReplyFuzz(m, p.replies, views); err != nil {
535			return err
536		}
537	}
538	return nil
539}
540
541// emitReplyFuzz writes FuzzParse<Req>Reply for every reply. Seeds are the
542// zero-filled fixed prefix plus any captured goldens <Req>*.reply.hex.
543func emitReplyFuzz(m *Module, replies []replyIR, views map[*Type]*container) error {
544	var decls []string
545	usesGolden := false
546	for _, r := range replies {
547		var b strings.Builder
548		c := r.C
549		fmt.Fprintf(&b, "func FuzzParse%s(f *testing.F) {\n", c.Name)
550		fmt.Fprintf(&b, "f.Add(make([]byte, %d));\n", (max(c.Fixed, 32)+3)&^3)
551		goldens, _ := filepath.Glob(filepath.Join("testdata/golden", m.Pkg, r.Req+"*.reply.hex"))
552		sort.Strings(goldens)
553		for _, g := range goldens {
554			base := filepath.Base(g)
555			if strings.TrimSuffix(base, ".reply.hex") != r.Req && !startsUpper(strings.TrimPrefix(strings.TrimSuffix(base, ".reply.hex"), r.Req)) {
556				continue
557			}
558			usesGolden = true
559			fmt.Fprintf(&b, "if raw, err := xtest.LoadHex(%q); err != nil { f.Fatal(err) } else { f.Add(raw) };\n", "../"+filepath.ToSlash(g))
560		}
561		fmt.Fprintf(&b, "f.Fuzz(func(t *testing.T, raw []byte) {\nr, ok := Parse%s(raw);\nif !ok {", c.Name)
562		if c.Dynamic < 0 {
563			fmt.Fprintf(&b, "if len(raw) >= %d { t.Fatal(\"rejected a complete fixed-size reply\") };", max(c.Fixed, 32))
564		}
565		b.WriteString("return };\n")
566		b.WriteString("if len(r.Raw()) != len(raw) { t.Fatal(\"Raw must return the parsed bytes\") };\n")
567		touch(&b, c, "r", 0, views, `t.Fatal("iterator error after successful Parse")`)
568		b.WriteString("\n})\n}\n")
569		decls = append(decls, b.String())
570	}
571	imports := "import \"testing\""
572	if usesGolden {
573		imports += "\nimport \"bnema.dev/xturbo/internal/xtest\""
574	}
575	decls = append([]string{imports}, decls...)
576	data := struct {
577		Pkg   string
578		Decls []string
579	}{Pkg: m.Pkg, Decls: decls}
580	return writeTemplate(filepath.Join(m.Pkg, "replies_fuzz_test.go"), "replies", data)
581}
582
583func startsUpper(s string) bool { return s != "" && s[0] >= 'A' && s[0] <= 'Z' }

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 1

Tags 1

↑↓ moveenter openesc close