v0.10.0 d5d679d

output_test.go RawHistoryFolder

1package headless
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"image"
8	"image/color"
9	"image/png"
10	"maps"
11	"os"
12	"path/filepath"
13	"strings"
14	"sync"
15	"testing"
16	"time"
17
18	"github.com/bnema/neferwl/internal/adapters/logging"
19	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
20	"github.com/bnema/neferwl/internal/ports"
21	"github.com/bnema/zerowrap"
22	"github.com/stretchr/testify/mock"
23)
24
25// frames records what the mocked renderer is asked to draw.
26type frames struct {
27	mu       sync.Mutex
28	scenes   []ports.Scene
29	contents []map[ports.WindowID]ports.SurfaceContent
30}
31
32func (f *frames) snapshot() ([]ports.Scene, []map[ports.WindowID]ports.SurfaceContent) {
33	f.mu.Lock()
34	defer f.mu.Unlock()
35	return append([]ports.Scene(nil), f.scenes...), append([]map[ports.WindowID]ports.SurfaceContent(nil), f.contents...)
36}
37
38// recordingRenderer returns a generated mock whose Render records each frame
39// and returns err. Close must be called exactly once.
40func recordingRenderer(t *testing.T, err error) (*portsmocks.MockRenderer, *frames) {
41	r := portsmocks.NewMockRenderer(t)
42	f := &frames{}
43	r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
44		f.mu.Lock()
45		defer f.mu.Unlock()
46		f.scenes = append(f.scenes, s)
47		f.contents = append(f.contents, maps.Clone(c))
48		return nil, err
49	}).Maybe()
50	r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe()
51	r.EXPECT().Close().Return().Once()
52	return r, f
53}
54
55// waitFrames polls until the renderer drew at least n frames.
56func waitFrames(t *testing.T, f *frames, n int) {
57	t.Helper()
58	deadline := time.After(3 * time.Second)
59	for {
60		if s, _ := f.snapshot(); len(s) >= n {
61			return
62		}
63		select {
64		case <-deadline:
65			t.Fatal("no frame")
66		case <-time.After(10 * time.Millisecond):
67		}
68	}
69}
70
71func TestRun(t *testing.T) {
72	dir := t.TempDir()
73	scenes := make(chan ports.Scene, 8)
74	contents := newContentFeed(t, 8)
75	r, f := recordingRenderer(t, nil)
76	contents.send(ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}})
77	shown := []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 2, H: 2}}}
78	scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: shown}
79	scenes <- ports.Scene{Seq: 2, OutputWidth: 2, OutputHeight: 2, Windows: shown}
80	contents.send(ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}})
81	ctx, cancel := context.WithCancel(context.Background())
82	done := make(chan error, 1)
83	go func() {
84		done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
85	}()
86	waitFrames(t, f, 1)
87	if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {
88		t.Errorf("coalescing: %+v %+v", s, c)
89	}
90	// An empty content of a window the scene still draws (it unmapped
91	// and fades out) keeps the last one; a scene without it drops it.
92	contents.send(ports.SurfaceContent{ID: 1})
93	waitFrames(t, f, 2)
94	if _, c := f.snapshot(); len(c) < 2 {
95		t.Fatal("no second frame")
96	} else if kept, ok := c[1][1]; !ok || kept.SHM == nil || kept.SHM.Pool != 1 {
97		t.Errorf("content of the shown window dropped on its empty content: %+v", c[1])
98	}
99	scenes <- ports.Scene{Seq: 3, OutputWidth: 2, OutputHeight: 2, Windows: shown[1:]}
100	waitFrames(t, f, 3)
101	if _, c := f.snapshot(); len(c) < 3 {
102		t.Fatal("no third frame")
103	} else if _, ok := c[2][1]; ok {
104		t.Error("content not deleted")
105	}
106	cancel()
107	if err := <-done; err != nil {
108		t.Fatal(err)
109	}
110	for _, name := range []string{"frame-000001.png", "frame-000002.png", "frame-000003.png", "latest.png"} {
111		file, err := os.Open(filepath.Join(dir, name))
112		if err != nil {
113			t.Fatal(err)
114		}
115		_, err = png.Decode(file)
116		_ = file.Close()
117		if err != nil {
118			t.Fatal(err)
119		}
120	}
121}
122
123// A scene whose Seq the output already holds is not drawn again.
124func TestHeadlessSameSeqRendersOnce(t *testing.T) {
125	scenes := make(chan ports.Scene)
126	contents := newContentFeed(t, 0)
127	r, f := recordingRenderer(t, nil)
128	ctx, cancel := context.WithCancel(context.Background())
129	done := make(chan error, 1)
130	go func() {
131		done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
132	}()
133	scene := ports.Scene{Seq: 5, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}}}
134	scenes <- scene
135	waitFrames(t, f, 1)
136	scenes <- scene
137	// Unshown content sends Run round its loop: the held scene is handled.
138	contents.send(ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}})
139	contents.send(ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}})
140	if s, _ := f.snapshot(); len(s) != 1 {
141		t.Fatalf("%d frames for a scene with the held Seq", len(s))
142	}
143	scene.Seq = 6
144	scenes <- scene
145	waitFrames(t, f, 2)
146	cancel()
147	if err := <-done; err != nil {
148		t.Fatal(err)
149	}
150	if s, _ := f.snapshot(); len(s) != 2 {
151		t.Fatalf("%d frames, want 2", len(s))
152	}
153}
154
155// Content of a window the output does not show draws no frame.
156func TestRunSkipsContentNotShown(t *testing.T) {
157	scenes := make(chan ports.Scene, 1)
158	contents := newContentFeed(t, 2)
159	scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 3, Hidden: true}}}
160	r, f := recordingRenderer(t, nil)
161	ctx, cancel := context.WithCancel(context.Background())
162	done := make(chan error, 1)
163	go func() {
164		done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
165	}()
166	waitFrames(t, f, 1)
167	contents.send(ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}})
168	contents.send(ports.SurfaceContent{ID: 3, SHM: &ports.SHMBuffer{Pool: 3}})
169	time.Sleep(50 * time.Millisecond)
170	if s, _ := f.snapshot(); len(s) != 1 {
171		t.Fatalf("%d frames for content not shown", len(s))
172	}
173	contents.send(ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}})
174	waitFrames(t, f, 2)
175	if _, c := f.snapshot(); c[1][2].SHM == nil {
176		t.Error("content not shown was dropped")
177	}
178	cancel()
179	if err := <-done; err != nil {
180		t.Fatal(err)
181	}
182}
183
184func TestErrors(t *testing.T) {
185	expected := errors.New("failure")
186	scenes := make(chan ports.Scene, 1)
187	scenes <- ports.Scene{}
188	if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil, nil, nil, nil); !errors.Is(err, expected) {
189		t.Fatal(err)
190	}
191	r, _ := recordingRenderer(t, expected)
192	if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil); !errors.Is(err, expected) {
193		t.Fatal(err)
194	}
195}
196
197// Each trim tick lets the idle renderer free what it no longer draws,
198// without a frame; a failed trim stops the output.
199func TestRunTrimsRendererOnTick(t *testing.T) {
200	ticks := make(chan time.Time)
201	clk := portsmocks.NewMockClock(t)
202	tk := portsmocks.NewMockTicker(t)
203	clk.EXPECT().NewTicker(trimEvery).Return(tk).Once()
204	tk.EXPECT().C().Return(ticks)
205	tk.EXPECT().Stop().Return().Once()
206	now := time.Unix(1000, 0)
207	clk.EXPECT().Now().Return(now)
208	r, f := recordingRenderer(t, nil)
209	trimmed := make(chan time.Time, 2)
210	failure := errors.New("trim failed")
211	calls := 0 // Run's goroutine only
212	r.EXPECT().Trim(now).RunAndReturn(func(at time.Time) error {
213		trimmed <- at
214		if calls++; calls == 2 {
215			return failure
216		}
217		return nil
218	}).Twice()
219	done := make(chan error, 1)
220	var statsLog bytes.Buffer // written by Run's goroutine, read after it ends
221	log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &statsLog})
222	go func() {
223		done <- Run(context.Background(), Options{Width: 2, Height: 2, Clock: clk, Log: log, Name: "HEADLESS-1", NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil, nil)
224	}()
225	ticks <- now
226	if got := <-trimmed; !got.Equal(now) {
227		t.Fatalf("trimmed at %v", got)
228	}
229	ticks <- now
230	if err := <-done; !errors.Is(err, failure) {
231		t.Fatalf("run: %v", err)
232	}
233	if s, _ := f.snapshot(); len(s) != 0 {
234		t.Fatalf("%d frames for a trim", len(s))
235	}
236	// A trim tick that succeeded logs the periodic stats once.
237	if got := strings.Count(statsLog.String(), `"message":"stats"`); got != 1 {
238		t.Fatalf("%d stats entries: %s", got, statsLog.String())
239	}
240	for _, want := range []string{`"output":"HEADLESS-1"`, `"frames":0`} {
241		if !strings.Contains(statsLog.String(), want) {
242			t.Fatalf("stats entry lacks %s: %s", want, statsLog.String())
243		}
244	}
245}
246
247func TestWritePNGAtomic(t *testing.T) {
248	dir := t.TempDir()
249	img := image.NewRGBA(image.Rect(0, 0, 1, 1))
250	for _, name := range []string{"frame-000001.png", "latest.png"} {
251		path := filepath.Join(dir, name)
252		if err := writePNGSecure(path, img, nil, ports.SecurityState{}); err != nil {
253			t.Fatal(err)
254		}
255		f, err := os.Open(path)
256		if err != nil {
257			t.Fatal(err)
258		}
259		if _, err := png.Decode(f); err != nil {
260			t.Fatal(err)
261		}
262		_ = f.Close()
263	}
264	entries, err := os.ReadDir(dir)
265	if err != nil {
266		t.Fatal(err)
267	}
268	if len(entries) != 2 {
269		t.Fatalf("unexpected files: %v", entries)
270	}
271}
272
273func TestRunPassesLayerContent(t *testing.T) {
274	scenes := make(chan ports.Scene, 1)
275	contents := newContentFeed(t, 1)
276	layer := ports.SceneLayer{ID: 42, Layer: ports.LayerTop, Rect: ports.Rect{W: 2, H: 2}}
277	scenes <- ports.Scene{Layers: []ports.SceneLayer{layer}}
278	contents.send(ports.SurfaceContent{ID: 42, Width: 1, Height: 1, SHM: &ports.SHMBuffer{Pool: 42, Stride: 4}})
279	r, f := recordingRenderer(t, nil)
280	ctx, cancel := context.WithCancel(context.Background())
281	done := make(chan error, 1)
282	go func() {
283		done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
284	}()
285	deadline := time.After(3 * time.Second)
286	for {
287		s, c := f.snapshot()
288		if len(c) > 0 && len(s[0].Layers) == 1 && c[0][42].SHM != nil {
289			break
290		}
291		select {
292		case <-deadline:
293			cancel()
294			<-done
295			t.Fatal("layer content not passed to renderer")
296		case <-time.After(10 * time.Millisecond):
297		}
298	}
299	cancel()
300	if err := <-done; err != nil {
301		t.Fatal(err)
302	}
303}
304
305func TestCaptureForcesFreshHeadlessFrame(t *testing.T) {
306	scenes := make(chan ports.Scene, 1)
307	requests := make(chan ports.CaptureRequest, 1)
308	replies := make(chan ports.CaptureDone, 1)
309	r, frames := recordingRenderer(t, nil)
310
311	frame := portsmocks.NewMockCaptureFrame(t)
312	frame.EXPECT().Done().Return(nil)
313	frame.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.MatchedBy(func(p []byte) bool { return len(p) >= 16 }), 8).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error { copy(dst, []byte{1, 2, 3, 255}); return nil }).Once()
314	r.EXPECT().BeginCapture().Return(frame, nil).Once()
315	r.EXPECT().EndCapture(frame).Return().Once()
316	ctx, cancel := context.WithCancel(context.Background())
317	done := make(chan error, 1)
318	go func() {
319		done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, requests)
320	}()
321	scenes <- ports.Scene{Background: "#000000"}
322	waitFrames(t, frames, 1)
323	f, err := os.CreateTemp(t.TempDir(), "shot")
324	if err != nil {
325		t.Fatal(err)
326	}
327	if err := f.Truncate(16); err != nil {
328		t.Fatal(err)
329	}
330	requests <- ports.CaptureRequest{ID: 1, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Dst: ports.SHMBuffer{File: f}}
331	select {
332	case result := <-replies:
333		if result.Err != nil {
334			t.Fatal(result.Err)
335		}
336	case <-time.After(3 * time.Second):
337		t.Fatal("no capture")
338	}
339	frame.Calls = nil // Testify must not inspect unmapped slice during expectation cleanup.
340	if _, err := f.Stat(); err == nil {
341		t.Fatal("descriptor not closed")
342	}
343	if s, _ := frames.snapshot(); len(s) < 2 {
344		t.Fatal("no fresh frame")
345	}
346	cancel()
347	if err := <-done; err != nil {
348		t.Fatal(err)
349	}
350}
351
352// RawHDR writes the renderer's raw PQ codes as a 16-bit latest-pq.png next
353// to the 8-bit screenshots.
354func TestHeadlessRawHDRScreenshot(t *testing.T) {
355	dir := t.TempDir()
356	want := image.NewRGBA64(image.Rect(0, 0, 2, 2))
357	for i := range 4 {
358		want.SetRGBA64(i%2, i/2, color.RGBA64{R: uint16(i*0x1111 + 0x0123), G: 0x8000 + uint16(i), B: 0xffc0, A: 0xffff})
359	}
360	f, err := os.CreateTemp(t.TempDir(), "target")
361	if err != nil {
362		t.Fatal(err)
363	}
364	r := portsmocks.NewMockRenderer(t)
365	r.EXPECT().SetHDR(float64(203)).Return().Once()
366	r.EXPECT().ExportTargets(1, []uint64(nil), false).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
367	rendered := make(chan struct{}, 4)
368	r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
369		rendered <- struct{}{}
370		return nil, nil
371	}).Maybe()
372	r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe()
373	r.EXPECT().HDRPixels().Return(want).Maybe()
374	r.EXPECT().Close().Return().Once()
375	scenes := make(chan ports.Scene, 1)
376	scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2}
377	ctx, cancel := context.WithCancel(context.Background())
378	done := make(chan error, 1)
379	go func() {
380		done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil)
381	}()
382	select {
383	case <-rendered:
384	case <-time.After(3 * time.Second):
385		t.Fatal("no frame")
386	}
387	// The files are written after Render returns.
388	path := filepath.Join(dir, "latest-pq.png")
389	deadline := time.Now().Add(3 * time.Second)
390	for {
391		if _, err := os.Stat(path); err == nil {
392			break
393		}
394		if time.Now().After(deadline) {
395			t.Fatal("no latest-pq.png")
396		}
397		time.Sleep(10 * time.Millisecond)
398	}
399	cancel()
400	if err := <-done; err != nil {
401		t.Fatal(err)
402	}
403	file, err := os.Open(path)
404	if err != nil {
405		t.Fatal(err)
406	}
407	defer file.Close()
408	got, err := png.Decode(file)
409	if err != nil {
410		t.Fatal(err)
411	}
412	if _, ok := got.(*image.NRGBA64); !ok && got.ColorModel() != color.RGBA64Model {
413		t.Fatalf("not a 16-bit image: %T", got)
414	}
415	for y := range 2 {
416		for x := range 2 {
417			if g, w := color.RGBA64Model.Convert(got.At(x, y)), want.RGBA64At(x, y); g != color.Color(w) {
418				t.Errorf("(%d,%d) = %v, want %v", x, y, g, w)
419			}
420		}
421	}
422	if _, err := os.Stat(filepath.Join(dir, "latest.png")); err != nil {
423		t.Errorf("8-bit screenshot: %v", err)
424	}
425}
426
427// Only the output which successfully prepared HDR reports confirmed HDR.
428// The fallback must explicitly withdraw it, and close exported descriptors.
429func TestHeadlessHDRFormatsConfirmed(t *testing.T) {
430	for _, success := range []bool{true, false} {
431		t.Run(map[bool]string{true: "confirmed", false: "fallback"}[success], func(t *testing.T) {
432			r := portsmocks.NewMockRenderer(t)
433			r.EXPECT().SetHDR(float64(203)).Return().Once()
434			if success {
435				f, err := os.CreateTemp(t.TempDir(), "target")
436				if err != nil {
437					t.Fatal(err)
438				}
439				r.EXPECT().ExportTargets(1, []uint64(nil), false).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
440				t.Cleanup(func() {
441					if _, err := f.Stat(); err == nil {
442						t.Error("exported fd not closed")
443					}
444				})
445			} else {
446				r.EXPECT().ExportTargets(1, []uint64(nil), false).Return(nil, errors.New("no compatible target")).Once()
447				r.EXPECT().SetHDR(float64(0)).Return().Once()
448				r.EXPECT().ExportTargets(0, []uint64(nil), false).Return(nil, nil).Once()
449			}
450			r.EXPECT().Close().Return().Once()
451			formats := make(chan ports.OutputFormats, 1)
452			ctx, cancel := context.WithCancel(context.Background())
453			done := make(chan error, 1)
454			go func() {
455				done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, Name: "HEADLESS-1", Formats: formats, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil, nil)
456			}()
457			select {
458			case f := <-formats:
459				if f.Output != "HEADLESS-1" || (f.HDR != nil) != success {
460					t.Fatalf("report %+v, success=%t", f, success)
461				}
462				if success && (f.HDR.MaxLuminance != 1000 || f.HDR.MaxFrameAverage != 400 || f.HDR.MinLuminance != .005) {
463					t.Fatalf("HDR metadata %+v", f.HDR)
464				}
465			case <-time.After(2 * time.Second):
466				t.Fatal("no format report")
467			}
468			cancel()
469			if err := <-done; err != nil {
470				t.Fatal(err)
471			}
472		})
473	}
474}
475
476// One batch with two windows applies both before the frame they show in, and
477// goes back to wayland reset and exactly once.
478func TestRunAppliesWholeBatch(t *testing.T) {
479	scenes := make(chan ports.Scene, 1)
480	contents := newContentFeed(t, 1)
481	shown := []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 2, H: 2}}}
482	scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: shown}
483	r, f := recordingRenderer(t, nil)
484	ctx, cancel := context.WithCancel(context.Background())
485	done := make(chan error, 1)
486	go func() {
487		done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
488	}()
489	waitFrames(t, f, 1)
490	contents.send(ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}}, ports.SurfaceContent{ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 2}})
491	waitFrames(t, f, 2)
492	if _, c := f.snapshot(); len(c[1]) != 2 || c[1][1].SHM == nil || c[1][2].SHM == nil {
493		t.Errorf("batch not applied whole: %+v", c[1])
494	}
495	select {
496	case b := <-contents.returned:
497		if len(b.Items) != 0 {
498			t.Errorf("batch returned with %d items", len(b.Items))
499		}
500	case <-time.After(2 * time.Second):
501		t.Fatal("batch not returned")
502	}
503	cancel()
504	if err := <-done; err != nil {
505		t.Fatal(err)
506	}
507	if len(contents.returned) != 0 {
508		t.Error("batch returned more than once")
509	}
510}

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 23

Tags 10

↑↓ moveenter openesc close