detached 3d61f9d

fix: resolve wrap-up review findings since v0.1.0 (#48)

* fix(core): leave a focused float before switching monitor at the column edge

* fix(core): show hidden floats when activating one of them

* style(core): gofmt fullscreen grace constant

* fix(drm): compose cropped and PQ windows instead of using the overlay plane

* fix(wayland): check color representation against the queued buffer

* fix(core): keep a hidden float's own fullscreen when activating it
commit
3d61f9dda7f37dbf5088b1687b24f338a471ba1b
author
bnema · · 2026-09-28 17:41 UTC
committer
GitHub · · 2026-09-28 17:41 UTC
parent
daacdb2
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +142 -5
M internal/adapters/drm/overlay.go +3 -1
@@ -73,7 +73,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
7373 return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"
7474 }
7575 c := surfaces[w.ID]
76- if c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 {
76+ // A viewport crop or PQ content is composed: the plane would show
77+ // the whole buffer, and raw PQ values on this SDR output.
78+ if c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() {
7779 pick = w
7880 continue
7981 }
@@ -73,7 +73,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
73 return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"73 return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"
74 }74 }
75 c := surfaces[w.ID]75 c := surfaces[w.ID]
76- if c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 {76+ // A viewport crop or PQ content is composed: the plane would show
77+ // the whole buffer, and raw PQ values on this SDR output.
78+ if c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() {
77 pick = w79 pick = w
78 continue80 continue
79 }81 }
M internal/adapters/drm/overlay_test.go +28 -0
@@ -78,6 +78,34 @@ func TestOverlayCandidate(t *testing.T) {
7878 if _, _, reason := overlayCandidate(s, c); reason != "dim" {
7979 t.Fatalf("dim reason %q", reason)
8080 }
81+ // A viewport crop is composed: the plane would show the whole buffer.
82+ s, c = overlayScene()
83+ cropped := c[2]
84+ cropped.Source = [4]float32{10, 10, 50, 50}
85+ c[2] = cropped
86+ if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
87+ t.Fatalf("cropped overlay reason %q", reason)
88+ }
89+ // A full-buffer source is no crop.
90+ cropped.Source = [4]float32{0, 0, 100, 100}
91+ c[2] = cropped
92+ if _, _, reason := overlayCandidate(s, c); reason != "" {
93+ t.Fatalf("full source reason %q", reason)
94+ }
95+}
96+
97+// The overlay is used only on SDR outputs: PQ content there needs the
98+// renderer's tone mapping.
99+func TestOverlaySkipsPQContent(t *testing.T) {
100+ o, _, _ := overlayOutput(t)
101+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
102+ s, c := overlayScene()
103+ pq := c[2]
104+ pq.Color = ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}
105+ c[2] = pq
106+ if ov, rest := o.overlayFrame(s, c); ov.fb != 0 || len(rest.Windows) != 2 {
107+ t.Fatalf("PQ overlay %+v rest %+v", ov, rest.Windows)
108+ }
81109 }
82110
83111 // The overlay is chosen and the composed scene leaves its window out;
@@ -78,6 +78,34 @@ func TestOverlayCandidate(t *testing.T) {
78 if _, _, reason := overlayCandidate(s, c); reason != "dim" {78 if _, _, reason := overlayCandidate(s, c); reason != "dim" {
79 t.Fatalf("dim reason %q", reason)79 t.Fatalf("dim reason %q", reason)
80 }80 }
81+ // A viewport crop is composed: the plane would show the whole buffer.
82+ s, c = overlayScene()
83+ cropped := c[2]
84+ cropped.Source = [4]float32{10, 10, 50, 50}
85+ c[2] = cropped
86+ if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
87+ t.Fatalf("cropped overlay reason %q", reason)
88+ }
89+ // A full-buffer source is no crop.
90+ cropped.Source = [4]float32{0, 0, 100, 100}
91+ c[2] = cropped
92+ if _, _, reason := overlayCandidate(s, c); reason != "" {
93+ t.Fatalf("full source reason %q", reason)
94+ }
95+}
96+
97+// The overlay is used only on SDR outputs: PQ content there needs the
98+// renderer's tone mapping.
99+func TestOverlaySkipsPQContent(t *testing.T) {
100+ o, _, _ := overlayOutput(t)
101+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
102+ s, c := overlayScene()
103+ pq := c[2]
104+ pq.Color = ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}
105+ c[2] = pq
106+ if ov, rest := o.overlayFrame(s, c); ov.fb != 0 || len(rest.Windows) != 2 {
107+ t.Fatalf("PQ overlay %+v rest %+v", ov, rest.Windows)
108+ }
81 }109 }
82 110
83 // The overlay is chosen and the composed scene leaves its window out;111 // The overlay is chosen and the composed scene leaves its window out;
M internal/adapters/drm/scanout.go +6 -1
@@ -102,7 +102,7 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
102102 return c, "buffer_transform"
103103 case c.Width != w || c.Height != h:
104104 return c, "size_mismatch"
105- case c.Source[2] != 0 && c.Source != [4]float32{0, 0, float32(c.Width), float32(c.Height)}:
105+ case cropped(c):
106106 // A viewport crop: the plane would show the whole buffer.
107107 return c, "source_crop"
108108 case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H:
@@ -113,6 +113,11 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
113113 return c, ""
114114 }
115115
116+// cropped reports whether a viewport source shows less than the buffer.
117+func cropped(c ports.SurfaceContent) bool {
118+ return c.Source[2] != 0 && c.Source != [4]float32{0, 0, float32(c.Width), float32(c.Height)}
119+}
120+
116121 // clientSubsurface returns the content to scan out for a window whose
117122 // root has no GPU buffer but one opaque subsurface over its geometry: Wine
118123 // draws the frame in the root (wl_shm) and presents Vulkan in a subsurface
@@ -102,7 +102,7 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
102 return c, "buffer_transform"102 return c, "buffer_transform"
103 case c.Width != w || c.Height != h:103 case c.Width != w || c.Height != h:
104 return c, "size_mismatch"104 return c, "size_mismatch"
105- case c.Source[2] != 0 && c.Source != [4]float32{0, 0, float32(c.Width), float32(c.Height)}:105+ case cropped(c):
106 // A viewport crop: the plane would show the whole buffer.106 // A viewport crop: the plane would show the whole buffer.
107 return c, "source_crop"107 return c, "source_crop"
108 case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H:108 case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H:
@@ -113,6 +113,11 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
113 return c, ""113 return c, ""
114 }114 }
115 115
116+// cropped reports whether a viewport source shows less than the buffer.
117+func cropped(c ports.SurfaceContent) bool {
118+ return c.Source[2] != 0 && c.Source != [4]float32{0, 0, float32(c.Width), float32(c.Height)}
119+}
120+
116 // clientSubsurface returns the content to scan out for a window whose121 // clientSubsurface returns the content to scan out for a window whose
117 // root has no GPU buffer but one opaque subsurface over its geometry: Wine122 // root has no GPU buffer but one opaque subsurface over its geometry: Wine
118 // draws the frame in the root (wl_shm) and presents Vulkan in a subsurface123 // draws the frame in the root (wl_shm) and presents Vulkan in a subsurface
M internal/adapters/wayland/colorrepresentation.go +10 -2
@@ -114,14 +114,22 @@ func (h *representationSurface) SetChromaLocation(r *colorrepresentation.WpColor
114114 h.surf.pendingRepresentation.chroma = uint8(chroma)
115115 }
116116
117-// The metadata is checked against the committed buffer, including when a
118-// client changes representation without attaching a new buffer.
117+// The metadata is checked against the buffer the commit will show,
118+// including when a client changes representation without attaching a new
119+// buffer: then the one of the last queued commit that attached, or the
120+// current one.
119121 func (s *surface) checkRepresentationCommit() bool {
120122 rep := s.pendingRepresentation
121123 if rep.coefficients == 0 && rep.chroma == 0 {
122124 return true
123125 }
124126 buffer := s.current
127+ for i := len(s.queue) - 1; i >= 0; i-- {
128+ if s.queue[i].attached {
129+ buffer = s.queue[i].buffer
130+ break
131+ }
132+ }
125133 if s.attached {
126134 buffer = s.pending
127135 }
@@ -114,14 +114,22 @@ func (h *representationSurface) SetChromaLocation(r *colorrepresentation.WpColor
114 h.surf.pendingRepresentation.chroma = uint8(chroma)114 h.surf.pendingRepresentation.chroma = uint8(chroma)
115 }115 }
116 116
117-// The metadata is checked against the committed buffer, including when a117+// The metadata is checked against the buffer the commit will show,
118-// client changes representation without attaching a new buffer.118+// including when a client changes representation without attaching a new
119+// buffer: then the one of the last queued commit that attached, or the
120+// current one.
119 func (s *surface) checkRepresentationCommit() bool {121 func (s *surface) checkRepresentationCommit() bool {
120 rep := s.pendingRepresentation122 rep := s.pendingRepresentation
121 if rep.coefficients == 0 && rep.chroma == 0 {123 if rep.coefficients == 0 && rep.chroma == 0 {
122 return true124 return true
123 }125 }
124 buffer := s.current126 buffer := s.current
127+ for i := len(s.queue) - 1; i >= 0; i-- {
128+ if s.queue[i].attached {
129+ buffer = s.queue[i].buffer
130+ break
131+ }
132+ }
125 if s.attached {133 if s.attached {
126 buffer = s.pending134 buffer = s.pending
127 }135 }
M internal/adapters/wayland/colorrepresentation_test.go +35 -0
@@ -149,6 +149,41 @@ func TestColorRepresentationQueuedCommit(t *testing.T) {
149149 })
150150 }
151151
152+// A representation-only commit is checked against the buffer of the last
153+// queued commit, not the applied one it will not keep.
154+func TestColorRepresentationChecksQueuedBuffer(t *testing.T) {
155+ s, c, _ := colorServer(t)
156+ manager := bindProtocol(t, c, "wp_color_representation_manager_v1")
157+ registerProtocol(t, c, manager)
158+ comp := bindProtocol(t, c, "wl_compositor")
159+ registerProtocol(t, c, comp)
160+ wl := c.AllocateID()
161+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
162+ registerProtocol(t, c, wl)
163+ obj := c.AllocateID()
164+ requestProtocol(t, c, manager, cr.WpColorRepresentationManagerV1RequestGetSurface, obj, wl)
165+ registerProtocol(t, c, obj)
166+ buf := shmBuffer(t, c)
167+ requestProtocol(t, c, wl, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
168+ if err := c.Roundtrip(); err != nil {
169+ t.Fatal(err)
170+ }
171+ s.display.Do(func() {
172+ for res, surf := range s.surfaces {
173+ if res.ID() != wl {
174+ continue
175+ }
176+ // The RGB commit waits in the queue; the next one only sets
177+ // YUV coefficients.
178+ surf.queueUpdate()
179+ surf.pendingRepresentation.coefficients = coefficient709
180+ if surf.checkRepresentationCommit() {
181+ t.Error("YUV coefficients accepted for a queued RGB buffer")
182+ }
183+ }
184+ })
185+}
186+
152187 func TestColorRepresentationRejectsRGBWithYUVCoefficients(t *testing.T) {
153188 _, c, _ := colorServer(t)
154189 manager := bindProtocol(t, c, "wp_color_representation_manager_v1")
@@ -149,6 +149,41 @@ func TestColorRepresentationQueuedCommit(t *testing.T) {
149 })149 })
150 }150 }
151 151
152+// A representation-only commit is checked against the buffer of the last
153+// queued commit, not the applied one it will not keep.
154+func TestColorRepresentationChecksQueuedBuffer(t *testing.T) {
155+ s, c, _ := colorServer(t)
156+ manager := bindProtocol(t, c, "wp_color_representation_manager_v1")
157+ registerProtocol(t, c, manager)
158+ comp := bindProtocol(t, c, "wl_compositor")
159+ registerProtocol(t, c, comp)
160+ wl := c.AllocateID()
161+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
162+ registerProtocol(t, c, wl)
163+ obj := c.AllocateID()
164+ requestProtocol(t, c, manager, cr.WpColorRepresentationManagerV1RequestGetSurface, obj, wl)
165+ registerProtocol(t, c, obj)
166+ buf := shmBuffer(t, c)
167+ requestProtocol(t, c, wl, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
168+ if err := c.Roundtrip(); err != nil {
169+ t.Fatal(err)
170+ }
171+ s.display.Do(func() {
172+ for res, surf := range s.surfaces {
173+ if res.ID() != wl {
174+ continue
175+ }
176+ // The RGB commit waits in the queue; the next one only sets
177+ // YUV coefficients.
178+ surf.queueUpdate()
179+ surf.pendingRepresentation.coefficients = coefficient709
180+ if surf.checkRepresentationCommit() {
181+ t.Error("YUV coefficients accepted for a queued RGB buffer")
182+ }
183+ }
184+ })
185+}
186+
152 func TestColorRepresentationRejectsRGBWithYUVCoefficients(t *testing.T) {187 func TestColorRepresentationRejectsRGBWithYUVCoefficients(t *testing.T) {
153 _, c, _ := colorServer(t)188 _, c, _ := colorServer(t)
154 manager := bindProtocol(t, c, "wp_color_representation_manager_v1")189 manager := bindProtocol(t, c, "wp_color_representation_manager_v1")
M internal/core/action.go +3 -1
@@ -154,7 +154,9 @@ func (c *Core) applyAction(a Action) Effect {
154154 } else {
155155 dir = 1
156156 }
157- edge := w.columnToward(dir) < 0
157+ // The first move from a float leaves it for the columns (FocusColumn),
158+ // unless a covering fullscreen window pins the focus.
159+ edge := w.columnToward(dir) < 0 && (!w.floatFocus || w.pinned())
158160 if i := c.neighbor(dir); edge && i >= 0 {
159161 c.focusScreen = i
160162 return Effect{}
@@ -154,7 +154,9 @@ func (c *Core) applyAction(a Action) Effect {
154 } else {154 } else {
155 dir = 1155 dir = 1
156 }156 }
157- edge := w.columnToward(dir) < 0157+ // The first move from a float leaves it for the columns (FocusColumn),
158+ // unless a covering fullscreen window pins the focus.
159+ edge := w.columnToward(dir) < 0 && (!w.floatFocus || w.pinned())
158 if i := c.neighbor(dir); edge && i >= 0 {160 if i := c.neighbor(dir); edge && i >= 0 {
159 c.focusScreen = i161 c.focusScreen = i
160 return Effect{}162 return Effect{}
M internal/core/core.go +1 -0
@@ -53,6 +53,7 @@ type Channels struct {
5353 // fullscreen and hide the windows it opens next. The user can still
5454 // fullscreen it (bind or taskbar), and an app can ask again later.
5555 const fullscreenGrace = time.Second
56+
5657 type binding struct {
5758 mods ports.Mods
5859 key string
@@ -53,6 +53,7 @@ type Channels struct {
53 // fullscreen and hide the windows it opens next. The user can still53 // fullscreen and hide the windows it opens next. The user can still
54 // fullscreen it (bind or taskbar), and an app can ask again later.54 // fullscreen it (bind or taskbar), and an app can ask again later.
55 const fullscreenGrace = time.Second55 const fullscreenGrace = time.Second
56+
56 type binding struct {57 type binding struct {
57 mods ports.Mods58 mods ports.Mods
58 key string59 key string
M internal/core/floating_test.go +24 -0
@@ -322,3 +322,27 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) {
322322 w.SetFullscreen(3, false)
323323 check(0.3)
324324 }
325+
326+// Activating a hidden float shows the floats and focuses it; the
327+// fullscreen window it hid behind leaves fullscreen.
328+func TestActivateHiddenFloat(t *testing.T) {
329+ w := workspace()
330+ w.AddWindow(1)
331+ w.AddFloating(2, 20, 10)
332+ w.ToggleFloatingVisible()
333+ w.FocusID(1)
334+ w.SetFullscreen(1, true)
335+ w.Activate(2)
336+ if id, _ := w.Focused(); id != 2 || w.floatsHidden || w.fullscreen != 0 {
337+ t.Fatalf("focused %d hidden %v fullscreen %d", id, w.floatsHidden, w.fullscreen)
338+ }
339+ // The float's own fullscreen, asked while hidden, comes back with it.
340+ w.FocusID(2)
341+ w.ToggleFloatingVisible()
342+ w.SetFullscreen(2, true)
343+ w.SetFullscreen(1, true)
344+ w.Activate(2)
345+ if id, _ := w.Focused(); id != 2 || w.floatsHidden || w.fullscreen != 2 {
346+ t.Fatalf("own fullscreen: focused %d hidden %v fullscreen %d", id, w.floatsHidden, w.fullscreen)
347+ }
348+}
@@ -322,3 +322,27 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) {
322 w.SetFullscreen(3, false)322 w.SetFullscreen(3, false)
323 check(0.3)323 check(0.3)
324 }324 }
325+
326+// Activating a hidden float shows the floats and focuses it; the
327+// fullscreen window it hid behind leaves fullscreen.
328+func TestActivateHiddenFloat(t *testing.T) {
329+ w := workspace()
330+ w.AddWindow(1)
331+ w.AddFloating(2, 20, 10)
332+ w.ToggleFloatingVisible()
333+ w.FocusID(1)
334+ w.SetFullscreen(1, true)
335+ w.Activate(2)
336+ if id, _ := w.Focused(); id != 2 || w.floatsHidden || w.fullscreen != 0 {
337+ t.Fatalf("focused %d hidden %v fullscreen %d", id, w.floatsHidden, w.fullscreen)
338+ }
339+ // The float's own fullscreen, asked while hidden, comes back with it.
340+ w.FocusID(2)
341+ w.ToggleFloatingVisible()
342+ w.SetFullscreen(2, true)
343+ w.SetFullscreen(1, true)
344+ w.Activate(2)
345+ if id, _ := w.Focused(); id != 2 || w.floatsHidden || w.fullscreen != 2 {
346+ t.Fatalf("own fullscreen: focused %d hidden %v fullscreen %d", id, w.floatsHidden, w.fullscreen)
347+ }
348+}
M internal/core/layout.go +7 -0
@@ -631,6 +631,13 @@ func (w *Workspace) ToggleFullscreen() {
631631 // Activate focuses a window and makes it visible: it leaves another
632632 // window's fullscreen, which hides everything else.
633633 func (w *Workspace) Activate(id WindowID) {
634+ if w.floatsHidden && w.floatIndex(id) >= 0 {
635+ // A hidden float must be seen: show the floats with it. Another
636+ // window's fullscreen leaves first, so the float's own pending
637+ // fullscreen comes back.
638+ w.fullscreen = 0
639+ w.showFloats()
640+ }
634641 if w.fullscreen != 0 && w.fullscreen != id {
635642 w.fullscreen = 0
636643 }
@@ -631,6 +631,13 @@ func (w *Workspace) ToggleFullscreen() {
631 // Activate focuses a window and makes it visible: it leaves another631 // Activate focuses a window and makes it visible: it leaves another
632 // window's fullscreen, which hides everything else.632 // window's fullscreen, which hides everything else.
633 func (w *Workspace) Activate(id WindowID) {633 func (w *Workspace) Activate(id WindowID) {
634+ if w.floatsHidden && w.floatIndex(id) >= 0 {
635+ // A hidden float must be seen: show the floats with it. Another
636+ // window's fullscreen leaves first, so the float's own pending
637+ // fullscreen comes back.
638+ w.fullscreen = 0
639+ w.showFloats()
640+ }
634 if w.fullscreen != 0 && w.fullscreen != id {641 if w.fullscreen != 0 && w.fullscreen != id {
635 w.fullscreen = 0642 w.fullscreen = 0
636 }643 }
M internal/core/screens_test.go +25 -0
@@ -854,3 +854,28 @@ func TestTerminalTokenClaimedOnce(t *testing.T) {
854854 t.Fatalf("second claim: %v", got)
855855 }
856856 }
857+
858+// From a focused float, the first column focus move leaves the float for
859+// the columns, even when the column has no neighbor on that side: it does
860+// not jump to the neighbor monitor.
861+func TestFocusColumnFromFloatStaysOnMonitor(t *testing.T) {
862+ r := startMulti(t, nil, left, right)
863+ r.mapWindow(t, 1)
864+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10}
865+ receive(t, r.scenes)
866+ set := r.key(t, "Right", ports.ModAlt)
867+ var focused []ports.WindowID
868+ for _, s := range set {
869+ for _, w := range s.Windows {
870+ if w.Focused {
871+ focused = append(focused, w.ID)
872+ if s.Output != "DP-1" {
873+ t.Fatalf("focused %d on %s", w.ID, s.Output)
874+ }
875+ }
876+ }
877+ }
878+ if !slices.Equal(focused, []ports.WindowID{1}) {
879+ t.Fatalf("focused %v", focused)
880+ }
881+}
@@ -854,3 +854,28 @@ func TestTerminalTokenClaimedOnce(t *testing.T) {
854 t.Fatalf("second claim: %v", got)854 t.Fatalf("second claim: %v", got)
855 }855 }
856 }856 }
857+
858+// From a focused float, the first column focus move leaves the float for
859+// the columns, even when the column has no neighbor on that side: it does
860+// not jump to the neighbor monitor.
861+func TestFocusColumnFromFloatStaysOnMonitor(t *testing.T) {
862+ r := startMulti(t, nil, left, right)
863+ r.mapWindow(t, 1)
864+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10}
865+ receive(t, r.scenes)
866+ set := r.key(t, "Right", ports.ModAlt)
867+ var focused []ports.WindowID
868+ for _, s := range set {
869+ for _, w := range s.Windows {
870+ if w.Focused {
871+ focused = append(focused, w.ID)
872+ if s.Output != "DP-1" {
873+ t.Fatalf("focused %d on %s", w.ID, s.Output)
874+ }
875+ }
876+ }
877+ }
878+ if !slices.Equal(focused, []ports.WindowID{1}) {
879+ t.Fatalf("focused %v", focused)
880+ }
881+}

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 25

Tags 10

↑↓ moveenter openesc close