layer_input_test.go RawHistoryFolder

1package core_test
2
3import (
4	"context"
5	"testing"
6
7	"github.com/bnema/neferwl/internal/adapters/config"
8	"github.com/bnema/neferwl/internal/core"
9	"github.com/bnema/neferwl/internal/ports"
10)
11
12// next returns the next command of type T matching ok, skipping others.
13func next[T ports.ClientCommand](t *testing.T, ch <-chan ports.ClientCommand, ok func(T) bool) T {
14	t.Helper()
15	for {
16		if v, is := command(t, ch).(T); is && ok(v) {
17			return v
18		}
19	}
20}
21
22func anyOf[T any](T) bool { return true }
23
24// Bars and launchers get the pointer, popups and, on click, the keyboard.
25func TestLayerPointerPopupAndFocus(t *testing.T) {
26	cfg := config.Defaults()
27	cfg.Border.Width = 0
28	client := make(chan ports.ClientEvent, 8)
29	input := make(chan ports.InputEvent, 8)
30	output := make(chan ports.OutputEvent, 8)
31	commands := make(chan ports.ClientCommand, 256)
32	scenes := make(chan []ports.Scene, 1)
33	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
34	if err != nil {
35		t.Fatal(err)
36	}
37	ctx, cancel := context.WithCancel(context.Background())
38	defer cancel()
39	go c.Run(ctx)
40	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
41	scene(t, scenes)
42	client <- ports.WindowMapped{ID: 1}
43	scene(t, scenes)
44	if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 1 {
45		t.Fatalf("focus %d", f.ID)
46	}
47	bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 100, Height: 10, ExclusiveZone: 10, Keyboard: 2}
48	client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
49	scene(t, scenes)
50
51	// The bar gets the pointer, in its own coordinates.
52	input <- ports.PointerMotion{X: 50, Y: 5}
53	if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 6 || v.X != 50 || v.Y != 5 {
54		t.Fatalf("pointer focus %+v, want the bar", v)
55	}
56	// A click gives the on-demand bar the keyboard.
57	input <- ports.PointerButton{Button: 0x110, Pressed: true}
58	if v := next(t, commands, anyOf[ports.PointerButtonTo]); v.ID != 6 {
59		t.Fatalf("button to %d", v.ID)
60	}
61	if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 6 {
62		t.Fatalf("focus %d, want the bar", f.ID)
63	}
64	input <- ports.PointerButton{Button: 0x110}
65
66	// A menu from the bar is placed under it and slides inside the output.
67	pos := ports.Positioner{Width: 30, Height: 20, AnchorRect: ports.Rect{X: 90, Y: 0, W: 10, H: 10}, Anchor: ports.EdgeBottomLeft, Gravity: ports.EdgeBottomRight, Adjust: ports.AdjustSlideX}
68	client <- ports.PopupRequest{ID: 9, Parent: 6, Positioner: pos, Grab: true}
69	cp := next(t, commands, func(v ports.ConfigurePopup) bool { return v.ID == 9 })
70	if cp.Rect != (ports.Rect{X: 70, Y: 10, W: 30, H: 20}) {
71		t.Fatalf("popup rect %+v", cp.Rect)
72	}
73	client <- ports.PopupMapped{ID: 9}
74	sceneMatch(t, scenes, func(sc ports.Scene) bool {
75		w := sc.Windows[len(sc.Windows)-1]
76		return w.Popup && w.ID == 9 && w.Rect == (ports.Rect{X: 70, Y: 10, W: 30, H: 20})
77	})
78	if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 9 {
79		t.Fatalf("grab focus %d", f.ID)
80	}
81	input <- ports.PointerMotion{X: 80, Y: 15}
82	if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 9 || v.X != 10 || v.Y != 5 {
83		t.Fatalf("pointer focus %+v, want the menu", v)
84	}
85
86	// A click on the window closes the menu and takes the keyboard back.
87	input <- ports.PointerMotion{X: 20, Y: 50}
88	if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 1 {
89		t.Fatalf("pointer focus %+v, want the window", v)
90	}
91	input <- ports.PointerButton{Button: 0x110, Pressed: true}
92	if v := next(t, commands, anyOf[ports.ClosePopup]); v.ID != 9 {
93		t.Fatalf("closed %d", v.ID)
94	}
95	if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 1 {
96		t.Fatalf("focus %d, want the window", f.ID)
97	}
98	input <- ports.PointerButton{Button: 0x110}
99
100	// An exclusive launcher wins over a clicked on-demand bar.
101	input <- ports.PointerMotion{X: 50, Y: 5}
102	next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 6 })
103	input <- ports.PointerButton{Button: 0x110, Pressed: true}
104	next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 6 })
105	input <- ports.PointerButton{Button: 0x110}
106	launcher := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Width: 20, Height: 20, Keyboard: 1}
107	client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar, launcher}}
108	if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 7 {
109		t.Fatalf("focus %d, want the launcher", f.ID)
110	}
111	client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
112	if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 6 {
113		t.Fatalf("focus %d, want the bar back", f.ID)
114	}
115
116	// Unmapped then mapped again, the bar does not take the keyboard back.
117	client <- ports.LayerChanged{}
118	next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 1 })
119	client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
120	scene(t, scenes)
121	input <- ports.PointerMotion{X: 20, Y: 50} // publishes
122	next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 1 })
123	select {
124	case v := <-commands:
125		if f, ok := v.(ports.FocusWindow); ok {
126			t.Fatalf("focus moved to %d", f.ID)
127		}
128	default:
129	}
130
131	// A window focus change by keyboard or a new window takes it back too.
132	input <- ports.PointerMotion{X: 50, Y: 5}
133	next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 6 })
134	input <- ports.PointerButton{Button: 0x110, Pressed: true}
135	next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 6 })
136	input <- ports.PointerButton{Button: 0x110}
137	client <- ports.WindowMapped{ID: 2}
138	if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 2 {
139		t.Fatalf("focus %d, want the new window", f.ID)
140	}
141}
142
143// A window's menu stays with the windows: an overlay layer covers it.
144func TestWindowPopupUnderOverlay(t *testing.T) {
145	cfg := config.Defaults()
146	cfg.Border.Width = 0
147	client := make(chan ports.ClientEvent, 8)
148	input := make(chan ports.InputEvent, 8)
149	output := make(chan ports.OutputEvent, 8)
150	commands := make(chan ports.ClientCommand, 256)
151	scenes := make(chan []ports.Scene, 1)
152	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
153	if err != nil {
154		t.Fatal(err)
155	}
156	ctx, cancel := context.WithCancel(context.Background())
157	defer cancel()
158	go c.Run(ctx)
159	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
160	scene(t, scenes)
161	client <- ports.WindowMapped{ID: 1}
162	scene(t, scenes)
163	client <- ports.PopupRequest{ID: 9, Parent: 1, Positioner: ports.Positioner{Width: 30, Height: 20, AnchorRect: ports.Rect{X: 0, Y: 0, W: 10, H: 10}}}
164	next(t, commands, func(v ports.ConfigurePopup) bool { return v.ID == 9 })
165	client <- ports.PopupMapped{ID: 9}
166	sc := sceneMatch(t, scenes, func(sc ports.Scene) bool { return len(sc.Windows) == 2 })
167	if w := sc.Windows[1]; !w.Popup || w.OverLayers {
168		t.Fatalf("window popup %+v", w)
169	}
170	osd := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Anchor: 15}
171	client <- ports.LayerChanged{Layers: []ports.LayerSurface{osd}}
172	scene(t, scenes)
173	input <- ports.PointerMotion{X: 5, Y: 5}
174	if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != 7 {
175		t.Fatalf("hit %d, want the overlay", v.ID)
176	}
177}
178
179// A fullscreen window hides top and bottom layers from the pointer, not
180// overlay; background and bottom layers get it only where no window is.
181func TestLayerHitOrder(t *testing.T) {
182	cfg := config.Defaults()
183	cfg.Border.Width = 0
184	client := make(chan ports.ClientEvent, 8)
185	input := make(chan ports.InputEvent, 8)
186	output := make(chan ports.OutputEvent, 8)
187	commands := make(chan ports.ClientCommand, 256)
188	scenes := make(chan []ports.Scene, 1)
189	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})
190	if err != nil {
191		t.Fatal(err)
192	}
193	ctx, cancel := context.WithCancel(context.Background())
194	defer cancel()
195	go c.Run(ctx)
196	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
197	scene(t, scenes)
198	wallpaper := ports.LayerSurface{ID: 5, Layer: ports.LayerBackground, Anchor: 15}
199	bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10}
200	osd := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Anchor: ports.AnchorBottom, Width: 20, Height: 10}
201	client <- ports.LayerChanged{Layers: []ports.LayerSurface{wallpaper, bar, osd}}
202	scene(t, scenes)
203	hit := func(x, y float64, want ports.WindowID) {
204		t.Helper()
205		input <- ports.PointerMotion{X: x, Y: y}
206		// A focus change is sent only when the target changes; motion always.
207		if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != want {
208			t.Fatalf("(%v,%v) hit %d, want %d", x, y, v.ID, want)
209		}
210	}
211	hit(50, 40, 5)
212	hit(50, 5, 6)
213	client <- ports.WindowMapped{ID: 1}
214	scene(t, scenes)
215	hit(50, 40, 1)
216	hit(50, 5, 6)
217	hit(50, 75, 7)
218	// A fullscreen window is exclusive: bars and notifications are hidden.
219	client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
220	scene(t, scenes)
221	hit(50, 5, 1)
222	hit(50, 75, 1)
223}
224
225// The pointer moving to another output does not take the keyboard from a
226// clicked on-demand layer: keys still reach it.
227func TestLayerFocusAcrossOutputs(t *testing.T) {
228	cfg := config.Defaults()
229	cfg.Border.Width = 0
230	client := make(chan ports.ClientEvent, 8)
231	input := make(chan ports.InputEvent, 8)
232	output := make(chan ports.OutputEvent, 8)
233	commands := make(chan ports.ClientCommand, 256)
234	scenes := make(chan []ports.Scene, 1)
235	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
236	if err != nil {
237		t.Fatal(err)
238	}
239	ctx, cancel := context.WithCancel(context.Background())
240	defer cancel()
241	go c.Run(ctx)
242	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
243	scene(t, scenes)
244	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
245	scene(t, scenes)
246	client <- ports.WindowMapped{ID: 1}
247	scene(t, scenes)
248	bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10, Keyboard: 2, Output: "OUT-1"}
249	client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
250	scene(t, scenes)
251	input <- ports.PointerMotion{X: 50, Y: 5}
252	next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 6 })
253	input <- ports.PointerButton{Button: 0x110, Pressed: true}
254	next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 6 })
255	input <- ports.PointerButton{Button: 0x110}
256	input <- ports.PointerMotion{X: 100, Y: 40} // onto OUT-2, now focused
257	next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 0 })
258	input <- ports.KeyEvent{Keysym: "a", Pressed: true}
259	if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 6 {
260		t.Fatalf("key to %d, want the bar", v.ID)
261	}
262	input <- ports.KeyEvent{Keysym: "a"}
263	next(t, commands, anyOf[ports.ForwardKey])
264
265	// A window mapped on OUT-2 (focused there) takes the keyboard back.
266	client <- ports.WindowMapped{ID: 2}
267	next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 2 })
268	input <- ports.KeyEvent{Keysym: "b", Pressed: true}
269	if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 2 {
270		t.Fatalf("key to %d, want the new window", v.ID)
271	}
272}
273
274// A layer's transparent margin passes pointer focus through to the window.
275func TestLayerInputRegionPassThrough(t *testing.T) {
276	cfg := config.Defaults()
277	cfg.Border.Width = 0
278	client := make(chan ports.ClientEvent, 16)
279	input := make(chan ports.InputEvent, 16)
280	output := make(chan ports.OutputEvent, 16)
281	commands := make(chan ports.ClientCommand, 256)
282	scenes := make(chan []ports.Scene, 1)
283	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
284	if err != nil {
285		t.Fatal(err)
286	}
287	ctx, cancel := context.WithCancel(context.Background())
288	defer cancel()
289	go c.Run(ctx)
290	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
291	scene(t, scenes)
292	client <- ports.WindowMapped{ID: 1}
293	scene(t, scenes)
294	client <- ports.LayerChanged{Layers: []ports.LayerSurface{{ID: 6, Layer: ports.LayerOverlay, Width: 100, Height: 80}}}
295	scene(t, scenes)
296	client <- ports.InputRegionChanged{ID: 6, Rects: []ports.Rect{{X: 10, Y: 10, W: 80, H: 60}}}
297	scene(t, scenes)
298	input <- ports.PointerMotion{X: 5, Y: 5}
299	if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 1 {
300		t.Fatalf("margin focus: %+v", v)
301	}
302	input <- ports.PointerMotion{X: 20, Y: 20}
303	if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 6 {
304		t.Fatalf("interior focus: %+v", v)
305	}
306	client <- ports.InputRegionChanged{ID: 6}
307	scene(t, scenes)
308	input <- ports.PointerMotion{X: 20, Y: 21}
309	if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 1 {
310		t.Fatalf("empty focus: %+v", v)
311	}
312	client <- ports.InputRegionChanged{ID: 6, All: true}
313	scene(t, scenes)
314	input <- ports.PointerMotion{X: 5, Y: 5}
315	if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 6 {
316		t.Fatalf("reset focus: %+v", v)
317	}
318}
319
320func TestRemovedLayerInputRegionCleared(t *testing.T) {
321	cfg := config.Defaults()
322	cfg.Border.Width = 0
323	client := make(chan ports.ClientEvent, 16)
324	input := make(chan ports.InputEvent, 16)
325	output := make(chan ports.OutputEvent, 16)
326	commands := make(chan ports.ClientCommand, 256)
327	scenes := make(chan []ports.Scene, 1)
328	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
329	if err != nil {
330		t.Fatal(err)
331	}
332	ctx, cancel := context.WithCancel(context.Background())
333	defer cancel()
334	go c.Run(ctx)
335	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
336	scene(t, scenes)
337	client <- ports.WindowMapped{ID: 1}
338	scene(t, scenes)
339	client <- ports.InputRegionChanged{ID: 1}
340	scene(t, scenes) // window's empty region must survive layer changes
341	layer := ports.LayerSurface{ID: 6, Layer: ports.LayerOverlay, Width: 100, Height: 80}
342	client <- ports.LayerChanged{Layers: []ports.LayerSurface{layer}}
343	scene(t, scenes)
344	client <- ports.InputRegionChanged{ID: 6}
345	scene(t, scenes)
346	client <- ports.LayerChanged{}
347	scene(t, scenes)
348	client <- ports.LayerChanged{Layers: []ports.LayerSurface{layer}}
349	scene(t, scenes)
350	input <- ports.PointerMotion{X: 20, Y: 20}
351	if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 6 {
352		t.Fatalf("remapped layer retains old region: %+v", v)
353	}
354	client <- ports.LayerChanged{}
355	scene(t, scenes)
356	input <- ports.PointerMotion{X: 21, Y: 21}
357	if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 0 {
358		t.Fatalf("window region removed with layer: %+v", v)
359	}
360}
361
362func TestPopupInputRegionPassesThroughToWindow(t *testing.T) {
363	cfg := config.Defaults()
364	cfg.Border.Width = 0
365	client := make(chan ports.ClientEvent, 16)
366	input := make(chan ports.InputEvent, 16)
367	output := make(chan ports.OutputEvent, 16)
368	commands := make(chan ports.ClientCommand, 256)
369	scenes := make(chan []ports.Scene, 1)
370	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
371	if err != nil {
372		t.Fatal(err)
373	}
374	ctx, cancel := context.WithCancel(context.Background())
375	defer cancel()
376	go c.Run(ctx)
377	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
378	scene(t, scenes)
379	client <- ports.WindowMapped{ID: 1}
380	scene(t, scenes)
381	client <- ports.PopupRequest{ID: 9, Parent: 1, Positioner: ports.Positioner{Width: 30, Height: 20, AnchorRect: ports.Rect{X: 10, Y: 10, W: 10, H: 10}}}
382	next(t, commands, func(v ports.ConfigurePopup) bool { return v.ID == 9 })
383	client <- ports.PopupMapped{ID: 9}
384	sc := sceneMatch(t, scenes, func(sc ports.Scene) bool { return len(sc.Windows) == 2 })
385	client <- ports.InputRegionChanged{ID: 9, Rects: []ports.Rect{{X: 10, Y: 0, W: 20, H: 20}}}
386	scene(t, scenes)
387	popup := sc.Windows[1].Rect
388	input <- ports.PointerMotion{X: float64(popup.X + 2), Y: float64(popup.Y + 2)}
389	if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != 1 {
390		t.Fatalf("popup margin motion: %+v, popup %+v", v, popup)
391	}
392	input <- ports.PointerMotion{X: float64(popup.X + 12), Y: float64(popup.Y + 2)}
393	if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != 9 {
394		t.Fatalf("popup interior motion: %+v", v)
395	}
396}
397
398// A grabbing popup hidden with its parent under a fullscreen window is
399// closed, and the keyboard goes to the fullscreen window.
400func TestHiddenGrabPopupLosesKeyboard(t *testing.T) {
401	cfg := config.Defaults()
402	cfg.Border.Width = 0
403	client := make(chan ports.ClientEvent, 8)
404	output := make(chan ports.OutputEvent, 8)
405	commands := make(chan ports.ClientCommand, 64)
406	scenes := make(chan []ports.Scene, 1)
407	c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})
408	if err != nil {
409		t.Fatal(err)
410	}
411	ctx, cancel := context.WithCancel(context.Background())
412	defer cancel()
413	go c.Run(ctx)
414	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
415	<-scenes
416	client <- ports.WindowMapped{ID: 1}
417	<-scenes
418	pos := ports.Positioner{Width: 10, Height: 10, AnchorRect: ports.Rect{W: 1, H: 1}, Anchor: ports.EdgeBottomRight, Gravity: ports.EdgeBottomRight}
419	client <- ports.PopupRequest{ID: 3, Parent: 1, Positioner: pos, Grab: true}
420	next(t, commands, func(v ports.ConfigurePopup) bool { return v.ID == 3 })
421	client <- ports.PopupMapped{ID: 3}
422	if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 3 {
423		t.Fatalf("focus %d, want the grabbing popup", f.ID)
424	}
425	// A new fullscreen window covers its parent: the popup is hidden.
426	client <- ports.WindowMapped{ID: 2}
427	client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
428	next(t, commands, func(v ports.ClosePopup) bool { return v.ID == 3 })
429	if f := next(t, commands, func(v ports.FocusWindow) bool { return v.ID != 3 }); f.ID != 2 {
430		t.Fatalf("focus %d, want the fullscreen window", f.ID)
431	}
432}

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