detached 777e1e0

fix(scale): configure layers from committed state, keep output choice, copy near-size buffers

Scale changes re-configure fill-sized layers from committed state instead of replaying pending requests. A scale-only output line no longer selects the connector. Buffers within 1px of their physical slot are copied 1:1 and clipped instead of resampled. A user bind on a digit replaces the default bound to the same physical key.
commit
777e1e0dedbf2ff7131dc1e647017e9b76f7b4c4
author
brice · · 2026-09-24 15:03 UTC
parent
8b505f8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +157 -22
M internal/adapters/config/config.go +21 -1
@@ -187,7 +187,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
187187 return &c.Outputs[i]
188188 }
189189 outputs[name] = len(c.Outputs)
190- c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name})
190+ c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true})
191191 return &c.Outputs[len(c.Outputs)-1]
192192 }
193193 if base, ok := strings.CutSuffix(name, ".scale"); ok {
@@ -225,6 +225,26 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
225225 if err := scanner.Err(); err != nil {
226226 return c, raw, warnings, err
227227 }
228+ // A user bind on a digit (cmd+1, even "none") replaces the default bound
229+ // to the same physical key (cmd+code:2), so older configs keep working.
230+ for combo := range c.Binds {
231+ if seen["bind."+combo] == 0 {
232+ continue
233+ }
234+ parts := strings.Split(combo, "+")
235+ key := parts[len(parts)-1]
236+ if len(key) != 1 || key[0] < '0' || key[0] > '9' {
237+ continue
238+ }
239+ code := int(key[0]-'0') + 1
240+ if key == "0" {
241+ code = 11
242+ }
243+ parts[len(parts)-1] = "code:" + strconv.Itoa(code)
244+ if physical := strings.Join(parts, "+"); seen["bind."+physical] == 0 {
245+ delete(c.Binds, physical)
246+ }
247+ }
228248 // "none" only removes a default bind.
229249 for combo, a := range c.Binds {
230250 if a == "none" {
@@ -187,7 +187,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
187 return &c.Outputs[i]187 return &c.Outputs[i]
188 }188 }
189 outputs[name] = len(c.Outputs)189 outputs[name] = len(c.Outputs)
190- c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name})190+ c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true})
191 return &c.Outputs[len(c.Outputs)-1]191 return &c.Outputs[len(c.Outputs)-1]
192 }192 }
193 if base, ok := strings.CutSuffix(name, ".scale"); ok {193 if base, ok := strings.CutSuffix(name, ".scale"); ok {
@@ -225,6 +225,26 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
225 if err := scanner.Err(); err != nil {225 if err := scanner.Err(); err != nil {
226 return c, raw, warnings, err226 return c, raw, warnings, err
227 }227 }
228+ // A user bind on a digit (cmd+1, even "none") replaces the default bound
229+ // to the same physical key (cmd+code:2), so older configs keep working.
230+ for combo := range c.Binds {
231+ if seen["bind."+combo] == 0 {
232+ continue
233+ }
234+ parts := strings.Split(combo, "+")
235+ key := parts[len(parts)-1]
236+ if len(key) != 1 || key[0] < '0' || key[0] > '9' {
237+ continue
238+ }
239+ code := int(key[0]-'0') + 1
240+ if key == "0" {
241+ code = 11
242+ }
243+ parts[len(parts)-1] = "code:" + strconv.Itoa(code)
244+ if physical := strings.Join(parts, "+"); seen["bind."+physical] == 0 {
245+ delete(c.Binds, physical)
246+ }
247+ }
228 // "none" only removes a default bind.248 // "none" only removes a default bind.
229 for combo, a := range c.Binds {249 for combo, a := range c.Binds {
230 if a == "none" {250 if a == "none" {
M internal/adapters/config/config_test.go +16 -0
@@ -217,3 +217,19 @@ func TestParseMode(t *testing.T) {
217217 t.Fatal(hz, err)
218218 }
219219 }
220+
221+func TestDigitBindReplacesPhysicalDefault(t *testing.T) {
222+ c, w := parseString(t, "bind.cmd+1 = none\nbind.cmd+shift+2 = quit\n")
223+ if len(w) != 0 {
224+ t.Fatal(w)
225+ }
226+ if _, ok := c.Binds["Cmd+code:2"]; ok {
227+ t.Fatal("default cmd+code:2 kept")
228+ }
229+ if _, ok := c.Binds["Cmd+Shift+code:3"]; ok || c.Binds["Cmd+Shift+2"] != "quit" {
230+ t.Fatal(c.Binds)
231+ }
232+ if c.Binds["Cmd+code:4"] != "focus-workspace 3" {
233+ t.Fatal("other defaults must stay")
234+ }
235+}
@@ -217,3 +217,19 @@ func TestParseMode(t *testing.T) {
217 t.Fatal(hz, err)217 t.Fatal(hz, err)
218 }218 }
219 }219 }
220+
221+func TestDigitBindReplacesPhysicalDefault(t *testing.T) {
222+ c, w := parseString(t, "bind.cmd+1 = none\nbind.cmd+shift+2 = quit\n")
223+ if len(w) != 0 {
224+ t.Fatal(w)
225+ }
226+ if _, ok := c.Binds["Cmd+code:2"]; ok {
227+ t.Fatal("default cmd+code:2 kept")
228+ }
229+ if _, ok := c.Binds["Cmd+Shift+code:3"]; ok || c.Binds["Cmd+Shift+2"] != "quit" {
230+ t.Fatal(c.Binds)
231+ }
232+ if c.Binds["Cmd+code:4"] != "focus-workspace 3" {
233+ t.Fatal("other defaults must stay")
234+ }
235+}
M internal/adapters/vulkan/renderer.go +18 -1
@@ -368,7 +368,24 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
368368 }
369369 // Buffer pixels per logical pixel on each axis.
370370 src := image.Rect(0, 0, vw*content.Width/lw, vh*content.Height/lh)
371- addContent(physRect(x, y, vw, vh), src, content)
371+ dst := physRect(x, y, vw, vh)
372+ // A buffer drawn at the physical size (fractional-scale clients round
373+ // w*scale, we round per edge) is copied 1:1, never resampled for 1px.
374+ near := func(a, b int) bool { return a-b <= 1 && b-a <= 1 }
375+ if near(src.Dx(), dst.Dx()) && near(src.Dy(), dst.Dy()) {
376+ dst.Max = dst.Min.Add(src.Size())
377+ // Clip to the body: the buffer may be 1px wider than the slot.
378+ clip := physRect(x, y, w, h)
379+ if dst.Max.X > clip.Max.X {
380+ src.Max.X -= dst.Max.X - clip.Max.X
381+ dst.Max.X = clip.Max.X
382+ }
383+ if dst.Max.Y > clip.Max.Y {
384+ src.Max.Y -= dst.Max.Y - clip.Max.Y
385+ dst.Max.Y = clip.Max.Y
386+ }
387+ }
388+ addContent(dst, src, content)
372389 }
373390 fullscreen := false
374391 for _, w := range s.Windows {
@@ -368,7 +368,24 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
368 }368 }
369 // Buffer pixels per logical pixel on each axis.369 // Buffer pixels per logical pixel on each axis.
370 src := image.Rect(0, 0, vw*content.Width/lw, vh*content.Height/lh)370 src := image.Rect(0, 0, vw*content.Width/lw, vh*content.Height/lh)
371- addContent(physRect(x, y, vw, vh), src, content)371+ dst := physRect(x, y, vw, vh)
372+ // A buffer drawn at the physical size (fractional-scale clients round
373+ // w*scale, we round per edge) is copied 1:1, never resampled for 1px.
374+ near := func(a, b int) bool { return a-b <= 1 && b-a <= 1 }
375+ if near(src.Dx(), dst.Dx()) && near(src.Dy(), dst.Dy()) {
376+ dst.Max = dst.Min.Add(src.Size())
377+ // Clip to the body: the buffer may be 1px wider than the slot.
378+ clip := physRect(x, y, w, h)
379+ if dst.Max.X > clip.Max.X {
380+ src.Max.X -= dst.Max.X - clip.Max.X
381+ dst.Max.X = clip.Max.X
382+ }
383+ if dst.Max.Y > clip.Max.Y {
384+ src.Max.Y -= dst.Max.Y - clip.Max.Y
385+ dst.Max.Y = clip.Max.Y
386+ }
387+ }
388+ addContent(dst, src, content)
372 }389 }
373 fullscreen := false390 fullscreen := false
374 for _, w := range s.Windows {391 for _, w := range s.Windows {
M internal/adapters/vulkan/renderer_test.go +60 -0
@@ -187,3 +187,63 @@ func TestRendererLayers(t *testing.T) {
187187 delete(contents, 2)
188188 check(color.RGBA{16, 32, 48, 255})
189189 }
190+
191+// solid returns a w×h B8G8R8A8 buffer of one color.
192+func solid(w, h int, c [3]uint8) []byte {
193+ p := make([]byte, w*h*4)
194+ for i := 0; i < len(p); i += 4 {
195+ p[i], p[i+1], p[i+2], p[i+3] = c[2], c[1], c[0], 255
196+ }
197+ return p
198+}
199+
200+func TestRendererScale(t *testing.T) {
201+ r, err := New(64, 48)
202+ if err != nil {
203+ t.Skipf("Vulkan unavailable: %v", err)
204+ }
205+ defer r.Close()
206+ red := [3]uint8{200, 0, 0}
207+ // Scale 2: a 10x10 logical window at (4,4) covers physical (8,8)-(28,28);
208+ // its 20x20 buffer (fractional client) is copied 1:1.
209+ s := ports.Scene{Scale: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 10}, Borderless: true}}}
210+ content := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Width: 20, Height: 20, LogicalW: 10, LogicalH: 10, Stride: 80, Pixels: solid(20, 20, red)}}
211+ if err := r.Render(s, content); err != nil {
212+ t.Fatal(err)
213+ }
214+ px := r.Pixels()
215+ want := color.RGBA{200, 0, 0, 255}
216+ for _, p := range []image.Point{{8, 8}, {27, 27}} {
217+ if got := px.At(p.X, p.Y); got != want {
218+ t.Errorf("%v = %v", p, got)
219+ }
220+ }
221+ for _, p := range []image.Point{{7, 7}, {28, 28}} {
222+ if got := px.At(p.X, p.Y); got != (color.RGBA{0, 0, 0, 255}) {
223+ t.Errorf("outside %v = %v", p, got)
224+ }
225+ }
226+ // Scale 1.5 with an integer-scale client (buffer scale 2): the 20x20
227+ // buffer is 10x10 logical and downscaled to 15x15 physical at (6,6).
228+ s.Scale = 1.5
229+ if err := r.Render(s, content); err != nil {
230+ t.Fatal(err)
231+ }
232+ px = r.Pixels()
233+ for _, p := range []image.Point{{6, 6}, {20, 20}} {
234+ if got := px.At(p.X, p.Y); got != want {
235+ t.Errorf("1.5 %v = %v", p, got)
236+ }
237+ }
238+ if got := px.At(21, 21); got != (color.RGBA{0, 0, 0, 255}) {
239+ t.Errorf("1.5 outside = %v", got)
240+ }
241+ // A buffer 1px wider than its slot is clipped, not resampled.
242+ content[1] = ports.SurfaceContent{ID: 1, Width: 16, Height: 15, LogicalW: 10, LogicalH: 10, Stride: 64, Pixels: solid(16, 15, red)}
243+ if err := r.Render(s, content); err != nil {
244+ t.Fatal(err)
245+ }
246+ if got := r.Pixels().At(21, 10); got != (color.RGBA{0, 0, 0, 255}) {
247+ t.Errorf("clipped column = %v", got)
248+ }
249+}
@@ -187,3 +187,63 @@ func TestRendererLayers(t *testing.T) {
187 delete(contents, 2)187 delete(contents, 2)
188 check(color.RGBA{16, 32, 48, 255})188 check(color.RGBA{16, 32, 48, 255})
189 }189 }
190+
191+// solid returns a w×h B8G8R8A8 buffer of one color.
192+func solid(w, h int, c [3]uint8) []byte {
193+ p := make([]byte, w*h*4)
194+ for i := 0; i < len(p); i += 4 {
195+ p[i], p[i+1], p[i+2], p[i+3] = c[2], c[1], c[0], 255
196+ }
197+ return p
198+}
199+
200+func TestRendererScale(t *testing.T) {
201+ r, err := New(64, 48)
202+ if err != nil {
203+ t.Skipf("Vulkan unavailable: %v", err)
204+ }
205+ defer r.Close()
206+ red := [3]uint8{200, 0, 0}
207+ // Scale 2: a 10x10 logical window at (4,4) covers physical (8,8)-(28,28);
208+ // its 20x20 buffer (fractional client) is copied 1:1.
209+ s := ports.Scene{Scale: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 10}, Borderless: true}}}
210+ content := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Width: 20, Height: 20, LogicalW: 10, LogicalH: 10, Stride: 80, Pixels: solid(20, 20, red)}}
211+ if err := r.Render(s, content); err != nil {
212+ t.Fatal(err)
213+ }
214+ px := r.Pixels()
215+ want := color.RGBA{200, 0, 0, 255}
216+ for _, p := range []image.Point{{8, 8}, {27, 27}} {
217+ if got := px.At(p.X, p.Y); got != want {
218+ t.Errorf("%v = %v", p, got)
219+ }
220+ }
221+ for _, p := range []image.Point{{7, 7}, {28, 28}} {
222+ if got := px.At(p.X, p.Y); got != (color.RGBA{0, 0, 0, 255}) {
223+ t.Errorf("outside %v = %v", p, got)
224+ }
225+ }
226+ // Scale 1.5 with an integer-scale client (buffer scale 2): the 20x20
227+ // buffer is 10x10 logical and downscaled to 15x15 physical at (6,6).
228+ s.Scale = 1.5
229+ if err := r.Render(s, content); err != nil {
230+ t.Fatal(err)
231+ }
232+ px = r.Pixels()
233+ for _, p := range []image.Point{{6, 6}, {20, 20}} {
234+ if got := px.At(p.X, p.Y); got != want {
235+ t.Errorf("1.5 %v = %v", p, got)
236+ }
237+ }
238+ if got := px.At(21, 21); got != (color.RGBA{0, 0, 0, 255}) {
239+ t.Errorf("1.5 outside = %v", got)
240+ }
241+ // A buffer 1px wider than its slot is clipped, not resampled.
242+ content[1] = ports.SurfaceContent{ID: 1, Width: 16, Height: 15, LogicalW: 10, LogicalH: 10, Stride: 64, Pixels: solid(16, 15, red)}
243+ if err := r.Render(s, content); err != nil {
244+ t.Fatal(err)
245+ }
246+ if got := r.Pixels().At(21, 10); got != (color.RGBA{0, 0, 0, 255}) {
247+ t.Errorf("clipped column = %v", got)
248+ }
249+}
M internal/adapters/wayland/layer.go +18 -13
@@ -147,6 +147,23 @@ func (s *Server) layerChanged() {
147147 sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
148148 s.emit(ports.LayerChanged{Layers: list})
149149 }
150+
151+// sendConfigure sizes the surface from committed state; zero means "fill",
152+// the logical output size.
153+func (l *layerSurface) sendConfigure() {
154+ w, h := l.current.width, l.current.height
155+ if w == 0 {
156+ w = uint32(l.shell.server.scale.width)
157+ }
158+ if h == 0 {
159+ h = uint32(l.shell.server.scale.height)
160+ }
161+ l.shell.server.serial++
162+ l.resource.SendConfigure(l.shell.server.serial, w, h)
163+ l.serials = append(l.serials, l.shell.server.serial)
164+ l.configured = true
165+}
166+
150167 func (l *layerSurface) commit(buffer bool) {
151168 if l.surface.destroyed {
152169 l.unmap()
@@ -162,19 +179,7 @@ func (l *layerSurface) commit(buffer bool) {
162179 sizeChanged := p.width != l.current.width || p.height != l.current.height
163180 l.current = p
164181 if !l.configured || sizeChanged {
165- // Zero means "fill": the logical output size.
166- w, h := p.width, p.height
167- if w == 0 {
168- w = uint32(l.shell.server.scale.width)
169- }
170- if h == 0 {
171- h = uint32(l.shell.server.scale.height)
172- }
173- l.shell.server.serial++
174- l.resource.SendConfigure(l.shell.server.serial, w, h)
175- l.serials = append(l.serials, l.shell.server.serial)
176- l.configured = true
177-
182+ l.sendConfigure()
178183 }
179184 if !buffer {
180185 if l.mapped {
@@ -147,6 +147,23 @@ func (s *Server) layerChanged() {
147 sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })147 sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
148 s.emit(ports.LayerChanged{Layers: list})148 s.emit(ports.LayerChanged{Layers: list})
149 }149 }
150+
151+// sendConfigure sizes the surface from committed state; zero means "fill",
152+// the logical output size.
153+func (l *layerSurface) sendConfigure() {
154+ w, h := l.current.width, l.current.height
155+ if w == 0 {
156+ w = uint32(l.shell.server.scale.width)
157+ }
158+ if h == 0 {
159+ h = uint32(l.shell.server.scale.height)
160+ }
161+ l.shell.server.serial++
162+ l.resource.SendConfigure(l.shell.server.serial, w, h)
163+ l.serials = append(l.serials, l.shell.server.serial)
164+ l.configured = true
165+}
166+
150 func (l *layerSurface) commit(buffer bool) {167 func (l *layerSurface) commit(buffer bool) {
151 if l.surface.destroyed {168 if l.surface.destroyed {
152 l.unmap()169 l.unmap()
@@ -162,19 +179,7 @@ func (l *layerSurface) commit(buffer bool) {
162 sizeChanged := p.width != l.current.width || p.height != l.current.height179 sizeChanged := p.width != l.current.width || p.height != l.current.height
163 l.current = p180 l.current = p
164 if !l.configured || sizeChanged {181 if !l.configured || sizeChanged {
165- // Zero means "fill": the logical output size.182+ l.sendConfigure()
166- w, h := p.width, p.height
167- if w == 0 {
168- w = uint32(l.shell.server.scale.width)
169- }
170- if h == 0 {
171- h = uint32(l.shell.server.scale.height)
172- }
173- l.shell.server.serial++
174- l.resource.SendConfigure(l.shell.server.serial, w, h)
175- l.serials = append(l.serials, l.shell.server.serial)
176- l.configured = true
177-
178 }183 }
179 if !buffer {184 if !buffer {
180 if l.mapped {185 if l.mapped {
M internal/adapters/wayland/scale.go +2 -2
@@ -58,9 +58,9 @@ func (s *Server) setOutputScale(c ports.SetOutputScale) {
5858 }
5959 // Layer surfaces sized from the output follow its new logical size.
6060 for _, l := range s.layers {
61+ // Uses committed state only: pending requests wait for the client's commit.
6162 if l.configured && (l.current.width == 0 || l.current.height == 0) {
62- l.configured = false
63- l.commit(l.surface.current != nil)
63+ l.sendConfigure()
6464 }
6565 }
6666 }
@@ -58,9 +58,9 @@ func (s *Server) setOutputScale(c ports.SetOutputScale) {
58 }58 }
59 // Layer surfaces sized from the output follow its new logical size.59 // Layer surfaces sized from the output follow its new logical size.
60 for _, l := range s.layers {60 for _, l := range s.layers {
61+ // Uses committed state only: pending requests wait for the client's commit.
61 if l.configured && (l.current.width == 0 || l.current.height == 0) {62 if l.configured && (l.current.width == 0 || l.current.height == 0) {
62- l.configured = false63+ l.sendConfigure()
63- l.commit(l.surface.current != nil)
64 }64 }
65 }65 }
66 }66 }
M internal/app/drm.go +1 -1
@@ -89,7 +89,7 @@ func wantFromConfig(outputs []ports.OutputConfig) drm.Want {
8989 want.Disabled[o.Name] = true
9090 continue
9191 }
92- if want.Name != "" {
92+ if want.Name != "" || o.ScaleOnly {
9393 continue
9494 }
9595 want.Name = o.Name
@@ -89,7 +89,7 @@ func wantFromConfig(outputs []ports.OutputConfig) drm.Want {
89 want.Disabled[o.Name] = true89 want.Disabled[o.Name] = true
90 continue90 continue
91 }91 }
92- if want.Name != "" {92+ if want.Name != "" || o.ScaleOnly {
93 continue93 continue
94 }94 }
95 want.Name = o.Name95 want.Name = o.Name
A internal/app/drm_scale_test.go +14 -0
@@ -0,0 +1,14 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+func TestScaleOnlyDoesNotSelectOutput(t *testing.T) {
10+ want := wantFromConfig([]ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true}, {Name: "HDMI-A-1", Mode: "1920x1080"}})
11+ if want.Name != "HDMI-A-1" || want.W != 1920 {
12+ t.Fatalf("%+v", want)
13+ }
14+}
@@ -0,0 +1,14 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+func TestScaleOnlyDoesNotSelectOutput(t *testing.T) {
10+ want := wantFromConfig([]ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true}, {Name: "HDMI-A-1", Mode: "1920x1080"}})
11+ if want.Name != "HDMI-A-1" || want.W != 1920 {
12+ t.Fatalf("%+v", want)
13+ }
14+}
M internal/ports/config.go +7 -4
@@ -7,11 +7,14 @@ package ports
77 // connected output that is not off.
88 // Scale is the output scale (0 means 1); layout works in logical pixels,
99 // physical = logical × Scale.
10+// ScaleOnly marks an entry set by output.<name>.scale alone: it does not
11+// select the connector.
1012 type OutputConfig struct {
11- Name string
12- Mode string
13- Off bool
14- Scale float64
13+ Name string
14+ Mode string
15+ Off bool
16+ Scale float64
17+ ScaleOnly bool
1518 }
1619
1720 // Config is the parsed compositor configuration (see the config adapter for keys).
@@ -7,11 +7,14 @@ package ports
7 // connected output that is not off.7 // connected output that is not off.
8 // Scale is the output scale (0 means 1); layout works in logical pixels,8 // Scale is the output scale (0 means 1); layout works in logical pixels,
9 // physical = logical × Scale.9 // physical = logical × Scale.
10+// ScaleOnly marks an entry set by output.<name>.scale alone: it does not
11+// select the connector.
10 type OutputConfig struct {12 type OutputConfig struct {
11- Name string13+ Name string
12- Mode string14+ Mode string
13- Off bool15+ Off bool
14- Scale float6416+ Scale float64
17+ ScaleOnly bool
15 }18 }
16 19
17 // Config is the parsed compositor configuration (see the config adapter for keys).20 // Config is the parsed compositor configuration (see the config adapter for keys).

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