detached 2b40f0a

fix(drm): carry cursor moves on game frames under VRR

A cursor-only commit under VRR refreshes the panel at its slowest rate (~21 ms), delaying the next fullscreen game frame. While a game drives VRR, cursor moves now wait for the next frame, or commit alone after 1/24 s. slow flip logs frame and vrr.
commit
2b40f0aba5c1cd1bab0e1fe40249d14a376cb6e1
author
brice · · 2026-09-28 14:29 UTC
committer
brice · · 2026-09-28 14:55 UTC
parent
7b60e95
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +92 -2
M docs/performance.md +3 -0
@@ -6,6 +6,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
66
77 - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts.
88 - **No allocation per frame or per request on the steady-state path.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.
9+- **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.
910 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
1011
1112 ## Guards
@@ -31,6 +32,8 @@ GODEBUG=gctrace=1 neferwl --backend=drm
3132
3233 `make tty` enables it on `localhost:6060` (`make tty PPROF=` disables it). `--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap.
3334
35+`slow flip` log entries (flip over 20 ms) carry `frame` (false: cursor or VRR state commit) and `vrr`; `input stats` shows coalesced pointer motion and the longest wait for core.
36+
3437 ## Reference: 4K HDR tiled video playback
3538
3639 Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive).
@@ -6,6 +6,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
6 6
7 - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts.7 - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts.
8 - **No allocation per frame or per request on the steady-state path.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.8 - **No allocation per frame or per request on the steady-state path.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.
9+- **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.
9 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.10 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
10 11
11 ## Guards12 ## Guards
@@ -31,6 +32,8 @@ GODEBUG=gctrace=1 neferwl --backend=drm
31 32
32 `make tty` enables it on `localhost:6060` (`make tty PPROF=` disables it). `--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap.33 `make tty` enables it on `localhost:6060` (`make tty PPROF=` disables it). `--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap.
33 34
35+`slow flip` log entries (flip over 20 ms) carry `frame` (false: cursor or VRR state commit) and `vrr`; `input stats` shows coalesced pointer motion and the longest wait for core.
36+
34 ## Reference: 4K HDR tiled video playback37 ## Reference: 4K HDR tiled video playback
35 38
36 Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive).39 Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive).
M internal/adapters/drm/output.go +40 -2
@@ -94,6 +94,10 @@ type Output struct {
9494 vrrOn bool
9595 vrrGame bool
9696 composedSince time.Time
97+ // lastFrame is when the last frame was committed. cursorHeld: a cursor
98+ // move waits for the next frame (see cursorWaits).
99+ lastFrame time.Time
100+ cursorHeld bool
97101 // wantOff is the latest Scene.Off: a client turned the display off.
98102 // off: the CRTC is inactive for it. Every modeset (resume, recovery)
99103 // follows wantOff, so a display turned off never lights up.
@@ -604,6 +608,7 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
604608 if !async {
605609 o.overlayOn = ov.buf
606610 }
611+ o.lastFrame, o.cursorHeld = time.Now(), false
607612 f.frame, f.async = true, async
608613 f.fences = dupFences(fence, ov.acquire)
609614 o.setAsync(async)
@@ -626,7 +631,12 @@ func (o *Output) commitState(vrr bool) error {
626631 if o.cursor != nil {
627632 cur = o.cursor.desired()
628633 if cur != o.cursor.applied {
629- o.cursor.props(req, o.crtc, cur)
634+ if o.cursorWaits(time.Now()) {
635+ o.cursorHeld = true
636+ cur = o.cursor.applied
637+ } else {
638+ o.cursor.props(req, o.crtc, cur)
639+ }
630640 }
631641 }
632642 if vrr != o.vrrOn {
@@ -647,6 +657,9 @@ func (o *Output) commitState(vrr bool) error {
647657 }
648658 if o.cursor != nil {
649659 o.cursor.committed(cur)
660+ if cur == o.cursor.desired() {
661+ o.cursorHeld = false
662+ }
650663 }
651664 if vrr != o.vrrOn {
652665 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")
@@ -656,6 +669,18 @@ func (o *Output) commitState(vrr bool) error {
656669 return nil
657670 }
658671
672+// cursorMinInterval bounds how long a held cursor move waits for a frame.
673+const cursorMinInterval = time.Second / 24
674+
675+// cursorWaits reports whether a cursor move rides on the next frame
676+// instead of its own commit. Under VRR a cursor-only commit refreshes the
677+// panel at its slowest rate, which delays the game's next frame by up to
678+// one such refresh. A game that stops drawing still gets the cursor at
679+// cursorMinInterval.
680+func (o *Output) cursorWaits(now time.Time) bool {
681+ return o.vrrOn && o.vrrGame && now.Sub(o.lastFrame) < cursorMinInterval
682+}
683+
659684 func boolValue(b bool) uint64 {
660685 if b {
661686 return 1
@@ -857,11 +882,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
857882 // guarantees a stopped/reset timer cannot deliver its old value.
858883 retryTimer := time.NewTimer(time.Hour)
859884 vrrTimer := time.NewTimer(time.Hour)
885+ cursorTimer := time.NewTimer(time.Hour)
860886 o.frame.startTimer()
861887 retryTimer.Stop()
862888 vrrTimer.Stop()
889+ cursorTimer.Stop()
863890 defer retryTimer.Stop()
864891 defer vrrTimer.Stop()
892+ defer cursorTimer.Stop()
865893 defer o.frame.stopTimer()
866894 // seen is the latest content Seq per window. It is reported only while
867895 // no frame is in flight: then the GPU has finished every frame that
@@ -898,6 +926,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
898926 } else {
899927 vrrTimer.Stop()
900928 }
929+ // A held cursor move commits alone if no frame comes in time.
930+ var cursorDue <-chan time.Time
931+ if enabled && o.cursorHeld && !o.frame.pendingCommit() {
932+ cursorTimer.Reset(max(0, cursorMinInterval-time.Since(o.lastFrame)))
933+ cursorDue = cursorTimer.C
934+ } else {
935+ cursorTimer.Stop()
936+ }
901937 // A commit whose event never comes must not stop the output.
902938 stuck := o.frame.wait(enabled)
903939 stateDirty := false
@@ -920,6 +956,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
920956 stateDirty = true
921957 case <-vrrOff:
922958 stateDirty = true
959+ case <-cursorDue:
960+ stateDirty = true
923961 case <-cursorWake:
924962 stateDirty = true
925963 case <-ctx.Done():
@@ -1149,7 +1187,7 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
11491187 return false
11501188 }
11511189 if age := time.Since(start); age > 20*time.Millisecond {
1152- o.log.Info().Dur("flip_ms", age).Str("connector", o.conn.name).Msg("slow flip")
1190+ o.log.Info().Dur("flip_ms", age).Bool("frame", f.frame).Bool("vrr", o.vrrOn).Str("connector", o.conn.name).Msg("slow flip")
11531191 }
11541192 if o.cursor != nil {
11551193 o.cursor.landed()
@@ -94,6 +94,10 @@ type Output struct {
94 vrrOn bool94 vrrOn bool
95 vrrGame bool95 vrrGame bool
96 composedSince time.Time96 composedSince time.Time
97+ // lastFrame is when the last frame was committed. cursorHeld: a cursor
98+ // move waits for the next frame (see cursorWaits).
99+ lastFrame time.Time
100+ cursorHeld bool
97 // wantOff is the latest Scene.Off: a client turned the display off.101 // wantOff is the latest Scene.Off: a client turned the display off.
98 // off: the CRTC is inactive for it. Every modeset (resume, recovery)102 // off: the CRTC is inactive for it. Every modeset (resume, recovery)
99 // follows wantOff, so a display turned off never lights up.103 // follows wantOff, so a display turned off never lights up.
@@ -604,6 +608,7 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
604 if !async {608 if !async {
605 o.overlayOn = ov.buf609 o.overlayOn = ov.buf
606 }610 }
611+ o.lastFrame, o.cursorHeld = time.Now(), false
607 f.frame, f.async = true, async612 f.frame, f.async = true, async
608 f.fences = dupFences(fence, ov.acquire)613 f.fences = dupFences(fence, ov.acquire)
609 o.setAsync(async)614 o.setAsync(async)
@@ -626,7 +631,12 @@ func (o *Output) commitState(vrr bool) error {
626 if o.cursor != nil {631 if o.cursor != nil {
627 cur = o.cursor.desired()632 cur = o.cursor.desired()
628 if cur != o.cursor.applied {633 if cur != o.cursor.applied {
629- o.cursor.props(req, o.crtc, cur)634+ if o.cursorWaits(time.Now()) {
635+ o.cursorHeld = true
636+ cur = o.cursor.applied
637+ } else {
638+ o.cursor.props(req, o.crtc, cur)
639+ }
630 }640 }
631 }641 }
632 if vrr != o.vrrOn {642 if vrr != o.vrrOn {
@@ -647,6 +657,9 @@ func (o *Output) commitState(vrr bool) error {
647 }657 }
648 if o.cursor != nil {658 if o.cursor != nil {
649 o.cursor.committed(cur)659 o.cursor.committed(cur)
660+ if cur == o.cursor.desired() {
661+ o.cursorHeld = false
662+ }
650 }663 }
651 if vrr != o.vrrOn {664 if vrr != o.vrrOn {
652 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")665 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")
@@ -656,6 +669,18 @@ func (o *Output) commitState(vrr bool) error {
656 return nil669 return nil
657 }670 }
658 671
672+// cursorMinInterval bounds how long a held cursor move waits for a frame.
673+const cursorMinInterval = time.Second / 24
674+
675+// cursorWaits reports whether a cursor move rides on the next frame
676+// instead of its own commit. Under VRR a cursor-only commit refreshes the
677+// panel at its slowest rate, which delays the game's next frame by up to
678+// one such refresh. A game that stops drawing still gets the cursor at
679+// cursorMinInterval.
680+func (o *Output) cursorWaits(now time.Time) bool {
681+ return o.vrrOn && o.vrrGame && now.Sub(o.lastFrame) < cursorMinInterval
682+}
683+
659 func boolValue(b bool) uint64 {684 func boolValue(b bool) uint64 {
660 if b {685 if b {
661 return 1686 return 1
@@ -857,11 +882,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
857 // guarantees a stopped/reset timer cannot deliver its old value.882 // guarantees a stopped/reset timer cannot deliver its old value.
858 retryTimer := time.NewTimer(time.Hour)883 retryTimer := time.NewTimer(time.Hour)
859 vrrTimer := time.NewTimer(time.Hour)884 vrrTimer := time.NewTimer(time.Hour)
885+ cursorTimer := time.NewTimer(time.Hour)
860 o.frame.startTimer()886 o.frame.startTimer()
861 retryTimer.Stop()887 retryTimer.Stop()
862 vrrTimer.Stop()888 vrrTimer.Stop()
889+ cursorTimer.Stop()
863 defer retryTimer.Stop()890 defer retryTimer.Stop()
864 defer vrrTimer.Stop()891 defer vrrTimer.Stop()
892+ defer cursorTimer.Stop()
865 defer o.frame.stopTimer()893 defer o.frame.stopTimer()
866 // seen is the latest content Seq per window. It is reported only while894 // seen is the latest content Seq per window. It is reported only while
867 // no frame is in flight: then the GPU has finished every frame that895 // no frame is in flight: then the GPU has finished every frame that
@@ -898,6 +926,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
898 } else {926 } else {
899 vrrTimer.Stop()927 vrrTimer.Stop()
900 }928 }
929+ // A held cursor move commits alone if no frame comes in time.
930+ var cursorDue <-chan time.Time
931+ if enabled && o.cursorHeld && !o.frame.pendingCommit() {
932+ cursorTimer.Reset(max(0, cursorMinInterval-time.Since(o.lastFrame)))
933+ cursorDue = cursorTimer.C
934+ } else {
935+ cursorTimer.Stop()
936+ }
901 // A commit whose event never comes must not stop the output.937 // A commit whose event never comes must not stop the output.
902 stuck := o.frame.wait(enabled)938 stuck := o.frame.wait(enabled)
903 stateDirty := false939 stateDirty := false
@@ -920,6 +956,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
920 stateDirty = true956 stateDirty = true
921 case <-vrrOff:957 case <-vrrOff:
922 stateDirty = true958 stateDirty = true
959+ case <-cursorDue:
960+ stateDirty = true
923 case <-cursorWake:961 case <-cursorWake:
924 stateDirty = true962 stateDirty = true
925 case <-ctx.Done():963 case <-ctx.Done():
@@ -1149,7 +1187,7 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
1149 return false1187 return false
1150 }1188 }
1151 if age := time.Since(start); age > 20*time.Millisecond {1189 if age := time.Since(start); age > 20*time.Millisecond {
1152- o.log.Info().Dur("flip_ms", age).Str("connector", o.conn.name).Msg("slow flip")1190+ o.log.Info().Dur("flip_ms", age).Bool("frame", f.frame).Bool("vrr", o.vrrOn).Str("connector", o.conn.name).Msg("slow flip")
1153 }1191 }
1154 if o.cursor != nil {1192 if o.cursor != nil {
1155 o.cursor.landed()1193 o.cursor.landed()
M internal/adapters/drm/output_test.go +49 -0
@@ -192,6 +192,55 @@ func TestCursorCommitWaitsForEvent(t *testing.T) {
192192 }
193193 }
194194
195+// Under VRR with a game shown, a cursor move rides on the next frame: a
196+// cursor-only commit would hold the panel for its slowest refresh. A game
197+// that stops drawing still gets the cursor after cursorMinInterval.
198+func TestVRRGameCursorWaitsForFrame(t *testing.T) {
199+ o, _, commits := testOutput(t)
200+ o.cursor.image = true
201+ o.vrrOn, o.vrrGame = true, true
202+ o.lastFrame = time.Now()
203+ o.cursor.Move(5, 5)
204+ if err := o.commitState(o.stateVRR()); err != nil {
205+ t.Fatal(err)
206+ }
207+ if len(*commits) != 0 || !o.cursorHeld || o.frame.pendingCommit() {
208+ t.Fatalf("cursor committed alone: %d commits held=%v", len(*commits), o.cursorHeld)
209+ }
210+ // The next frame carries the cursor.
211+ if err := o.commitFrame(70, nil, false, true, pendingFrame{}); err != nil {
212+ t.Fatal(err)
213+ }
214+ if v, _ := (*commits)[0].req.value(tCursor, 14); v != 5 || o.cursorHeld {
215+ t.Fatalf("frame cursor x %d held=%v", v, o.cursorHeld)
216+ }
217+ o.completed(eventOf((*commits)[0]), map[ports.WindowID]uint64{})
218+ // The game stalls: the cursor commits alone after the interval.
219+ o.lastFrame = time.Now().Add(-cursorMinInterval)
220+ o.cursor.Move(8, 8)
221+ if err := o.commitState(o.stateVRR()); err != nil {
222+ t.Fatal(err)
223+ }
224+ if len(*commits) != 2 || o.cursorHeld {
225+ t.Fatalf("stalled game: %d commits held=%v", len(*commits), o.cursorHeld)
226+ }
227+ if v, _ := (*commits)[1].req.value(tCursor, 14); v != 8 {
228+ t.Fatalf("cursor x %d", v)
229+ }
230+}
231+
232+// Without a game, the cursor keeps its own commits.
233+func TestCursorCommitsAloneWithoutGame(t *testing.T) {
234+ o, _, commits := testOutput(t)
235+ o.cursor.image = true
236+ o.vrrOn, o.lastFrame = true, time.Now()
237+ o.composedSince = time.Now()
238+ o.cursor.Move(5, 5)
239+ if err := o.commitState(o.stateVRR()); err != nil || len(*commits) != 1 || o.cursorHeld {
240+ t.Fatalf("err %v, %d commits held=%v", err, len(*commits), o.cursorHeld)
241+ }
242+}
243+
195244 func TestBusyCommitStaysPending(t *testing.T) {
196245 o, _, _ := testOutput(t, unix.EBUSY)
197246 err := o.commitFrame(70, nil, false, false, pendingFrame{})
@@ -192,6 +192,55 @@ func TestCursorCommitWaitsForEvent(t *testing.T) {
192 }192 }
193 }193 }
194 194
195+// Under VRR with a game shown, a cursor move rides on the next frame: a
196+// cursor-only commit would hold the panel for its slowest refresh. A game
197+// that stops drawing still gets the cursor after cursorMinInterval.
198+func TestVRRGameCursorWaitsForFrame(t *testing.T) {
199+ o, _, commits := testOutput(t)
200+ o.cursor.image = true
201+ o.vrrOn, o.vrrGame = true, true
202+ o.lastFrame = time.Now()
203+ o.cursor.Move(5, 5)
204+ if err := o.commitState(o.stateVRR()); err != nil {
205+ t.Fatal(err)
206+ }
207+ if len(*commits) != 0 || !o.cursorHeld || o.frame.pendingCommit() {
208+ t.Fatalf("cursor committed alone: %d commits held=%v", len(*commits), o.cursorHeld)
209+ }
210+ // The next frame carries the cursor.
211+ if err := o.commitFrame(70, nil, false, true, pendingFrame{}); err != nil {
212+ t.Fatal(err)
213+ }
214+ if v, _ := (*commits)[0].req.value(tCursor, 14); v != 5 || o.cursorHeld {
215+ t.Fatalf("frame cursor x %d held=%v", v, o.cursorHeld)
216+ }
217+ o.completed(eventOf((*commits)[0]), map[ports.WindowID]uint64{})
218+ // The game stalls: the cursor commits alone after the interval.
219+ o.lastFrame = time.Now().Add(-cursorMinInterval)
220+ o.cursor.Move(8, 8)
221+ if err := o.commitState(o.stateVRR()); err != nil {
222+ t.Fatal(err)
223+ }
224+ if len(*commits) != 2 || o.cursorHeld {
225+ t.Fatalf("stalled game: %d commits held=%v", len(*commits), o.cursorHeld)
226+ }
227+ if v, _ := (*commits)[1].req.value(tCursor, 14); v != 8 {
228+ t.Fatalf("cursor x %d", v)
229+ }
230+}
231+
232+// Without a game, the cursor keeps its own commits.
233+func TestCursorCommitsAloneWithoutGame(t *testing.T) {
234+ o, _, commits := testOutput(t)
235+ o.cursor.image = true
236+ o.vrrOn, o.lastFrame = true, time.Now()
237+ o.composedSince = time.Now()
238+ o.cursor.Move(5, 5)
239+ if err := o.commitState(o.stateVRR()); err != nil || len(*commits) != 1 || o.cursorHeld {
240+ t.Fatalf("err %v, %d commits held=%v", err, len(*commits), o.cursorHeld)
241+ }
242+}
243+
195 func TestBusyCommitStaysPending(t *testing.T) {244 func TestBusyCommitStaysPending(t *testing.T) {
196 o, _, _ := testOutput(t, unix.EBUSY)245 o, _, _ := testOutput(t, unix.EBUSY)
197 err := o.commitFrame(70, nil, false, false, pendingFrame{})246 err := o.commitFrame(70, nil, false, false, pendingFrame{})

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 26

Tags 10

↑↓ moveenter openesc close