detached b4a889c

perf(core): frame without per-message copies and marshal without allocations

- The receive buffer keeps a read offset and compacts once per socket read
  instead of shifting the remaining bytes after every frame. Event bodies
  alias the buffer for the duration of the handler (Event.Data documents
  this). 64 KiB of coalesced 12-byte messages: ~297 -> ~46 ns/frame, one
  allocation per frame -> three per read.
- Request marshaling writes wire words directly instead of binary.Write and
  a heap header: ~830 ns/3 allocs -> ~32 ns/0 allocs per request.
- Add BenchmarkDispatchCoalesced and BenchmarkSendRequest.
commit
b4a889c541a6eda82cc4558000cfe0cb6de70909
author
brice · · 2026-09-29 15:48 UTC
parent
b69dc78
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +78 -33
M benchmark_test.go +34 -0
@@ -17,6 +17,29 @@ func BenchmarkWireDispatch(b *testing.B) {
1717 }
1818 }
1919
20+// BenchmarkDispatchCoalesced measures framing when one read carries many
21+// messages; per-frame cost must not grow with the amount buffered.
22+func BenchmarkDispatchCoalesced(b *testing.B) {
23+ var chunk []byte
24+ for len(chunk)+12 <= readChunkSize {
25+ chunk = append(chunk, message(7, 0, []byte{1, 0, 0, 0})...)
26+ }
27+ frames := len(chunk) / 12
28+ b.ReportAllocs()
29+ for i := 0; i < b.N; i++ {
30+ b.StopTimer()
31+ d := newDisplay(&chunkConn{chunks: [][]byte{append([]byte(nil), chunk...)}})
32+ d.context.Register(&benchProxy{BaseProxy: BaseProxy{id: 7, context: d.context}})
33+ b.StartTimer()
34+ for j := 0; j < frames; j++ {
35+ if err := d.Dispatch(); err != nil {
36+ b.Fatal(err)
37+ }
38+ }
39+ }
40+ b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N*frames), "ns/frame")
41+}
42+
2043 type benchProxy struct {
2144 BaseProxy
2245 value uint32
@@ -24,3 +47,14 @@ type benchProxy struct {
2447
2548 func (p *benchProxy) EventSignature(op uint16) (string, bool) { return "uint,", op == 0 }
2649 func (p *benchProxy) Dispatch(e *Event) { p.value = e.Uint32() }
50+
51+// BenchmarkSendRequest measures marshaling a typical fixed-size request.
52+func BenchmarkSendRequest(b *testing.B) {
53+ d := newDisplay(&chunkConn{})
54+ b.ReportAllocs()
55+ for i := 0; i < b.N; i++ {
56+ if err := d.SendRequest(9, 0, uint32(1), int32(2), Fixed(3)); err != nil {
57+ b.Fatal(err)
58+ }
59+ }
60+}
@@ -17,6 +17,29 @@ func BenchmarkWireDispatch(b *testing.B) {
17 }17 }
18 }18 }
19 19
20+// BenchmarkDispatchCoalesced measures framing when one read carries many
21+// messages; per-frame cost must not grow with the amount buffered.
22+func BenchmarkDispatchCoalesced(b *testing.B) {
23+ var chunk []byte
24+ for len(chunk)+12 <= readChunkSize {
25+ chunk = append(chunk, message(7, 0, []byte{1, 0, 0, 0})...)
26+ }
27+ frames := len(chunk) / 12
28+ b.ReportAllocs()
29+ for i := 0; i < b.N; i++ {
30+ b.StopTimer()
31+ d := newDisplay(&chunkConn{chunks: [][]byte{append([]byte(nil), chunk...)}})
32+ d.context.Register(&benchProxy{BaseProxy: BaseProxy{id: 7, context: d.context}})
33+ b.StartTimer()
34+ for j := 0; j < frames; j++ {
35+ if err := d.Dispatch(); err != nil {
36+ b.Fatal(err)
37+ }
38+ }
39+ }
40+ b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N*frames), "ns/frame")
41+}
42+
20 type benchProxy struct {43 type benchProxy struct {
21 BaseProxy44 BaseProxy
22 value uint3245 value uint32
@@ -24,3 +47,14 @@ type benchProxy struct {
24 47
25 func (p *benchProxy) EventSignature(op uint16) (string, bool) { return "uint,", op == 0 }48 func (p *benchProxy) EventSignature(op uint16) (string, bool) { return "uint,", op == 0 }
26 func (p *benchProxy) Dispatch(e *Event) { p.value = e.Uint32() }49 func (p *benchProxy) Dispatch(e *Event) { p.value = e.Uint32() }
50+
51+// BenchmarkSendRequest measures marshaling a typical fixed-size request.
52+func BenchmarkSendRequest(b *testing.B) {
53+ d := newDisplay(&chunkConn{})
54+ b.ReportAllocs()
55+ for i := 0; i < b.N; i++ {
56+ if err := d.SendRequest(9, 0, uint32(1), int32(2), Fixed(3)); err != nil {
57+ b.Fatal(err)
58+ }
59+ }
60+}
M client.go +42 -32
@@ -61,11 +61,12 @@ type Display struct {
6161 registry *Registry
6262 context *Context // Store context once
6363
64- // Connection-local receive state. rbuf holds bytes read from the socket
65- // that do not yet form a complete frame; recvBuf is the scratch buffer
66- // used for a single read. pendingFDs holds ancillary descriptors that
67- // arrived with those bytes and have not been consumed yet.
64+ // Connection-local receive state. rbuf[rpos:] holds bytes read from the
65+ // socket that have not been framed yet; recvBuf is the scratch buffer used
66+ // for a single read. pendingFDs holds ancillary descriptors that arrived
67+ // with those bytes and have not been consumed yet.
6868 rbuf []byte
69+ rpos int
6970 recvBuf []byte
7071 readErr error
7172 maxMsgLen uint32
@@ -244,8 +245,8 @@ func (d *Display) sendRequest(objectID uint32, opcode uint16, fds []int, guard f
244245 }()
245246
246247 // Write header placeholder
247- header := make([]byte, 8)
248- _, _ = buf.Write(header)
248+ var header [HeaderSize]byte
249+ _, _ = buf.Write(header[:])
249250
250251 // Marshal arguments
251252 for _, arg := range args {
@@ -266,30 +267,35 @@ func (d *Display) sendRequest(objectID uint32, opcode uint16, fds []int, guard f
266267
267268 // Update buffer with correct header
268269 data := buf.Bytes()
269- copy(data[0:8], header)
270+ copy(data[0:8], header[:])
270271
271272 // Send message with optional file descriptors
272273 return d.sendmsgWithFDs(data, fds, guard)
273274 }
274275
276+// putUint32 appends one little-endian wire word without allocating.
277+func putUint32(buf *bytes.Buffer, v uint32) {
278+ var w [4]byte
279+ binary.LittleEndian.PutUint32(w[:], v)
280+ _, _ = buf.Write(w[:])
281+}
282+
275283 // marshalArg marshals a single argument
276284 func (d *Display) marshalArg(buf *bytes.Buffer, arg interface{}) error {
277285 switch v := arg.(type) {
278286 case uint32:
279- return binary.Write(buf, binary.LittleEndian, v)
287+ putUint32(buf, v)
280288 case int32:
281- return binary.Write(buf, binary.LittleEndian, v)
289+ putUint32(buf, uint32(v))
282290 case Fixed:
283- return binary.Write(buf, binary.LittleEndian, int32(v))
291+ putUint32(buf, uint32(v))
284292 case string:
285293 // String format: length (including null) + string + null + padding
286294 strlen := len(v) + 1
287295 if strlen > 0xffff-HeaderSize {
288296 return fmt.Errorf("string too long: %d bytes", strlen)
289297 }
290- if err := binary.Write(buf, binary.LittleEndian, uint32(strlen)); err != nil { // Safe: checked above
291- return err
292- }
298+ putUint32(buf, uint32(strlen)) // Safe: checked above
293299 _, _ = buf.WriteString(v)
294300 _ = buf.WriteByte(0)
295301 // Pad to 32-bit boundary
@@ -303,9 +309,7 @@ func (d *Display) marshalArg(buf *bytes.Buffer, arg interface{}) error {
303309 if arrlen > 0xffff-HeaderSize {
304310 return fmt.Errorf("array too long: %d bytes", arrlen)
305311 }
306- if err := binary.Write(buf, binary.LittleEndian, uint32(arrlen)); err != nil {
307- return err
308- }
312+ putUint32(buf, uint32(arrlen))
309313 _, _ = buf.Write(v)
310314 // Pad to 32-bit boundary
311315 padding := (4 - (arrlen % 4)) % 4
@@ -314,12 +318,13 @@ func (d *Display) marshalArg(buf *bytes.Buffer, arg interface{}) error {
314318 }
315319 case Object:
316320 if v != nil {
317- return binary.Write(buf, binary.LittleEndian, v.ID())
321+ putUint32(buf, v.ID())
322+ } else {
323+ putUint32(buf, 0)
318324 }
319- return binary.Write(buf, binary.LittleEndian, uint32(0))
320325 case nil:
321326 // Null object
322- return binary.Write(buf, binary.LittleEndian, uint32(0))
327+ putUint32(buf, 0)
323328 case uintptr:
324329 // File descriptor as uintptr - NO placeholder in message for neurlang compatibility
325330 // FD is ONLY sent via SCM_RIGHTS, not in the message body
@@ -393,18 +398,20 @@ func (d *Display) maxMessageSize() uint32 {
393398 // nextFrame returns the next complete frame, reading more bytes as needed.
394399 func (d *Display) nextFrame() (receivedFrame, error) {
395400 for {
396- if len(d.rbuf) >= HeaderSize {
397- object, opcode, size, err := parseHeader(d.rbuf[:HeaderSize], d.maxMessageSize())
401+ buf := d.rbuf[d.rpos:]
402+ if len(buf) >= HeaderSize {
403+ object, opcode, size, err := parseHeader(buf[:HeaderSize], d.maxMessageSize())
398404 if err != nil {
399405 return receivedFrame{}, err
400406 }
401- if uint32(len(d.rbuf)) >= size {
407+ if uint32(len(buf)) >= size {
408+ // The body aliases the receive buffer. It stays valid until
409+ // the next fill, which only happens in a later Dispatch.
402410 frame := receivedFrame{object: object, opcode: opcode}
403411 if size > HeaderSize {
404- frame.body = make([]byte, size-HeaderSize)
405- copy(frame.body, d.rbuf[HeaderSize:size])
412+ frame.body = buf[HeaderSize:size:size]
406413 }
407- d.consume(size)
414+ d.rpos += int(size)
408415 return frame, nil
409416 }
410417 }
@@ -417,11 +424,8 @@ func (d *Display) nextFrame() (receivedFrame, error) {
417424 }
418425 }
419426
420-// consume removes the first n bytes from the receive buffer.
421-func (d *Display) consume(n uint32) {
422- rest := copy(d.rbuf, d.rbuf[n:])
423- d.rbuf = d.rbuf[:rest]
424-}
427+// unread reports whether received bytes remain that have not been framed.
428+func (d *Display) unread() bool { return d.rpos < len(d.rbuf) }
425429
426430 // fill reads one chunk from the connection into the receive buffer. Bytes that
427431 // arrive together with an error are kept so they are still parsed before the
@@ -433,6 +437,12 @@ func (d *Display) fill() error {
433437 if d.recvBuf == nil {
434438 d.recvBuf = make([]byte, readChunkSize)
435439 }
440+ // Compact once per read rather than once per frame, so framing cost does
441+ // not grow with the number of buffered messages.
442+ if d.rpos > 0 {
443+ d.rbuf = d.rbuf[:copy(d.rbuf, d.rbuf[d.rpos:])]
444+ d.rpos = 0
445+ }
436446 n, err := d.readChunk()
437447 if n > 0 {
438448 d.rbuf = append(d.rbuf, d.recvBuf[:n]...)
@@ -499,7 +509,7 @@ func (d *Display) prepareFrame(f receivedFrame) (Object, *Event, error) {
499509 return nil, nil, net.ErrClosed
500510 }
501511 if f.object == displayObjectID {
502- if len(d.pendingFDs) != 0 && len(d.rbuf) == 0 {
512+ if len(d.pendingFDs) != 0 && !d.unread() {
503513 return nil, nil, &ProtocolError{Kind: "extra_fd", Err: ErrMalformedFrame}
504514 }
505515 return nil, nil, nil
@@ -545,7 +555,7 @@ func (d *Display) prepareFrame(f receivedFrame) (Object, *Event, error) {
545555 }
546556 // FDs from a single recvmsg can accompany multiple coalesced frames. Once
547557 // all buffered bytes have been framed, surplus descriptors are not legal.
548- if len(d.rbuf) == 0 && len(d.pendingFDs) != 0 {
558+ if !d.unread() && len(d.pendingFDs) != 0 {
549559 for _, fd := range ev.fds {
550560 _ = fd.Close()
551561 }
@@ -61,11 +61,12 @@ type Display struct {
61 registry *Registry61 registry *Registry
62 context *Context // Store context once62 context *Context // Store context once
63 63
64- // Connection-local receive state. rbuf holds bytes read from the socket64+ // Connection-local receive state. rbuf[rpos:] holds bytes read from the
65- // that do not yet form a complete frame; recvBuf is the scratch buffer65+ // socket that have not been framed yet; recvBuf is the scratch buffer used
66- // used for a single read. pendingFDs holds ancillary descriptors that66+ // for a single read. pendingFDs holds ancillary descriptors that arrived
67- // arrived with those bytes and have not been consumed yet.67+ // with those bytes and have not been consumed yet.
68 rbuf []byte68 rbuf []byte
69+ rpos int
69 recvBuf []byte70 recvBuf []byte
70 readErr error71 readErr error
71 maxMsgLen uint3272 maxMsgLen uint32
@@ -244,8 +245,8 @@ func (d *Display) sendRequest(objectID uint32, opcode uint16, fds []int, guard f
244 }()245 }()
245 246
246 // Write header placeholder247 // Write header placeholder
247- header := make([]byte, 8)248+ var header [HeaderSize]byte
248- _, _ = buf.Write(header)249+ _, _ = buf.Write(header[:])
249 250
250 // Marshal arguments251 // Marshal arguments
251 for _, arg := range args {252 for _, arg := range args {
@@ -266,30 +267,35 @@ func (d *Display) sendRequest(objectID uint32, opcode uint16, fds []int, guard f
266 267
267 // Update buffer with correct header268 // Update buffer with correct header
268 data := buf.Bytes()269 data := buf.Bytes()
269- copy(data[0:8], header)270+ copy(data[0:8], header[:])
270 271
271 // Send message with optional file descriptors272 // Send message with optional file descriptors
272 return d.sendmsgWithFDs(data, fds, guard)273 return d.sendmsgWithFDs(data, fds, guard)
273 }274 }
274 275
276+// putUint32 appends one little-endian wire word without allocating.
277+func putUint32(buf *bytes.Buffer, v uint32) {
278+ var w [4]byte
279+ binary.LittleEndian.PutUint32(w[:], v)
280+ _, _ = buf.Write(w[:])
281+}
282+
275 // marshalArg marshals a single argument283 // marshalArg marshals a single argument
276 func (d *Display) marshalArg(buf *bytes.Buffer, arg interface{}) error {284 func (d *Display) marshalArg(buf *bytes.Buffer, arg interface{}) error {
277 switch v := arg.(type) {285 switch v := arg.(type) {
278 case uint32:286 case uint32:
279- return binary.Write(buf, binary.LittleEndian, v)287+ putUint32(buf, v)
280 case int32:288 case int32:
281- return binary.Write(buf, binary.LittleEndian, v)289+ putUint32(buf, uint32(v))
282 case Fixed:290 case Fixed:
283- return binary.Write(buf, binary.LittleEndian, int32(v))291+ putUint32(buf, uint32(v))
284 case string:292 case string:
285 // String format: length (including null) + string + null + padding293 // String format: length (including null) + string + null + padding
286 strlen := len(v) + 1294 strlen := len(v) + 1
287 if strlen > 0xffff-HeaderSize {295 if strlen > 0xffff-HeaderSize {
288 return fmt.Errorf("string too long: %d bytes", strlen)296 return fmt.Errorf("string too long: %d bytes", strlen)
289 }297 }
290- if err := binary.Write(buf, binary.LittleEndian, uint32(strlen)); err != nil { // Safe: checked above298+ putUint32(buf, uint32(strlen)) // Safe: checked above
291- return err
292- }
293 _, _ = buf.WriteString(v)299 _, _ = buf.WriteString(v)
294 _ = buf.WriteByte(0)300 _ = buf.WriteByte(0)
295 // Pad to 32-bit boundary301 // Pad to 32-bit boundary
@@ -303,9 +309,7 @@ func (d *Display) marshalArg(buf *bytes.Buffer, arg interface{}) error {
303 if arrlen > 0xffff-HeaderSize {309 if arrlen > 0xffff-HeaderSize {
304 return fmt.Errorf("array too long: %d bytes", arrlen)310 return fmt.Errorf("array too long: %d bytes", arrlen)
305 }311 }
306- if err := binary.Write(buf, binary.LittleEndian, uint32(arrlen)); err != nil {312+ putUint32(buf, uint32(arrlen))
307- return err
308- }
309 _, _ = buf.Write(v)313 _, _ = buf.Write(v)
310 // Pad to 32-bit boundary314 // Pad to 32-bit boundary
311 padding := (4 - (arrlen % 4)) % 4315 padding := (4 - (arrlen % 4)) % 4
@@ -314,12 +318,13 @@ func (d *Display) marshalArg(buf *bytes.Buffer, arg interface{}) error {
314 }318 }
315 case Object:319 case Object:
316 if v != nil {320 if v != nil {
317- return binary.Write(buf, binary.LittleEndian, v.ID())321+ putUint32(buf, v.ID())
322+ } else {
323+ putUint32(buf, 0)
318 }324 }
319- return binary.Write(buf, binary.LittleEndian, uint32(0))
320 case nil:325 case nil:
321 // Null object326 // Null object
322- return binary.Write(buf, binary.LittleEndian, uint32(0))327+ putUint32(buf, 0)
323 case uintptr:328 case uintptr:
324 // File descriptor as uintptr - NO placeholder in message for neurlang compatibility329 // File descriptor as uintptr - NO placeholder in message for neurlang compatibility
325 // FD is ONLY sent via SCM_RIGHTS, not in the message body330 // FD is ONLY sent via SCM_RIGHTS, not in the message body
@@ -393,18 +398,20 @@ func (d *Display) maxMessageSize() uint32 {
393 // nextFrame returns the next complete frame, reading more bytes as needed.398 // nextFrame returns the next complete frame, reading more bytes as needed.
394 func (d *Display) nextFrame() (receivedFrame, error) {399 func (d *Display) nextFrame() (receivedFrame, error) {
395 for {400 for {
396- if len(d.rbuf) >= HeaderSize {401+ buf := d.rbuf[d.rpos:]
397- object, opcode, size, err := parseHeader(d.rbuf[:HeaderSize], d.maxMessageSize())402+ if len(buf) >= HeaderSize {
403+ object, opcode, size, err := parseHeader(buf[:HeaderSize], d.maxMessageSize())
398 if err != nil {404 if err != nil {
399 return receivedFrame{}, err405 return receivedFrame{}, err
400 }406 }
401- if uint32(len(d.rbuf)) >= size {407+ if uint32(len(buf)) >= size {
408+ // The body aliases the receive buffer. It stays valid until
409+ // the next fill, which only happens in a later Dispatch.
402 frame := receivedFrame{object: object, opcode: opcode}410 frame := receivedFrame{object: object, opcode: opcode}
403 if size > HeaderSize {411 if size > HeaderSize {
404- frame.body = make([]byte, size-HeaderSize)412+ frame.body = buf[HeaderSize:size:size]
405- copy(frame.body, d.rbuf[HeaderSize:size])
406 }413 }
407- d.consume(size)414+ d.rpos += int(size)
408 return frame, nil415 return frame, nil
409 }416 }
410 }417 }
@@ -417,11 +424,8 @@ func (d *Display) nextFrame() (receivedFrame, error) {
417 }424 }
418 }425 }
419 426
420-// consume removes the first n bytes from the receive buffer.427+// unread reports whether received bytes remain that have not been framed.
421-func (d *Display) consume(n uint32) {428+func (d *Display) unread() bool { return d.rpos < len(d.rbuf) }
422- rest := copy(d.rbuf, d.rbuf[n:])
423- d.rbuf = d.rbuf[:rest]
424-}
425 429
426 // fill reads one chunk from the connection into the receive buffer. Bytes that430 // fill reads one chunk from the connection into the receive buffer. Bytes that
427 // arrive together with an error are kept so they are still parsed before the431 // arrive together with an error are kept so they are still parsed before the
@@ -433,6 +437,12 @@ func (d *Display) fill() error {
433 if d.recvBuf == nil {437 if d.recvBuf == nil {
434 d.recvBuf = make([]byte, readChunkSize)438 d.recvBuf = make([]byte, readChunkSize)
435 }439 }
440+ // Compact once per read rather than once per frame, so framing cost does
441+ // not grow with the number of buffered messages.
442+ if d.rpos > 0 {
443+ d.rbuf = d.rbuf[:copy(d.rbuf, d.rbuf[d.rpos:])]
444+ d.rpos = 0
445+ }
436 n, err := d.readChunk()446 n, err := d.readChunk()
437 if n > 0 {447 if n > 0 {
438 d.rbuf = append(d.rbuf, d.recvBuf[:n]...)448 d.rbuf = append(d.rbuf, d.recvBuf[:n]...)
@@ -499,7 +509,7 @@ func (d *Display) prepareFrame(f receivedFrame) (Object, *Event, error) {
499 return nil, nil, net.ErrClosed509 return nil, nil, net.ErrClosed
500 }510 }
501 if f.object == displayObjectID {511 if f.object == displayObjectID {
502- if len(d.pendingFDs) != 0 && len(d.rbuf) == 0 {512+ if len(d.pendingFDs) != 0 && !d.unread() {
503 return nil, nil, &ProtocolError{Kind: "extra_fd", Err: ErrMalformedFrame}513 return nil, nil, &ProtocolError{Kind: "extra_fd", Err: ErrMalformedFrame}
504 }514 }
505 return nil, nil, nil515 return nil, nil, nil
@@ -545,7 +555,7 @@ func (d *Display) prepareFrame(f receivedFrame) (Object, *Event, error) {
545 }555 }
546 // FDs from a single recvmsg can accompany multiple coalesced frames. Once556 // FDs from a single recvmsg can accompany multiple coalesced frames. Once
547 // all buffered bytes have been framed, surplus descriptors are not legal.557 // all buffered bytes have been framed, surplus descriptors are not legal.
548- if len(d.rbuf) == 0 && len(d.pendingFDs) != 0 {558+ if !d.unread() && len(d.pendingFDs) != 0 {
549 for _, fd := range ev.fds {559 for _, fd := range ev.fds {
550 _ = fd.Close()560 _ = fd.Close()
551 }561 }
M core.go +2 -1
@@ -45,7 +45,8 @@ type Event struct {
4545 fdIndex int
4646 }
4747
48-// Data returns the raw event data
48+// Data returns the raw event body. It aliases the connection's receive buffer
49+// and is only valid until the handler returns; copy it to keep it.
4950 func (e *Event) Data() []byte {
5051 return e.data
5152 }
@@ -45,7 +45,8 @@ type Event struct {
45 fdIndex int45 fdIndex int
46 }46 }
47 47
48-// Data returns the raw event data48+// Data returns the raw event body. It aliases the connection's receive buffer
49+// and is only valid until the handler returns; copy it to keep it.
49 func (e *Event) Data() []byte {50 func (e *Event) Data() []byte {
50 return e.data51 return e.data
51 }52 }

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