v0.3.0 cf370a0

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

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