output.go RawHistoryFolder

1package headless
2
3import (
4	"context"
5	"fmt"
6	"image"
7	"image/png"
8	"maps"
9	"os"
10	"path/filepath"
11	"time"
12
13	"golang.org/x/sys/unix"
14
15	"github.com/bnema/neferwl/internal/adapters/clock"
16	"github.com/bnema/neferwl/internal/adapters/outputkit/capture"
17	"github.com/bnema/neferwl/internal/adapters/outputkit/presented"
18	"github.com/bnema/neferwl/internal/adapters/outputkit/surfaces"
19	"github.com/bnema/neferwl/internal/adapters/syncfile"
20	"github.com/bnema/neferwl/internal/ports"
21	"github.com/bnema/zerowrap"
22)
23
24type Options struct {
25	// Security is the defensive gate. SecurityChanges wakes an idle owner;
26	// the snapshot, not the queued notification payload, is authoritative.
27	Security        ports.SessionSecurity
28	SecurityChanges <-chan ports.SecurityState
29	Instance        ports.OutputInstance
30	SecurityEvents  chan<- ports.SecurityBackendEvent
31	// Cursor, when set, is drawn into screenshots with the image from
32	// LoadCursor at the scene scale and transform. The cursor is drawn in
33	// target space: the image comes rotated and Move takes target pixels.
34	Cursor        *Cursor
35	LoadCursor    func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error)
36	Width, Height int
37	ScreenshotDir string
38	HDR           bool // virtual HDR output for protocol testing only
39	// RawHDR also writes latest-pq.png, the raw PQ codes of an HDR output
40	// (16-bit), next to the screenshots. Test-only; needs HDR and ScreenshotDir.
41	RawHDR      bool
42	Formats     chan<- ports.OutputFormats // confirmed color state; nil disables reporting
43	Log         zerowrap.Logger
44	NewRenderer func(w, h int) (ports.Renderer, error)
45	// NewCaptureRenderer makes the child renderer of a hidden workspace
46	// capture session (Scene.CaptureScene). Nil: such captures fail closed.
47	NewCaptureRenderer func(w, h int) (ports.Renderer, error)
48	// Name and Presented report what the output has read after each
49	// frame, so wayland can release client buffers (nil: no reports).
50	Name      string
51	Presented ports.PresentationSink
52	// Frames tells core a presented scene is animating: its next frame is
53	// due. Sends never block (nil: nobody is told).
54	Frames   chan<- ports.OutputFrame
55	Captured chan<- ports.CaptureDone
56	// Clock paces the renderer trim (nil: system clock).
57	Clock ports.Clock
58}
59
60// trimEvery is how often an output lets its renderer free the buffers of
61// windows it no longer draws.
62const trimEvery = 10 * time.Second
63
64func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan *ports.ContentBatch, returns ports.ContentReturn, cursor <-chan ports.CursorChange, incoming <-chan ports.CaptureRequest) error {
65	r, err := opts.NewRenderer(opts.Width, opts.Height)
66	if err != nil {
67		return fmt.Errorf("create renderer: %w", err)
68	}
69	defer r.Close()
70	var confirmed *ports.OutputHDR
71	if opts.HDR {
72		r.SetHDR(203)
73		if bufs, err := r.ExportTargets(1, nil, false); err != nil {
74			opts.Log.Warn().Err(err).Str("output", opts.Name).Msg("virtual HDR unavailable; falling back to SDR")
75			r.SetHDR(0)
76			_, _ = r.ExportTargets(0, nil, false)
77		} else {
78			for _, b := range bufs {
79				for _, p := range b.Planes {
80					_ = p.File.Close()
81				}
82			}
83			confirmed = &ports.OutputHDR{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: .005}
84			opts.Log.Info().Str("output", opts.Name).Msg("virtual HDR enabled")
85		}
86		if opts.Formats != nil {
87			select {
88			case opts.Formats <- ports.OutputFormats{Output: opts.Name, HDR: confirmed}:
89			case <-ctx.Done():
90				return nil
91			}
92		}
93	}
94	table := surfaces.New()
95	drawn := table.Map()
96	var scene ports.Scene
97	haveScene, dirty := false, false
98	frame := 0
99	var requests capture.Requests
100	ctx, cancelCaptures := context.WithCancel(ctx)
101	requests.Init(ctx, opts.Captured)
102	pipeline := capture.NewPipeline(ctx, opts.Captured)
103	pipeline.Security = opts.Security
104	if opts.NewCaptureRenderer != nil {
105		pipeline.EnableOffscreen(opts.NewCaptureRenderer)
106	}
107	defer func() {
108		// An error path out of a gated frame: what it held is failed.
109		pipeline.EndGate(capture.ErrFrameNotPresented)
110		cancelCaptures()
111		pipeline.Close(r)
112		requests.Stop(incoming)
113	}()
114	var want ports.CursorChange
115	cursorScale := -1.0 // not loaded yet
116	var cursorTransform ports.BufferTransform
117	seen := map[ports.WindowID]uint64{}
118	// reports queues what the output read; every report is pushed to the
119	// sink at once (Push never blocks), so it holds one at most.
120	var reports presented.Queue
121	var shows flipShows
122	var security ports.SecurityState
123	securityKnown := false
124	failRequests := func(reason error) {
125		// Whatever this frame had handed to a worker is failed with it.
126		pipeline.EndGate(capture.GateVerdict(reason))
127		requests.FailPending(reason)
128	}
129	// A transition is its own frame, never a locker-buffer-dependent frame.
130	// Keep its epoch through the fence wait and report only actual completion.
131	checkSecurity := func() error {
132		if opts.Security == nil {
133			return nil
134		}
135		for {
136			next := opts.Security.Snapshot()
137			if securityKnown && next == security {
138				return nil
139			}
140			// No hardware scanout exists: an already applied Off scene is
141			// confirmed software inactivity, independent of its old epoch.
142			inactive := haveScene && scene.Off
143			security, securityKnown = next, true
144			reports.Reset()
145			haveScene, dirty = false, false
146			if !security.Protected {
147				continue
148			}
149			black := ports.Scene{Security: security, Output: opts.Name, OutputWidth: opts.Width, OutputHeight: opts.Height, Scale: 1, Background: "#000000"}
150			black.Off = inactive
151			scene = black
152			kind := ports.ProtectionInactiveOutput
153			if !inactive {
154				kind = ports.ProtectionProtectedFrame
155				done, err := r.Render(black, nil)
156				if err != nil {
157					return fmt.Errorf("render protection: %w", err)
158				}
159				if done != nil {
160					err = syncfile.Wait(ctx, done)
161					_ = done.Close()
162					if err != nil {
163						return fmt.Errorf("protection fence: %w", err)
164					}
165				}
166			}
167			if opts.Security.Snapshot() != security {
168				continue
169			}
170			if opts.SecurityEvents != nil {
171				proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: security.Generation, Instance: opts.Instance, Kind: kind}}
172				select {
173				case opts.SecurityEvents <- proof:
174				case <-ctx.Done():
175					return ctx.Err()
176				}
177			}
178			// Capture reply backpressure must not postpone the first black
179			// completion/proof. Capture drainage is a separate parent barrier.
180			failRequests(capture.ErrSessionProtected)
181		}
182	}
183	acceptScene := func(s ports.Scene) {
184		if opts.Security != nil && (s.Security != security || s.Security != opts.Security.Snapshot()) {
185			return
186		}
187		// The core keeps the Seq of a scene that draws the same: nothing to do.
188		if haveScene && s.Seq != 0 && s.Seq == scene.Seq && s.Security == scene.Security {
189			return
190		}
191		scene, haveScene, dirty = s, true, true
192		// A closed window's last content goes once no scene draws it.
193		table.Prune(scene)
194	}
195	update := func(b *ports.ContentBatch) {
196		for i := range b.Items {
197			c := b.Items[i]
198			seen[c.ID] = max(seen[c.ID], c.Seq)
199			dirty = dirty || capture.Shows(scene, c.ID)
200			table.Update(c, scene)
201		}
202		b.Reset()
203		returns.Return(b)
204	}
205	clk := opts.Clock
206	if clk == nil {
207		clk = clock.System{}
208	}
209	trim := clk.NewTicker(trimEvery)
210	defer trim.Stop()
211	// One timer for the wait of held requests, reset per pass (a time.After
212	// per loop pass would leave a timer behind each).
213	holdTimer := time.NewTimer(time.Hour)
214	holdTimer.Stop()
215	defer holdTimer.Stop()
216	for {
217		// A frame that left its gate open (any path out but its present) fails
218		// what it held: nothing is delivered for a frame that did not present.
219		pipeline.EndGate(capture.ErrFrameNotPresented)
220		if ctx.Err() != nil {
221			return nil
222		}
223		if err := checkSecurity(); err != nil {
224			if ctx.Err() != nil {
225				return nil
226			}
227			return err
228		}
229		// Requests wait for a scene that shows their indicator, at most
230		// capture.HoldFor (see capture.Hold).
231		var holdDue <-chan time.Time
232		if requests.Len() > 0 {
233			var wait time.Duration
234			requests.List, wait = pipeline.Expire(scene, requests.List, time.Now())
235			if wait > 0 {
236				holdTimer.Reset(wait)
237				holdDue = holdTimer.C
238			}
239		}
240		if holdDue == nil {
241			holdTimer.Stop()
242		}
243		select {
244		case <-holdDue:
245			continue
246		case <-ctx.Done():
247			return nil
248		case _, ok := <-opts.SecurityChanges:
249			if !ok {
250				opts.SecurityChanges = nil
251			}
252			if err := checkSecurity(); err != nil {
253				if ctx.Err() != nil {
254					return nil
255				}
256				return err
257			}
258			continue
259		case b := <-pipeline.Completed():
260			// The completed token has left the pipeline's queue. Return its
261			// lease before any transition path can error or block.
262			pipeline.Recycle(b, r)
263			if err := checkSecurity(); err != nil {
264				if ctx.Err() != nil {
265					return nil
266				}
267				return err
268			}
269			continue
270		case b := <-pipeline.HiddenCompleted():
271			pipeline.RecycleHidden(b)
272			if err := checkSecurity(); err != nil {
273				if ctx.Err() != nil {
274					return nil
275				}
276				return err
277			}
278			pipeline.Retire(scene)
279			continue
280		case q, ok := <-incoming:
281			if !ok {
282				incoming = nil
283				continue
284			}
285			if err := checkSecurity(); err != nil {
286				// q has left incoming but is not yet owned by requests/workers.
287				capture.Fail(ctx, q, capture.ErrOutputStopped, opts.Captured)
288				if ctx.Err() != nil {
289					return nil
290				}
291				return err
292			}
293			var refuse error
294			if opts.protected() {
295				refuse = capture.ErrSessionProtected
296			} else if scene.Off {
297				refuse = capture.ErrOutputOff
298			}
299			dirty = requests.Admit(q, refuse, scene, haveScene) || dirty
300		case <-trim.C():
301			if err := checkSecurity(); err != nil {
302				if ctx.Err() != nil {
303					return nil
304				}
305				return err
306			}
307			// An idle output renders nothing: free what windows that left
308			// it held, and the child of an ended session.
309			pipeline.Retire(scene)
310			if err := r.Trim(clk.Now()); err != nil {
311				return fmt.Errorf("trim renderer: %w", err)
312			}
313			opts.Log.Info().Str("output", opts.Name).Int("frames", frame).Msg("stats")
314			continue
315		case s, ok := <-scenes:
316			if !ok {
317				scenes = nil
318				continue
319			}
320			if err := checkSecurity(); err != nil {
321				if ctx.Err() != nil {
322					return nil
323				}
324				return err
325			}
326			acceptScene(s)
327		case b, ok := <-contents:
328			if !ok {
329				contents = nil
330				continue
331			}
332			if err := checkSecurity(); err != nil {
333				if ctx.Err() != nil {
334					return nil
335				}
336				return err
337			}
338			update(b)
339		case c, ok := <-cursor:
340			if !ok {
341				cursor = nil
342				continue
343			}
344			if err := checkSecurity(); err != nil {
345				if ctx.Err() != nil {
346					return nil
347				}
348				return err
349			}
350			// The cursor is only drawn into screenshots: no new frame.
351			want, cursorScale, cursorTransform = c, -1, 0
352		}
353		// Drain all queued updates before presenting one frame.
354	drain:
355		for {
356			select {
357			case s, ok := <-scenes:
358				if !ok {
359					scenes = nil
360				} else {
361					if err := checkSecurity(); err != nil {
362						if ctx.Err() != nil {
363							return nil
364						}
365						return err
366					}
367					acceptScene(s)
368				}
369			case b, ok := <-contents:
370				if !ok {
371					contents = nil
372				} else {
373					if err := checkSecurity(); err != nil {
374						if ctx.Err() != nil {
375							return nil
376						}
377						return err
378					}
379					update(b)
380				}
381			default:
382				break drain
383			}
384		}
385		if err := checkSecurity(); err != nil {
386			if ctx.Err() != nil {
387				return nil
388			}
389			return err
390		}
391		if opts.protected() {
392			failRequests(capture.ErrSessionProtected)
393		}
394		if scene.Off && requests.Len() > 0 {
395			requests.FailPending(capture.ErrOutputOff)
396		}
397		if !haveScene || !dirty || scene.Off {
398			// Contents not drawn are still read: report them. An output
399			// turned off draws nothing.
400			opts.report(&reports, nil, seen)
401			continue
402		}
403		dirty = false
404		if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && (scene.Scale != cursorScale || scene.Transform != cursorTransform) {
405			cursorScale, cursorTransform = scene.Scale, scene.Transform
406			if img, err := opts.LoadCursor(want, scene.Scale, scene.Transform, 256); err == nil {
407				if !opts.protected() {
408					opts.Cursor.set(img)
409				}
410			} else {
411				opts.Log.Warn().Err(err).Msg("cursor")
412			}
413		}
414		if err := checkSecurity(); err != nil {
415			if ctx.Err() != nil {
416				return nil
417			}
418			return err
419		}
420		if !haveScene {
421			continue
422		}
423		start := time.Now()
424		// Requests the scene cannot serve (exclusion of another session, a
425		// workspace that moved) are failed here; see capture.Pipeline.Split.
426		// A request whose indicator the scene does not show yet stays held.
427		ready, _ := pipeline.Hold(scene, requests.List, time.Now())
428		if len(scene.CaptureIndicators) > 0 {
429			// The captures of this frame are delivered only once its frame,
430			// with the indicator, is presented (capture.Pipeline.EndGate).
431			pipeline.BeginGate()
432		}
433		normal, excluded, hidden := pipeline.Split(scene, ready)
434		pipeline.Retire(scene)
435		if len(hidden) > 0 {
436			// A hidden workspace: a child renderer draws it. The display's
437			// fences do not cover the child, so wait for it before reporting.
438			if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
439				failRequests(capture.ErrEpochChanged)
440				continue
441			}
442			pipeline.SubmitHidden(scene, drawn, hidden)
443			clear(hidden)
444			// A slow child may outlive Wayland's stale-report timeout even
445			// though this owner waits. Publish its non-expiring holds first.
446			if opts.Presented != nil {
447				_, reads, _ := pipeline.CapHiddenSeen(seen)
448				if len(reads) > 0 {
449					reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads})
450					reports.Flush(opts.Presented)
451				}
452			}
453			if err := pipeline.WaitHidden(ctx); err != nil {
454				if ctx.Err() != nil {
455					return nil
456				}
457				return fmt.Errorf("workspace frame fence: %w", err)
458			}
459		}
460		// Captures never show the capture indicator: their frame goes first.
461		took, pdone, perr := pipeline.SubmitPlain(r, scene, drawn, normal)
462		if pdone != nil {
463			// The plain render reads client buffers even when its requests
464			// are rejected by a gate transition: own and wait its fence
465			// before any Seen report or protection check can proceed.
466			waitErr := syncfile.Wait(ctx, pdone)
467			_ = pdone.Close()
468			if waitErr != nil {
469				if ctx.Err() != nil {
470					return nil
471				}
472				return fmt.Errorf("plain frame fence: %w", waitErr)
473			}
474		}
475		if perr != nil {
476			return fmt.Errorf("render plain frame: %w", perr)
477		}
478		if took {
479			normal = nil // answered from the frame without the indicator
480		}
481		if len(excluded) > 0 {
482			// Exclude requests: the frame without the excluded surfaces is
483			// drawn first (same renderer, queue order), then the displayed one.
484			if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
485				failRequests(capture.ErrEpochChanged)
486				continue
487			}
488			xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn)
489			if xdone != nil {
490				// The excluded-frame composition reads client buffers even when the displayed
491				// frame is skipped by a gate/off transition. Own and wait its
492				// fence before any Seen report or protection check can proceed.
493				waitErr := syncfile.Wait(ctx, xdone)
494				_ = xdone.Close()
495				if waitErr != nil {
496					if ctx.Err() != nil {
497						return nil
498					}
499					return fmt.Errorf("excluded frame fence: %w", waitErr)
500				}
501			}
502			if renderErr != nil {
503				return fmt.Errorf("render excluded frame: %w", renderErr)
504			}
505			if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
506				failRequests(capture.ErrEpochChanged)
507				continue
508			}
509			pipeline.SubmitScoped(scene.Security, r, excluded)
510			clear(excluded)
511		}
512		if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
513			failRequests(capture.ErrEpochChanged)
514			continue
515		}
516		done, err := r.Render(scene, drawn)
517		if err != nil {
518			return fmt.Errorf("render frame: %w", err)
519		}
520		if done != nil {
521			// No screen paces headless frames: wait for the GPU before
522			// reporting that its buffers were read.
523			err = syncfile.Wait(ctx, done)
524			done.Close()
525			if err != nil {
526				if ctx.Err() != nil {
527					return nil
528				}
529				return fmt.Errorf("frame fence: %w", err)
530			}
531		}
532		if len(normal) > 0 {
533			opts.Log.Debug().Str("output", opts.Name).Int("captures", len(normal)).Msg("capture frame composed")
534		}
535		// Do not capture or report a frame that crossed a transition during
536		// Render/fence wait. The next loop establishes the new black proof.
537		if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
538			failRequests(capture.ErrEpochChanged)
539			continue
540		}
541		pipeline.SubmitScoped(scene.Security, r, normal)
542		// The indicated frame is presented: release what it gated.
543		pipeline.EndGate(nil)
544		requests.List = capture.Waiting(requests.List, scene)
545		frame++
546		opts.report(&reports, shows.flipInfo(scene, drawn, table.Kept), seen)
547		if scene.Animating && opts.Frames != nil {
548			select {
549			case opts.Frames <- ports.OutputFrame{Output: opts.Name}:
550			default:
551			}
552		}
553		if opts.ScreenshotDir != "" && !opts.protected() {
554			shot := r.Pixels()
555			if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
556				continue
557			}
558			if shot == nil {
559				opts.Log.Debug().Str("output", opts.Name).Int("frame", frame).Msg("screenshot readback unavailable; frame skipped")
560				continue
561			}
562			if opts.Cursor != nil {
563				opts.Cursor.drawSecure(shot, opts.Security)
564			}
565			if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
566				continue
567			}
568			if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot, opts.Security, scene.Security); err != nil {
569				return fmt.Errorf("screenshot: %w", err)
570			}
571			if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil {
572				return fmt.Errorf("latest screenshot: %w", err)
573			}
574			if opts.RawHDR && opts.HDR {
575				// No cursor on the raw image. Only a confirmed HDR output
576				// has a readable PQ target; else HDRPixels is nil.
577				if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
578					continue
579				}
580				raw := r.HDRPixels()
581				if raw == nil {
582					opts.Log.Debug().Str("output", opts.Name).Int("frame", frame).Msg("raw HDR readback unavailable; frame skipped")
583					continue
584				}
585				if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest-pq.png"), raw, opts.Security, scene.Security); err != nil {
586					return fmt.Errorf("raw HDR screenshot: %w", err)
587				}
588			}
589		}
590		if ev := opts.Log.Debug(); ev.Enabled() {
591			ev.Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Int("redrawn", r.TakeRedrawn()).Msg("frame")
592		}
593	}
594}
595
596func (opts Options) protected() bool {
597	return opts.Security != nil && opts.Security.Snapshot().Protected
598}
599
600func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error {
601	if security != nil && (state.Protected || security.Snapshot() != state) {
602		return nil
603	}
604	f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
605	if err != nil {
606		return err
607	}
608	defer os.Remove(f.Name())
609	if err := png.Encode(f, img); err != nil {
610		_ = f.Close()
611		return err
612	}
613	if err := f.Close(); err != nil {
614		return err
615	}
616	if security != nil && security.Snapshot() != state {
617		return nil
618	}
619	return os.Rename(f.Name(), path)
620}
621
622// report queues what the output read, with flip for a drawn frame, and
623// pushes it to the sink.
624func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
625	if opts.Presented == nil {
626		return
627	}
628	q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen})
629	q.Flush(opts.Presented)
630}
631
632// flipShows builds the Shows of successive flips. Its owner goroutine is the
633// only user. A flip's Shows map is an immutable snapshot: wayland may still
634// read the one of an earlier flip on another goroutine, so the scratch map is
635// never sent and a snapshot is replaced by a clone only when it changed.
636type flipShows struct {
637	scratch, snapshot map[ports.WindowID]uint64
638}
639
640// of returns the content Seq of the windows scene showed to their client. The
641// result is shared with earlier flips of the same content: never modify it.
642func (f *flipShows) of(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) map[ports.WindowID]uint64 {
643	if f.scratch == nil {
644		f.scratch = make(map[ports.WindowID]uint64)
645	}
646	clear(f.scratch)
647	for id, c := range contents {
648		// A closed window drawn from its kept content is not shown to
649		// its client.
650		if surfaces.Shown(scene, id, kept) {
651			f.scratch[id] = c.Seq
652		}
653	}
654	if f.snapshot == nil || !maps.Equal(f.snapshot, f.scratch) {
655		f.snapshot = maps.Clone(f.scratch)
656	}
657	return f.snapshot
658}
659
660// flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
661// time (software clock, refresh unknown), so presentation feedback and
662// frame callbacks follow headless frames. The FlipInfo itself is new per
663// flip (the sink counts merges on it); only its Shows is shared.
664func (f *flipShows) flipInfo(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo {
665	var ts unix.Timespec
666	_ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
667	return &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: f.of(scene, contents, kept)}
668}

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