output.go RawHistoryFolder

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

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