detached 6c0dbb6

feat(ports): add temporary modifier override for clients

commit
6c0dbb620bad9980e11b56b922eaf85f8a7c0e1b
author
brice · · 2026-10-19 13:46 UTC
parent
853fbd9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +121 -8
M internal/adapters/hints/ocr.go +1 -1
@@ -237,7 +237,7 @@ func textLinks(words []OCRWord, scale float64) []ports.HintTarget {
237237 }
238238 x, y := int(math.Floor(float64(rect.Min.X)/scale)), int(math.Floor(float64(rect.Min.Y)/scale))
239239 right, bottom := int(math.Ceil(float64(rect.Max.X)/scale)), int(math.Ceil(float64(rect.Max.Y)/scale))
240- out = append(out, ports.HintTarget{Kind: ports.HintLink, Rect: ports.Rect{X: x, Y: y, W: right - x, H: bottom - y}})
240+ out = append(out, ports.HintTarget{Kind: ports.HintLink, Text: true, Rect: ports.Rect{X: x, Y: y, W: right - x, H: bottom - y}})
241241 }
242242 return out
243243 }
@@ -237,7 +237,7 @@ func textLinks(words []OCRWord, scale float64) []ports.HintTarget {
237 }237 }
238 x, y := int(math.Floor(float64(rect.Min.X)/scale)), int(math.Floor(float64(rect.Min.Y)/scale))238 x, y := int(math.Floor(float64(rect.Min.X)/scale)), int(math.Floor(float64(rect.Min.Y)/scale))
239 right, bottom := int(math.Ceil(float64(rect.Max.X)/scale)), int(math.Ceil(float64(rect.Max.Y)/scale))239 right, bottom := int(math.Ceil(float64(rect.Max.X)/scale)), int(math.Ceil(float64(rect.Max.Y)/scale))
240- out = append(out, ports.HintTarget{Kind: ports.HintLink, Rect: ports.Rect{X: x, Y: y, W: right - x, H: bottom - y}})240+ out = append(out, ports.HintTarget{Kind: ports.HintLink, Text: true, Rect: ports.Rect{X: x, Y: y, W: right - x, H: bottom - y}})
241 }241 }
242 return out242 return out
243 }243 }
M internal/adapters/wayland/keyboard_test.go +45 -1
@@ -130,13 +130,14 @@ type eventsProxy struct {
130130 opcodes []uint16
131131 keymaps []string
132132 locked []uint32 // locked modifiers of each modifiers event
133+ depress []uint32 // depressed modifiers of each modifiers event
133134 }
134135
135136 func (p *eventsProxy) Dispatch(e *wlturbo.Event) {
136137 p.opcodes = append(p.opcodes, e.Opcode)
137138 if e.Opcode == uint16(wayland.KeyboardEventModifiers) {
138139 e.Uint32() // serial
139- e.Uint32() // depressed
140+ p.depress = append(p.depress, e.Uint32())
140141 e.Uint32() // latched
141142 p.locked = append(p.locked, e.Uint32())
142143 return
@@ -254,3 +255,46 @@ func TestFootKeyboard(t *testing.T) {
254255 }
255256 }
256257 }
258+
259+// SetModifiers sends the requested depressed mask with the real latched and
260+// locked state, and Restore sends the real state back; the seat's own state
261+// is untouched. A window that is not focused is ignored.
262+func TestSetModifiersOverrideAndRestore(t *testing.T) {
263+ s, events, commands, dir := keyboardServer(t)
264+ target, proxy := focusedTarget(t, s, events, commands, dir)
265+ const capsLocked = 2
266+ var id ports.WindowID
267+ set := make(chan struct{})
268+ s.display.Do(func() { s.seat.modState = ports.ModState{Locked: capsLocked}; id = s.seat.focused; close(set) })
269+ <-set
270+ wait := func() {
271+ t.Helper()
272+ deadline := time.Now().Add(time.Second)
273+ for len(commands) > 0 && time.Now().Before(deadline) {
274+ time.Sleep(5 * time.Millisecond)
275+ }
276+ time.Sleep(20 * time.Millisecond)
277+ roundtrip(t, target)
278+ }
279+ proxy.depress, proxy.locked = nil, nil
280+ commands <- ports.SetModifiers{ID: id + 100, Mods: ports.ModCtrl}
281+ wait()
282+ if len(proxy.depress) != 0 {
283+ t.Fatalf("unfocused window got modifiers %v", proxy.depress)
284+ }
285+ commands <- ports.SetModifiers{ID: id, Mods: ports.ModCtrl | ports.ModShift}
286+ wait()
287+ if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift}) || !slices.Equal(proxy.locked, []uint32{capsLocked}) {
288+ t.Fatalf("override depressed %v locked %v", proxy.depress, proxy.locked)
289+ }
290+ commands <- ports.SetModifiers{ID: id, Restore: true}
291+ wait()
292+ if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift, 0}) || !slices.Equal(proxy.locked, []uint32{capsLocked, capsLocked}) {
293+ t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
294+ }
295+ done := make(chan ports.ModState, 1)
296+ s.display.Do(func() { done <- s.seat.modState })
297+ if m := <-done; m != (ports.ModState{Locked: capsLocked}) {
298+ t.Fatalf("seat state changed: %+v", m)
299+ }
300+}
@@ -130,13 +130,14 @@ type eventsProxy struct {
130 opcodes []uint16130 opcodes []uint16
131 keymaps []string131 keymaps []string
132 locked []uint32 // locked modifiers of each modifiers event132 locked []uint32 // locked modifiers of each modifiers event
133+ depress []uint32 // depressed modifiers of each modifiers event
133 }134 }
134 135
135 func (p *eventsProxy) Dispatch(e *wlturbo.Event) {136 func (p *eventsProxy) Dispatch(e *wlturbo.Event) {
136 p.opcodes = append(p.opcodes, e.Opcode)137 p.opcodes = append(p.opcodes, e.Opcode)
137 if e.Opcode == uint16(wayland.KeyboardEventModifiers) {138 if e.Opcode == uint16(wayland.KeyboardEventModifiers) {
138 e.Uint32() // serial139 e.Uint32() // serial
139- e.Uint32() // depressed140+ p.depress = append(p.depress, e.Uint32())
140 e.Uint32() // latched141 e.Uint32() // latched
141 p.locked = append(p.locked, e.Uint32())142 p.locked = append(p.locked, e.Uint32())
142 return143 return
@@ -254,3 +255,46 @@ func TestFootKeyboard(t *testing.T) {
254 }255 }
255 }256 }
256 }257 }
258+
259+// SetModifiers sends the requested depressed mask with the real latched and
260+// locked state, and Restore sends the real state back; the seat's own state
261+// is untouched. A window that is not focused is ignored.
262+func TestSetModifiersOverrideAndRestore(t *testing.T) {
263+ s, events, commands, dir := keyboardServer(t)
264+ target, proxy := focusedTarget(t, s, events, commands, dir)
265+ const capsLocked = 2
266+ var id ports.WindowID
267+ set := make(chan struct{})
268+ s.display.Do(func() { s.seat.modState = ports.ModState{Locked: capsLocked}; id = s.seat.focused; close(set) })
269+ <-set
270+ wait := func() {
271+ t.Helper()
272+ deadline := time.Now().Add(time.Second)
273+ for len(commands) > 0 && time.Now().Before(deadline) {
274+ time.Sleep(5 * time.Millisecond)
275+ }
276+ time.Sleep(20 * time.Millisecond)
277+ roundtrip(t, target)
278+ }
279+ proxy.depress, proxy.locked = nil, nil
280+ commands <- ports.SetModifiers{ID: id + 100, Mods: ports.ModCtrl}
281+ wait()
282+ if len(proxy.depress) != 0 {
283+ t.Fatalf("unfocused window got modifiers %v", proxy.depress)
284+ }
285+ commands <- ports.SetModifiers{ID: id, Mods: ports.ModCtrl | ports.ModShift}
286+ wait()
287+ if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift}) || !slices.Equal(proxy.locked, []uint32{capsLocked}) {
288+ t.Fatalf("override depressed %v locked %v", proxy.depress, proxy.locked)
289+ }
290+ commands <- ports.SetModifiers{ID: id, Restore: true}
291+ wait()
292+ if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift, 0}) || !slices.Equal(proxy.locked, []uint32{capsLocked, capsLocked}) {
293+ t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
294+ }
295+ done := make(chan ports.ModState, 1)
296+ s.display.Do(func() { done <- s.seat.modState })
297+ if m := <-done; m != (ports.ModState{Locked: capsLocked}) {
298+ t.Fatalf("seat state changed: %+v", m)
299+ }
300+}
M internal/adapters/wayland/seat.go +51 -0
@@ -83,6 +83,8 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
8383 target = c.ID
8484 case ports.ForwardKey:
8585 target = c.ID
86+ case ports.SetModifiers:
87+ target = c.ID
8688 }
8789 if target != 0 && !s.lockInputTarget(target) {
8890 return
@@ -177,6 +179,8 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
177179 if s.seat.focused != c.ID {
178180 s.changeFocus(c.ID)
179181 }
182+ case ports.SetModifiers:
183+ s.setModifiers(c)
180184 case ports.ForwardKey:
181185 if !(s.security != nil && s.security.Snapshot().Protected) && s.grabKey(c) {
182186 return
@@ -219,6 +223,53 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
219223 }
220224 }
221225
226+// xkb modifier masks of the default keymaps: Shift, Control, Mod1 (Alt) and
227+// Mod4 (Super). The server does not parse the keymap for its modifier indices.
228+const (
229+ xkbShift = 1 << 0
230+ xkbControl = 1 << 2
231+ xkbMod1 = 1 << 3
232+ xkbMod4 = 1 << 6
233+)
234+
235+// depressedMask maps core modifier flags to xkb depressed masks.
236+func depressedMask(m ports.Mods) uint32 {
237+ var d uint32
238+ for _, f := range []struct {
239+ flag ports.Mods
240+ mask uint32
241+ }{{ports.ModShift, xkbShift}, {ports.ModCtrl, xkbControl}, {ports.ModAlt, xkbMod1}, {ports.ModSuper, xkbMod4}} {
242+ if m&f.flag != 0 {
243+ d |= f.mask
244+ }
245+ }
246+ return d
247+}
248+
249+// setModifiers overrides, then restores, the modifiers the focused window's
250+// keyboards (or the input method grab) see. The override replaces the
251+// depressed modifiers and keeps latched, locked and group; the seat's real
252+// modState is never changed by it.
253+func (s *Server) setModifiers(c ports.SetModifiers) {
254+ _, keyboards := s.focusTarget(c.ID)
255+ if c.ID != s.seat.focused || len(keyboards) == 0 {
256+ s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored set modifiers")
257+ return
258+ }
259+ s.useKeymap(nil)
260+ s.serial++
261+ m := s.seat.modState
262+ if !c.Restore {
263+ m.Depressed = depressedMask(c.Mods)
264+ }
265+ for _, k := range keyboards {
266+ k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
267+ }
268+ if g := s.grab(); g != nil {
269+ g.res.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
270+ }
271+}
272+
222273 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
223274 if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
224275 return nil
@@ -83,6 +83,8 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
83 target = c.ID83 target = c.ID
84 case ports.ForwardKey:84 case ports.ForwardKey:
85 target = c.ID85 target = c.ID
86+ case ports.SetModifiers:
87+ target = c.ID
86 }88 }
87 if target != 0 && !s.lockInputTarget(target) {89 if target != 0 && !s.lockInputTarget(target) {
88 return90 return
@@ -177,6 +179,8 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
177 if s.seat.focused != c.ID {179 if s.seat.focused != c.ID {
178 s.changeFocus(c.ID)180 s.changeFocus(c.ID)
179 }181 }
182+ case ports.SetModifiers:
183+ s.setModifiers(c)
180 case ports.ForwardKey:184 case ports.ForwardKey:
181 if !(s.security != nil && s.security.Snapshot().Protected) && s.grabKey(c) {185 if !(s.security != nil && s.security.Snapshot().Protected) && s.grabKey(c) {
182 return186 return
@@ -219,6 +223,53 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
219 }223 }
220 }224 }
221 225
226+// xkb modifier masks of the default keymaps: Shift, Control, Mod1 (Alt) and
227+// Mod4 (Super). The server does not parse the keymap for its modifier indices.
228+const (
229+ xkbShift = 1 << 0
230+ xkbControl = 1 << 2
231+ xkbMod1 = 1 << 3
232+ xkbMod4 = 1 << 6
233+)
234+
235+// depressedMask maps core modifier flags to xkb depressed masks.
236+func depressedMask(m ports.Mods) uint32 {
237+ var d uint32
238+ for _, f := range []struct {
239+ flag ports.Mods
240+ mask uint32
241+ }{{ports.ModShift, xkbShift}, {ports.ModCtrl, xkbControl}, {ports.ModAlt, xkbMod1}, {ports.ModSuper, xkbMod4}} {
242+ if m&f.flag != 0 {
243+ d |= f.mask
244+ }
245+ }
246+ return d
247+}
248+
249+// setModifiers overrides, then restores, the modifiers the focused window's
250+// keyboards (or the input method grab) see. The override replaces the
251+// depressed modifiers and keeps latched, locked and group; the seat's real
252+// modState is never changed by it.
253+func (s *Server) setModifiers(c ports.SetModifiers) {
254+ _, keyboards := s.focusTarget(c.ID)
255+ if c.ID != s.seat.focused || len(keyboards) == 0 {
256+ s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored set modifiers")
257+ return
258+ }
259+ s.useKeymap(nil)
260+ s.serial++
261+ m := s.seat.modState
262+ if !c.Restore {
263+ m.Depressed = depressedMask(c.Mods)
264+ }
265+ for _, k := range keyboards {
266+ k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
267+ }
268+ if g := s.grab(); g != nil {
269+ g.res.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
270+ }
271+}
272+
222 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {273 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
223 if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {274 if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
224 return nil275 return nil
M internal/adapters/wayland/server.go +2 -2
@@ -752,13 +752,13 @@ func (s *Server) apply(cmd ports.ClientCommand) {
752752 // Only input/focus commands require an owner envelope. Config relay
753753 // also produces SetKeymap and other epoch-independent commands.
754754 switch cmd.(type) {
755- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey,
755+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers,
756756 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
757757 return
758758 }
759759 }
760760 switch c := cmd.(type) {
761- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey:
761+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers:
762762 s.applyInput(c)
763763 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
764764 s.applyDrag(c)
@@ -752,13 +752,13 @@ func (s *Server) apply(cmd ports.ClientCommand) {
752 // Only input/focus commands require an owner envelope. Config relay752 // Only input/focus commands require an owner envelope. Config relay
753 // also produces SetKeymap and other epoch-independent commands.753 // also produces SetKeymap and other epoch-independent commands.
754 switch cmd.(type) {754 switch cmd.(type) {
755- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey,755+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers,
756 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:756 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
757 return757 return
758 }758 }
759 }759 }
760 switch c := cmd.(type) {760 switch c := cmd.(type) {
761- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey:761+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers:
762 s.applyInput(c)762 s.applyInput(c)
763 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:763 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
764 s.applyDrag(c)764 s.applyDrag(c)
M internal/ports/client_commands.go +12 -0
@@ -289,3 +289,15 @@ type SpawnRequest struct {
289289 // Env adds KEY=value entries to the child environment.
290290 Env []string
291291 }
292+
293+// SetModifiers carries core → wayland a temporary modifier override for the
294+// focused window's keyboards: Mods are sent as depressed modifiers (mapped
295+// to the current keymap's masks), latched/locked/group kept from the real
296+// state. Mods 0 with Restore true sends the real modifier state again.
297+type SetModifiers struct {
298+ ID WindowID
299+ Mods Mods
300+ Restore bool
301+}
302+
303+func (SetModifiers) clientCommand() {}
@@ -289,3 +289,15 @@ type SpawnRequest struct {
289 // Env adds KEY=value entries to the child environment.289 // Env adds KEY=value entries to the child environment.
290 Env []string290 Env []string
291 }291 }
292+
293+// SetModifiers carries core → wayland a temporary modifier override for the
294+// focused window's keyboards: Mods are sent as depressed modifiers (mapped
295+// to the current keymap's masks), latched/locked/group kept from the real
296+// state. Mods 0 with Restore true sends the real modifier state again.
297+type SetModifiers struct {
298+ ID WindowID
299+ Mods Mods
300+ Restore bool
301+}
302+
303+func (SetModifiers) clientCommand() {}
M internal/ports/config.go +4 -1
@@ -165,7 +165,10 @@ type Config struct {
165165 Hint struct {
166166 // LinksOnly limits the targets to links and URLs; false also
167167 // includes buttons (hint.targets = clickable).
168- LinksOnly bool
168+ LinksOnly bool
169+ // TextClick are the modifiers held for a click on a link found by
170+ // OCR (hint.text-click); zero sends a plain click.
171+ TextClick Mods
169172 Size float64 // logical pixels; zero selects automatic density sizing
170173 Background, Foreground string // empty derives from Border.Active
171174 }
@@ -165,7 +165,10 @@ type Config struct {
165 Hint struct {165 Hint struct {
166 // LinksOnly limits the targets to links and URLs; false also166 // LinksOnly limits the targets to links and URLs; false also
167 // includes buttons (hint.targets = clickable).167 // includes buttons (hint.targets = clickable).
168- LinksOnly bool168+ LinksOnly bool
169+ // TextClick are the modifiers held for a click on a link found by
170+ // OCR (hint.text-click); zero sends a plain click.
171+ TextClick Mods
169 Size float64 // logical pixels; zero selects automatic density sizing172 Size float64 // logical pixels; zero selects automatic density sizing
170 Background, Foreground string // empty derives from Border.Active173 Background, Foreground string // empty derives from Border.Active
171 }174 }
M internal/ports/hint_messages.go +6 -3
@@ -39,6 +39,9 @@ type HintWindow struct {
3939 type HintTarget struct {
4040 Rect Rect // logical, output-local
4141 Kind HintKind
42+ // Text is set for targets found by OCR (read from pixels, not from an
43+ // accessibility tree).
44+ Text bool
4245 }
4346
4447 // HintScanResult answers a HintScanRequest. Core owns Targets.
@@ -51,9 +54,9 @@ type HintScanResult struct {
5154
5255 // HintLabel is a label drawn over a hint target.
5356 type HintLabel struct {
54- // Rect is the label box in logical output-local coordinates, placed at
55- // the target's top-left. W and H may be 0: the renderer sizes the box
56- // from Text.
57+ // Rect is the label box in logical output-local coordinates; X and Y are
58+ // the target's top-left corner, which the renderer anchors the label
59+ // outside of. W and H may be 0: the renderer sizes the box from Text.
5760 Rect Rect
5861 // Text is the full label, lowercase a–z.
5962 Text string
@@ -39,6 +39,9 @@ type HintWindow struct {
39 type HintTarget struct {39 type HintTarget struct {
40 Rect Rect // logical, output-local40 Rect Rect // logical, output-local
41 Kind HintKind41 Kind HintKind
42+ // Text is set for targets found by OCR (read from pixels, not from an
43+ // accessibility tree).
44+ Text bool
42 }45 }
43 46
44 // HintScanResult answers a HintScanRequest. Core owns Targets.47 // HintScanResult answers a HintScanRequest. Core owns Targets.
@@ -51,9 +54,9 @@ type HintScanResult struct {
51 54
52 // HintLabel is a label drawn over a hint target.55 // HintLabel is a label drawn over a hint target.
53 type HintLabel struct {56 type HintLabel struct {
54- // Rect is the label box in logical output-local coordinates, placed at57+ // Rect is the label box in logical output-local coordinates; X and Y are
55- // the target's top-left. W and H may be 0: the renderer sizes the box58+ // the target's top-left corner, which the renderer anchors the label
56- // from Text.59+ // outside of. W and H may be 0: the renderer sizes the box from Text.
57 Rect Rect60 Rect Rect
58 // Text is the full label, lowercase a–z.61 // Text is the full label, lowercase a–z.
59 Text string62 Text string

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