v0.4.0 9c230c3

scanner.go RawHistoryFolder

1// Package scanner generates WLTurbo-backed Go bindings from Wayland protocol
2// XML files.
3//
4// The generated code owns protocol semantics only: it embeds wl.BaseProxy,
5// allocates and registers child objects through the wl.Context, sends requests
6// through that context, and dispatches typed events to registered handlers.
7// Message framing, descriptor passing and connection lifetime stay in the
8// transport, so a generated binding never opens a socket or writes a header.
9package scanner
10
11import (
12	"bytes"
13	"encoding/xml"
14	"fmt"
15	"go/format"
16	"io"
17	"os"
18	"path/filepath"
19	"sort"
20	"strings"
21	"text/template"
22)
23
24// TransportImport is the import path generated bindings use for the Wayland
25// transport.
26const TransportImport = "github.com/bnema/wlturbo/wl"
27
28// Protocol represents a Wayland protocol specification
29type Protocol struct {
30	XMLName     xml.Name     `xml:"protocol"`
31	Name        string       `xml:"name,attr"`
32	Copyright   string       `xml:"copyright"`
33	Description *Description `xml:"description"`
34	Interfaces  []Interface  `xml:"interface"`
35}
36
37// Interface represents a Wayland interface in the protocol
38type Interface struct {
39	Name        string       `xml:"name,attr"`
40	Version     int          `xml:"version,attr"`
41	Description *Description `xml:"description"`
42	Requests    []Request    `xml:"request"`
43	Events      []Event      `xml:"event"`
44	Enums       []Enum       `xml:"enum"`
45}
46
47// Request represents a client-to-server message
48type Request struct {
49	Name        string       `xml:"name,attr"`
50	Type        string       `xml:"type,attr"` // "destructor" or empty
51	Since       int          `xml:"since,attr"`
52	Description *Description `xml:"description"`
53	Args        []Arg        `xml:"arg"`
54}
55
56// Event represents a server-to-client message
57type Event struct {
58	Type        string       `xml:"type,attr"`
59	Name        string       `xml:"name,attr"`
60	Since       int          `xml:"since,attr"`
61	Description *Description `xml:"description"`
62	Args        []Arg        `xml:"arg"`
63}
64
65// Arg represents a message argument
66type Arg struct {
67	Name      string `xml:"name,attr"`
68	Type      string `xml:"type,attr"`
69	Summary   string `xml:"summary,attr"`
70	Interface string `xml:"interface,attr"`
71	AllowNull bool   `xml:"allow-null,attr"`
72	Enum      string `xml:"enum,attr"`
73}
74
75// Enum represents an enumeration type
76type Enum struct {
77	Name        string       `xml:"name,attr"`
78	Since       int          `xml:"since,attr"`
79	Bitfield    bool         `xml:"bitfield,attr"`
80	Description *Description `xml:"description"`
81	Entries     []Entry      `xml:"entry"`
82}
83
84// Entry represents an enum value
85type Entry struct {
86	Name    string `xml:"name,attr"`
87	Value   string `xml:"value,attr"`
88	Summary string `xml:"summary,attr"`
89	Since   int    `xml:"since,attr"`
90}
91
92// Description represents documentation
93type Description struct {
94	Summary string `xml:"summary,attr"`
95	Text    string `xml:",chardata"`
96}
97
98// Scanner generates Go bindings from Wayland protocol XML
99type Scanner struct {
100	protocol *Protocol
101	source   string
102	// CrossPackage maps interface names to Go package import paths. Local wins.
103	CrossPackage map[string]string
104}
105
106// NewScanner creates a new protocol scanner
107func NewScanner() *Scanner {
108	return &Scanner{}
109}
110
111// ParseXML parses a Wayland protocol XML file
112func (s *Scanner) ParseXML(path string) error {
113	file, err := os.Open(path)
114	if err != nil {
115		return fmt.Errorf("failed to open XML file: %w", err)
116	}
117	defer func() { _ = file.Close() }()
118
119	data, err := io.ReadAll(file)
120	if err != nil {
121		return fmt.Errorf("failed to read XML file: %w", err)
122	}
123
124	var protocol Protocol
125	if err := xml.Unmarshal(data, &protocol); err != nil {
126		return fmt.Errorf("failed to parse XML: %w", err)
127	}
128
129	s.protocol = &protocol
130	// Only the base name is recorded: generated output must not depend on the
131	// path the XML happened to be read from.
132	s.source = filepath.Base(path)
133	return nil
134}
135
136// Generate creates Go source code for the parsed protocol
137func (s *Scanner) Generate(packageName string) ([]byte, error) {
138	if s.protocol == nil {
139		return nil, fmt.Errorf("no protocol parsed")
140	}
141
142	data, err := s.prepareTemplateData(packageName)
143	if err != nil {
144		return nil, err
145	}
146
147	var buf bytes.Buffer
148	tmpl := template.Must(template.New("protocol").Parse(protocolTemplate))
149	if err := tmpl.Execute(&buf, data); err != nil {
150		return nil, fmt.Errorf("failed to execute template: %w", err)
151	}
152
153	formatted, err := format.Source(buf.Bytes())
154	if err != nil {
155		return buf.Bytes(), fmt.Errorf("failed to format generated code: %w", err)
156	}
157	return formatted, nil
158}
159
160// templateData holds data for template generation
161type templateData struct {
162	Package     string
163	Protocol    string
164	Source      string
165	Copyright   string
166	ImportBlock string
167	Constants   []constantData
168	Interfaces  []interfaceData
169}
170
171type interfaceData struct {
172	Name           string
173	GoName         string
174	Version        int
175	HasEvents      bool
176	SignatureCases []signatureCase
177	Requests       []requestData
178	Events         []eventData
179}
180
181type signatureCase struct {
182	Opcode    int
183	Signature string
184}
185type requestData struct {
186	Name            string
187	GoName          string
188	Doc             string
189	Opcode          int
190	Destructor      bool
191	Since           int
192	Params          string
193	Results         string
194	ArgPreparations []string
195	CreatesChild    bool
196	ChildVar        string
197	ChildType       string
198	ChildFactory    string
199	ErrorReturn     string
200	HasFDs          bool
201	FDList          string
202	ArgList         string
203}
204
205// argSlot is one positional argument of a request, in declaration order. A
206// new_id slot is filled in once the child object has been created.
207type argSlot struct {
208	newID bool
209	expr  string
210}
211
212type eventData struct {
213	HasFD           bool
214	ServerDestroy   bool
215	FDNames         []string
216	Name            string
217	GoName          string
218	Opcode          int
219	HandlerField    string
220	HandlerAccessor string
221	HandlerParams   string
222	HandlerArgs     string
223	DecodeLines     []string
224}
225
226type constantData struct {
227	Name  string
228	Value string
229	Type  string
230	Enum  string
231	Entry string
232}
233
234// Only display and registry have handwritten bootstrap bindings.
235var bootstrapTypes = map[string]bool{
236	"Display":  true,
237	"Registry": true,
238}
239
240// goKeywords are avoided as parameter names because generated identifiers
241// must not collide with the language.
242var goKeywords = map[string]bool{
243	"break": true, "case": true, "chan": true, "const": true, "continue": true,
244	"default": true, "defer": true, "else": true, "fallthrough": true, "for": true,
245	"func": true, "go": true, "goto": true, "if": true, "import": true,
246	"interface": true, "map": true, "package": true, "range": true, "return": true,
247	"select": true, "struct": true, "switch": true, "type": true, "var": true,
248}
249
250func (s *Scanner) prepareTemplateData(packageName string) (templateData, error) {
251	data := templateData{
252		Package:  packageName,
253		Protocol: s.protocol.Name,
254		Source:   s.source,
255	}
256	if notice := strings.TrimSpace(s.protocol.Copyright); notice != "" {
257		var comment strings.Builder
258		for _, line := range strings.Split(notice, "\n") {
259			comment.WriteString("// ")
260			comment.WriteString(strings.TrimSpace(line))
261			comment.WriteByte('\n')
262		}
263		data.Copyright = comment.String()
264	}
265
266	imports := []string{TransportImport}
267	usedCross := make(map[string]bool)
268	for _, candidate := range s.protocol.Interfaces {
269		for _, req := range candidate.Requests {
270			for _, arg := range req.Args {
271				if arg.Type == "object" || arg.Type == "new_id" {
272					usedCross[arg.Interface] = true
273				}
274			}
275		}
276		for _, ev := range candidate.Events {
277			for _, arg := range ev.Args {
278				// Event object references are decoded as IDs, not imported proxies.
279				if arg.Type == "new_id" {
280					usedCross[arg.Interface] = true
281				}
282			}
283		}
284	}
285	for iface, path := range s.CrossPackage {
286		local := false
287		for _, candidate := range s.protocol.Interfaces {
288			if candidate.Name == iface {
289				local = true
290				break
291			}
292		}
293		if local || !usedCross[iface] || bootstrapTypes[s.toGoName(iface)] {
294			continue
295		}
296		alias := "cross_" + strings.ReplaceAll(iface, "-", "_")
297		imports = append(imports, alias+" "+path)
298	}
299	sort.Strings(imports)
300	stdImports := []string{}
301	needsSync := false
302
303	// Enum constants are package-level declarations, so two interfaces that
304	// declare the same enum entry (for example xdg_surface.error.invalid_size
305	// and xdg_toplevel.error.invalid_size) would collide. Entries whose name
306	// occurs in more than one interface are qualified with their interface
307	// name below; unique names keep their historical, unprefixed form.
308	enumEntryCounts := map[string]int{}
309	for _, iface := range s.protocol.Interfaces {
310		for _, enum := range iface.Enums {
311			for _, entry := range enum.Entries {
312				enumEntryCounts[constantName(enum.Name, entry.Name)]++
313			}
314		}
315	}
316
317	for _, iface := range s.protocol.Interfaces {
318		if packageName == "core" && (iface.Name == "wl_display" || iface.Name == "wl_registry") {
319			continue
320		}
321		ifaceData, err := s.processInterface(iface)
322		if err != nil {
323			return data, err
324		}
325		data.Interfaces = append(data.Interfaces, ifaceData)
326		if len(iface.Events) > 0 {
327			needsSync = true
328		}
329
330		for _, enum := range iface.Enums {
331			for _, entry := range enum.Entries {
332				name := constantName(enum.Name, entry.Name)
333				if enumEntryCounts[name] > 1 {
334					name = qualifiedConstantName(iface.Name, enum.Name, entry.Name)
335				}
336				// Enum values are emitted untyped: the same enum travels as a uint
337				// in one protocol and as an int in another, and an untyped constant
338				// assigns to either without casts at the call site.
339				data.Constants = append(data.Constants, constantData{
340					Name:  name,
341					Value: entry.Value,
342					Enum:  enum.Name,
343					Entry: entry.Name,
344				})
345			}
346		}
347	}
348
349	// Handlers are registered and dispatched from different goroutines, so an
350	// interface with events needs a mutex.
351	if needsSync {
352		stdImports = append(stdImports, "sync")
353	}
354
355	var block strings.Builder
356	block.WriteString("import (\n")
357	for _, imp := range stdImports {
358		fmt.Fprintf(&block, "\t%q\n", imp)
359	}
360	if len(stdImports) > 0 && len(imports) > 0 {
361		block.WriteString("\n")
362	}
363	for _, imp := range imports {
364		if strings.Contains(imp, " ") {
365			parts := strings.SplitN(imp, " ", 2)
366			fmt.Fprintf(&block, "\t%s %q\n", parts[0], parts[1])
367		} else {
368			fmt.Fprintf(&block, "\t%q\n", imp)
369		}
370	}
371	block.WriteString(")")
372	data.ImportBlock = block.String()
373
374	return data, nil
375}
376
377func (s *Scanner) processInterface(iface Interface) (interfaceData, error) {
378	data := interfaceData{
379		Name:      iface.Name,
380		GoName:    s.toGoName(iface.Name),
381		Version:   iface.Version,
382		HasEvents: len(iface.Events) > 0,
383	}
384
385	for i, req := range iface.Requests {
386		request, err := s.processRequest(iface, req, i)
387		if err != nil {
388			return data, err
389		}
390		data.Requests = append(data.Requests, request)
391	}
392
393	for i, event := range iface.Events {
394		var sig strings.Builder
395		for _, arg := range event.Args {
396			sig.WriteString(arg.Type)
397			sig.WriteByte(',')
398		}
399		data.SignatureCases = append(data.SignatureCases, signatureCase{i, sig.String()})
400		eventData, err := s.processEvent(event, i)
401		if iface.Name == "wl_callback" && event.Name == "done" {
402			eventData.ServerDestroy = true
403		}
404		if err != nil {
405			return data, err
406		}
407		data.Events = append(data.Events, eventData)
408	}
409
410	return data, nil
411}
412
413func (s *Scanner) processRequest(iface Interface, req Request, opcode int) (requestData, error) {
414	data := requestData{
415		Name:       req.Name,
416		GoName:     s.toGoName(req.Name),
417		Doc:        s.formatDescription(req.Description),
418		Opcode:     opcode,
419		Destructor: req.Type == "destructor",
420		Since:      req.Since,
421		ChildVar:   "child",
422	}
423
424	var (
425		params   []string
426		argSlots []argSlot
427		argExprs []string
428		fdExprs  []string
429		newIDArg *Arg
430	)
431
432	for i, arg := range req.Args {
433		if arg.Type == "new_id" {
434			if newIDArg != nil {
435				return data, fmt.Errorf("%s.%s: more than one new_id argument is not supported", iface.Name, req.Name)
436			}
437			newIDArg = &arg
438			// The child object is created later, but it keeps its declared
439			// position: the wire order is the argument order.
440			argSlots = append(argSlots, argSlot{newID: true})
441			continue
442		}
443
444		name := s.paramName(arg.Name, i)
445		goType, err := s.goTypeForArg(arg)
446		if err != nil {
447			return data, fmt.Errorf("%s.%s: %w", iface.Name, req.Name, err)
448		}
449
450		expr := name
451		switch arg.Type {
452		case "object":
453			// Objects are pointer types, and travel as wl.Object so a nil
454			// pointer is never sent as a non-nil interface holding a typed nil.
455			params = append(params, name+" *"+goType)
456			holder := fmt.Sprintf("arg%d", i)
457			data.ArgPreparations = append(data.ArgPreparations,
458				fmt.Sprintf("var %s wl.Object", holder),
459				fmt.Sprintf("if %s != nil {", name),
460				fmt.Sprintf("\t%s = %s", holder, name),
461				"}")
462			expr = "wl.ArgObject(" + holder + ")"
463
464		case "fd":
465			// Descriptors travel out of band: the body carries no value for
466			// them, and the descriptor list is attached to the request.
467			params = append(params, name+" int")
468			fdExprs = append(fdExprs, name)
469			expr = "wl.ArgFD()"
470
471		default:
472			params = append(params, name+" "+goType)
473			switch arg.Type {
474			case "int":
475				expr = "wl.ArgInt(" + name + ")"
476			case "uint":
477				expr = "wl.ArgUint(" + name + ")"
478			case "fixed":
479				expr = "wl.ArgFixed(" + name + ")"
480			case "string":
481				expr = "wl.ArgString(" + name + ")"
482			case "array":
483				expr = "wl.ArgArray(" + name + ")"
484			}
485		}
486
487		argSlots = append(argSlots, argSlot{expr: expr})
488	}
489
490	if newIDArg != nil {
491		childType, err := s.goTypeForArg(*newIDArg)
492		if err != nil {
493			return data, fmt.Errorf("%s.%s: %w", iface.Name, req.Name, err)
494		}
495		data.CreatesChild = true
496		data.ChildType = childType
497		if strings.Contains(childType, ".") {
498			data.ChildFactory = childType[:strings.LastIndex(childType, ".")+1] + "New" + s.toGoName(newIDArg.Interface)
499		}
500	}
501
502	for _, slot := range argSlots {
503		if slot.newID {
504			argExprs = append(argExprs, "wl.ArgObject("+data.ChildVar+")")
505			continue
506		}
507		argExprs = append(argExprs, slot.expr)
508	}
509
510	data.Params = strings.Join(params, ", ")
511	args := ""
512	if len(argExprs) > 0 {
513		args = ", " + strings.Join(argExprs, ", ")
514	}
515	// Context.RequestArgs owns the lifecycle: destructors claim the proxy
516	// atomically, children are registered before the send and descriptors are
517	// closed only after a successful send.
518	data.ArgList = args
519	data.FDList = strings.Join(fdExprs, ", ")
520	data.HasFDs = len(fdExprs) > 0
521	if data.CreatesChild {
522		data.Results = "(*" + data.ChildType + ", error)"
523		data.ErrorReturn = "nil, err"
524	} else {
525		data.Results = "error"
526		data.ErrorReturn = "err"
527	}
528
529	return data, nil
530}
531
532func (s *Scanner) processEvent(event Event, opcode int) (eventData, error) {
533	goName := s.toGoName(event.Name)
534	data := eventData{
535		ServerDestroy:   event.Type == "destructor",
536		Name:            event.Name,
537		GoName:          goName,
538		Opcode:          opcode,
539		HandlerField:    "on" + goName,
540		HandlerAccessor: "handlersFor" + goName,
541	}
542
543	var params, args []string
544
545	for i, arg := range event.Args {
546		name := s.paramName(arg.Name, i)
547
548		switch arg.Type {
549		case "new_id":
550			childType, err := s.goTypeForArg(arg)
551			if err != nil {
552				return data, fmt.Errorf("%s.%s: %w", event.Name, arg.Name, err)
553			}
554			child := name + "Object"
555			data.DecodeLines = append(data.DecodeLines, fmt.Sprintf("%sID := event.Uint32()", name))
556			if strings.Contains(childType, ".") {
557				factory := childType[:strings.LastIndex(childType, ".")+1] + "New" + s.toGoName(arg.Interface)
558				data.DecodeLines = append(data.DecodeLines, fmt.Sprintf("%s := %s(o.Context())", child, factory))
559			} else {
560				data.DecodeLines = append(data.DecodeLines,
561					fmt.Sprintf("%s := &%s{}", child, childType),
562					fmt.Sprintf("%s.SetContext(o.Context())", child),
563				)
564			}
565			data.DecodeLines = append(data.DecodeLines,
566				fmt.Sprintf("%s.SetID(%sID)", child, name),
567				fmt.Sprintf("%s.SetVersion(o.Version())", child),
568				fmt.Sprintf("o.Context().Register(%s)", child),
569			)
570			params = append(params, child+" *"+childType)
571			args = append(args, child)
572
573		case "object":
574			// Object references are reported as raw object IDs: the generated
575			// layer does not own a registry of foreign proxies.
576			data.DecodeLines = append(data.DecodeLines, fmt.Sprintf("%sID := event.Uint32()", name))
577			params = append(params, name+"ID uint32")
578			args = append(args, name+"ID")
579
580		default:
581			if arg.Type == "fd" {
582				data.HasFD = true
583				data.FDNames = append(data.FDNames, name)
584			}
585			goType, decoder, err := s.scalarDecoder(arg)
586			if err != nil {
587				return data, fmt.Errorf("%s.%s: %w", event.Name, arg.Name, err)
588			}
589			data.DecodeLines = append(data.DecodeLines, fmt.Sprintf("%s := event.%s()", name, decoder))
590			params = append(params, name+" "+goType)
591			args = append(args, name)
592		}
593	}
594
595	data.HandlerParams = strings.Join(params, ", ")
596	data.HandlerArgs = strings.Join(args, ", ")
597	return data, nil
598}
599
600// scalarDecoder maps a simple argument to its Go type and event decoder.
601func (s *Scanner) scalarDecoder(arg Arg) (goType, decoder string, err error) {
602	switch arg.Type {
603	case "int":
604		return "int32", "Int32", nil
605	case "uint":
606		return "uint32", "Uint32", nil
607	case "fixed":
608		return "wl.Fixed", "Fixed", nil
609	case "string":
610		return "string", "String", nil
611	case "array":
612		return "[]byte", "Array", nil
613	case "fd":
614		return "*wl.OwnedFD", "FD", nil
615	}
616	return "", "", fmt.Errorf("unsupported event argument type %q", arg.Type)
617}
618
619// goTypeForArg returns the Go type of an object-like argument. Protocol-local
620// interfaces map to generated types, explicit external references to their
621// mapped package, and bootstrap display/registry to transport wrappers.
622func (s *Scanner) goTypeForArg(arg Arg) (string, error) {
623	switch arg.Type {
624	case "int":
625		return "int32", nil
626	case "uint":
627		return "uint32", nil
628	case "fixed":
629		return "wl.Fixed", nil
630	case "string":
631		return "string", nil
632	case "array":
633		return "[]byte", nil
634	case "fd":
635		return "int", nil
636	case "object", "new_id":
637		return s.goTypeForInterface(arg.Interface)
638	}
639	return "", fmt.Errorf("unsupported argument type %q", arg.Type)
640}
641
642func (s *Scanner) goTypeForInterface(iface string) (string, error) {
643	if iface == "" {
644		return "wl.BaseProxy", nil
645	}
646	// The XML package owns its own definitions even when the name starts wl_.
647	for _, local := range s.protocol.Interfaces {
648		if local.Name == iface && iface != "wl_display" && iface != "wl_registry" {
649			return s.toGoName(iface), nil
650		}
651	}
652	if bootstrapTypes[s.toGoName(iface)] {
653		return "wl." + s.toGoName(iface), nil
654	}
655	if _, ok := s.CrossPackage[iface]; ok {
656		return "cross_" + strings.ReplaceAll(iface, "-", "_") + "." + s.toGoName(iface), nil
657	}
658	return "", fmt.Errorf("unresolved interface %q: add CrossPackage mapping", iface)
659}
660
661// paramName turns a protocol argument name into a Go parameter name.
662func (s *Scanner) paramName(name string, index int) string {
663	if name == "" {
664		return fmt.Sprintf("arg%d", index)
665	}
666
667	parts := strings.Split(name, "_")
668	for i, part := range parts {
669		if part == "" {
670			continue
671		}
672		if i == 0 {
673			parts[i] = strings.ToLower(part[:1]) + part[1:]
674			continue
675		}
676		parts[i] = strings.ToUpper(part[:1]) + part[1:]
677	}
678	result := strings.Join(parts, "")
679	if result == "" {
680		return fmt.Sprintf("arg%d", index)
681	}
682	if goKeywords[result] {
683		return result + "Arg"
684	}
685	return result
686}
687
688func (s *Scanner) toGoName(name string) string {
689	name = strings.TrimPrefix(name, "zwlr_")
690	name = strings.TrimPrefix(name, "zwp_")
691	name = strings.TrimPrefix(name, "wl_")
692	name = strings.TrimSuffix(name, "_v1")
693	name = strings.TrimSuffix(name, "_v2")
694
695	parts := strings.Split(name, "_")
696	for i, part := range parts {
697		if part != "" {
698			parts[i] = strings.ToUpper(part[:1]) + part[1:]
699		}
700	}
701	return strings.Join(parts, "")
702}
703
704// constantName renders an enum entry as the unprefixed constant name.
705func constantName(enum, entry string) string {
706	return strings.Join(append(joinName(enum), joinName(entry)...), "_")
707}
708
709// qualifiedConstantName prefixes an enum entry constant with its interface
710// name. It is used for entries that several interfaces declare, which would
711// otherwise collide in the generated package.
712func qualifiedConstantName(iface, enum, entry string) string {
713	return strings.Join(append(joinName(iface), constantName(enum, entry)), "_")
714}
715
716// joinName upper-cases the underscore-separated parts of a protocol name.
717func joinName(value string) []string {
718	parts := strings.Split(value, "_")
719	out := make([]string, 0, len(parts))
720	for _, part := range parts {
721		if part != "" {
722			out = append(out, strings.ToUpper(part))
723		}
724	}
725	return out
726}
727
728// formatDescription renders an argument or request description as a single
729// documentation line.
730func (s *Scanner) formatDescription(desc *Description) string {
731	if desc == nil {
732		return ""
733	}
734
735	text := strings.TrimSpace(desc.Text)
736	if text == "" {
737		return desc.Summary
738	}
739
740	lines := strings.Split(text, "\n")
741	formatted := make([]string, 0, len(lines))
742	for _, line := range lines {
743		if line = strings.TrimSpace(line); line != "" {
744			formatted = append(formatted, line)
745		}
746	}
747	return strings.Join(formatted, " ")
748}

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 8

↑↓ moveenter openesc close