feat/data-dnd e05cb15

output.go RawHistoryFolder

1package headless
2
3import (
4	"context"
5	"fmt"
6	"image"
7	"image/png"
8	"os"
9	"path/filepath"
10	"time"
11
12	"golang.org/x/sys/unix"
13
14	"github.com/bnema/neferwl/internal/adapters/clock"
15	"github.com/bnema/neferwl/internal/adapters/outputkit/capture"
16	"github.com/bnema/neferwl/internal/adapters/outputkit/corewake"
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		batchDirty, _ := table.Apply(b, scene, seen, returns)
197		dirty = dirty || batchDirty
198	}
199	clk := opts.Clock
200	if clk == nil {
201		clk = clock.System{}
202	}
203	trim := clk.NewTicker(trimEvery)
204	defer trim.Stop()
205	// One timer for the wait of held requests, reset per pass (a time.After
206	// per loop pass would leave a timer behind each).
207	holdTimer := time.NewTimer(time.Hour)
208	holdTimer.Stop()
209	defer holdTimer.Stop()
210	for {
211		// A frame that left its gate open (any path out but its present) fails
212		// what it held: nothing is delivered for a frame that did not present.
213		pipeline.EndGate(capture.ErrFrameNotPresented)
214		if ctx.Err() != nil {
215			return nil
216		}
217		if err := checkSecurity(); err != nil {
218			if ctx.Err() != nil {
219				return nil
220			}
221			return err
222		}
223		// Requests wait for a scene that shows their indicator, at most
224		// capture.HoldFor (see capture.Hold).
225		var holdDue <-chan time.Time
226		if requests.Len() > 0 {
227			var wait time.Duration
228			requests.List, wait = pipeline.Expire(scene, requests.List, time.Now())
229			if wait > 0 {
230				holdTimer.Reset(wait)
231				holdDue = holdTimer.C
232			}
233		}
234		if holdDue == nil {
235			holdTimer.Stop()
236		}
237		select {
238		case <-holdDue:
239			continue
240		case <-ctx.Done():
241			return nil
242		case _, ok := <-opts.SecurityChanges:
243			if !ok {
244				opts.SecurityChanges = nil
245			}
246			if err := checkSecurity(); err != nil {
247				if ctx.Err() != nil {
248					return nil
249				}
250				return err
251			}
252			continue
253		case b := <-pipeline.Completed():
254			// The completed token has left the pipeline's queue. Return its
255			// lease before any transition path can error or block.
256			pipeline.Recycle(b, r)
257			if err := checkSecurity(); err != nil {
258				if ctx.Err() != nil {
259					return nil
260				}
261				return err
262			}
263			continue
264		case b := <-pipeline.HiddenCompleted():
265			pipeline.RecycleHidden(b)
266			if err := checkSecurity(); err != nil {
267				if ctx.Err() != nil {
268					return nil
269				}
270				return err
271			}
272			pipeline.Retire(scene)
273			continue
274		case q, ok := <-incoming:
275			if !ok {
276				incoming = nil
277				continue
278			}
279			if err := checkSecurity(); err != nil {
280				// q has left incoming but is not yet owned by requests/workers.
281				capture.Fail(ctx, q, capture.ErrOutputStopped, opts.Captured)
282				if ctx.Err() != nil {
283					return nil
284				}
285				return err
286			}
287			var refuse error
288			if opts.protected() {
289				refuse = capture.ErrSessionProtected
290			} else if scene.Off {
291				refuse = capture.ErrOutputOff
292			}
293			dirty = requests.Admit(q, refuse, scene, haveScene) || dirty
294		case <-trim.C():
295			if err := checkSecurity(); err != nil {
296				if ctx.Err() != nil {
297					return nil
298				}
299				return err
300			}
301			// An idle output renders nothing: free what windows that left
302			// it held, and the child of an ended session.
303			pipeline.Retire(scene)
304			if err := r.Trim(clk.Now()); err != nil {
305				return fmt.Errorf("trim renderer: %w", err)
306			}
307			opts.Log.Info().Str("output", opts.Name).Int("frames", frame).Msg("stats")
308			continue
309		case s, ok := <-scenes:
310			if !ok {
311				scenes = nil
312				continue
313			}
314			if err := checkSecurity(); err != nil {
315				if ctx.Err() != nil {
316					return nil
317				}
318				return err
319			}
320			acceptScene(s)
321		case b, ok := <-contents:
322			if !ok {
323				contents = nil
324				continue
325			}
326			if err := checkSecurity(); err != nil {
327				if ctx.Err() != nil {
328					return nil
329				}
330				return err
331			}
332			update(b)
333		case c, ok := <-cursor:
334			if !ok {
335				cursor = nil
336				continue
337			}
338			if err := checkSecurity(); err != nil {
339				if ctx.Err() != nil {
340					return nil
341				}
342				return err
343			}
344			// The cursor is only drawn into screenshots: no new frame.
345			want, cursorScale, cursorTransform = c, -1, 0
346		}
347		// Drain all queued updates before presenting one frame.
348	drain:
349		for {
350			select {
351			case s, ok := <-scenes:
352				if !ok {
353					scenes = nil
354				} else {
355					if err := checkSecurity(); err != nil {
356						if ctx.Err() != nil {
357							return nil
358						}
359						return err
360					}
361					acceptScene(s)
362				}
363			case b, ok := <-contents:
364				if !ok {
365					contents = nil
366				} else {
367					if err := checkSecurity(); err != nil {
368						if ctx.Err() != nil {
369							return nil
370						}
371						return err
372					}
373					update(b)
374				}
375			default:
376				break drain
377			}
378		}
379		if err := checkSecurity(); err != nil {
380			if ctx.Err() != nil {
381				return nil
382			}
383			return err
384		}
385		if opts.protected() {
386			failRequests(capture.ErrSessionProtected)
387		}
388		if scene.Off && requests.Len() > 0 {
389			requests.FailPending(capture.ErrOutputOff)
390		}
391		if !haveScene || !dirty || scene.Off {
392			// Contents not drawn are still read: report them. An output
393			// turned off draws nothing.
394			opts.report(&reports, nil, seen)
395			continue
396		}
397		dirty = false
398		if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && (scene.Scale != cursorScale || scene.Transform != cursorTransform) {
399			cursorScale, cursorTransform = scene.Scale, scene.Transform
400			if img, err := opts.LoadCursor(want, scene.Scale, scene.Transform, 256); err == nil {
401				if !opts.protected() {
402					opts.Cursor.set(img)
403				}
404			} else {
405				opts.Log.Warn().Err(err).Msg("cursor")
406			}
407		}
408		if err := checkSecurity(); err != nil {
409			if ctx.Err() != nil {
410				return nil
411			}
412			return err
413		}
414		if !haveScene {
415			continue
416		}
417		start := time.Now()
418		// Requests the scene cannot serve (exclusion of another session, a
419		// workspace that moved) are failed here; see capture.Pipeline.Split.
420		// A request whose indicator the scene does not show yet stays held.
421		ready, _ := pipeline.Hold(scene, requests.List, time.Now())
422		if len(scene.CaptureIndicators) > 0 {
423			// The captures of this frame are delivered only once its frame,
424			// with the indicator, is presented (capture.Pipeline.EndGate).
425			pipeline.BeginGate()
426		}
427		normal, excluded, hidden := pipeline.Split(scene, ready)
428		pipeline.Retire(scene)
429		if len(hidden) > 0 {
430			// A hidden workspace: a child renderer draws it. The display's
431			// fences do not cover the child, so wait for it before reporting.
432			if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
433				failRequests(capture.ErrEpochChanged)
434				continue
435			}
436			pipeline.SubmitHidden(scene, drawn, hidden)
437			clear(hidden)
438			// A slow child may outlive Wayland's stale-report timeout even
439			// though this owner waits. Publish its non-expiring holds first.
440			if opts.Presented != nil {
441				_, reads, _ := pipeline.CapHiddenSeen(seen)
442				if len(reads) > 0 {
443					reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads})
444					reports.Flush(opts.Presented)
445				}
446			}
447			if err := pipeline.WaitHidden(ctx); err != nil {
448				if ctx.Err() != nil {
449					return nil
450				}
451				return fmt.Errorf("workspace frame fence: %w", err)
452			}
453		}
454		// Captures never show the capture indicator: their frame goes first.
455		took, pdone, perr := pipeline.SubmitPlain(r, scene, drawn, normal)
456		if pdone != nil {
457			// The plain render reads client buffers even when its requests
458			// are rejected by a gate transition: own and wait its fence
459			// before any Seen report or protection check can proceed.
460			waitErr := syncfile.Wait(ctx, pdone)
461			_ = pdone.Close()
462			if waitErr != nil {
463				if ctx.Err() != nil {
464					return nil
465				}
466				return fmt.Errorf("plain frame fence: %w", waitErr)
467			}
468		}
469		if perr != nil {
470			return fmt.Errorf("render plain frame: %w", perr)
471		}
472		if took {
473			normal = nil // answered from the frame without the indicator
474		}
475		if len(excluded) > 0 {
476			// Exclude requests: the frame without the excluded surfaces is
477			// drawn first (same renderer, queue order), then the displayed one.
478			if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
479				failRequests(capture.ErrEpochChanged)
480				continue
481			}
482			xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn)
483			if xdone != nil {
484				// The excluded-frame composition reads client buffers even when the displayed
485				// frame is skipped by a gate/off transition. Own and wait its
486				// fence before any Seen report or protection check can proceed.
487				waitErr := syncfile.Wait(ctx, xdone)
488				_ = xdone.Close()
489				if waitErr != nil {
490					if ctx.Err() != nil {
491						return nil
492					}
493					return fmt.Errorf("excluded frame fence: %w", waitErr)
494				}
495			}
496			if renderErr != nil {
497				return fmt.Errorf("render excluded frame: %w", renderErr)
498			}
499			if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
500				failRequests(capture.ErrEpochChanged)
501				continue
502			}
503			pipeline.SubmitScoped(scene.Security, r, excluded)
504			clear(excluded)
505		}
506		if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
507			failRequests(capture.ErrEpochChanged)
508			continue
509		}
510		done, err := r.Render(scene, drawn)
511		if err != nil {
512			return fmt.Errorf("render frame: %w", err)
513		}
514		if done != nil {
515			// No screen paces headless frames: wait for the GPU before
516			// reporting that its buffers were read.
517			err = syncfile.Wait(ctx, done)
518			done.Close()
519			if err != nil {
520				if ctx.Err() != nil {
521					return nil
522				}
523				return fmt.Errorf("frame fence: %w", err)
524			}
525		}
526		if len(normal) > 0 {
527			opts.Log.Debug().Str("output", opts.Name).Int("captures", len(normal)).Msg("capture frame composed")
528		}
529		// Do not capture or report a frame that crossed a transition during
530		// Render/fence wait. The next loop establishes the new black proof.
531		if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
532			failRequests(capture.ErrEpochChanged)
533			continue
534		}
535		pipeline.SubmitScoped(scene.Security, r, normal)
536		// The indicated frame is presented: release what it gated.
537		pipeline.EndGate(nil)
538		requests.List = capture.Waiting(requests.List, scene)
539		frame++
540		opts.report(&reports, shows.flipInfo(scene, drawn, table.Kept), seen)
541		if scene.Animating {
542			corewake.Send(opts.Frames, opts.Name)
543		}
544		if opts.ScreenshotDir != "" && !opts.protected() {
545			shot := r.Pixels()
546			if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
547				continue
548			}
549			if shot == nil {
550				opts.Log.Debug().Str("output", opts.Name).Int("frame", frame).Msg("screenshot readback unavailable; frame skipped")
551				continue
552			}
553			if opts.Cursor != nil {
554				opts.Cursor.drawSecure(shot, opts.Security)
555			}
556			if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
557				continue
558			}
559			if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot, opts.Security, scene.Security); err != nil {
560				return fmt.Errorf("screenshot: %w", err)
561			}
562			if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil {
563				return fmt.Errorf("latest screenshot: %w", err)
564			}
565			if opts.RawHDR && opts.HDR {
566				// No cursor on the raw image. Only a confirmed HDR output
567				// has a readable PQ target; else HDRPixels is nil.
568				if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
569					continue
570				}
571				raw := r.HDRPixels()
572				if raw == nil {
573					opts.Log.Debug().Str("output", opts.Name).Int("frame", frame).Msg("raw HDR readback unavailable; frame skipped")
574					continue
575				}
576				if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest-pq.png"), raw, opts.Security, scene.Security); err != nil {
577					return fmt.Errorf("raw HDR screenshot: %w", err)
578				}
579			}
580		}
581		if ev := opts.Log.Debug(); ev.Enabled() {
582			ev.Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Int("redrawn", r.TakeRedrawn()).Msg("frame")
583		}
584	}
585}
586
587func (opts Options) protected() bool {
588	return opts.Security != nil && opts.Security.Snapshot().Protected
589}
590
591func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error {
592	if security != nil && (state.Protected || security.Snapshot() != state) {
593		return nil
594	}
595	f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
596	if err != nil {
597		return err
598	}
599	defer os.Remove(f.Name())
600	if err := png.Encode(f, img); err != nil {
601		_ = f.Close()
602		return err
603	}
604	if err := f.Close(); err != nil {
605		return err
606	}
607	if security != nil && security.Snapshot() != state {
608		return nil
609	}
610	return os.Rename(f.Name(), path)
611}
612
613// report queues what the output read, with flip for a drawn frame, and
614// pushes it to the sink.
615func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
616	if opts.Presented == nil {
617		return
618	}
619	q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen})
620	q.Flush(opts.Presented)
621}
622
623// flipShows builds the Shows of successive flips. Its owner goroutine is the
624// only user. The Shows snapshot is shared with earlier flips of the same
625// content (see surfaces.ShowsSnapshot): never modify it.
626type flipShows struct {
627	snapshot surfaces.ShowsSnapshot
628}
629
630// of returns the content Seq of the windows scene showed to their client.
631func (f *flipShows) of(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) map[ports.WindowID]uint64 {
632	// A closed window drawn from its kept content is not shown to its
633	// client.
634	return f.snapshot.FromContents(scene, contents, kept)
635}
636
637// flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
638// time (software clock, refresh unknown), so presentation feedback and
639// frame callbacks follow headless frames. The FlipInfo itself is new per
640// flip (the sink counts merges on it); only its Shows is shared.
641func (f *flipShows) flipInfo(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo {
642	var ts unix.Timespec
643	_ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
644	return &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: f.of(scene, contents, kept)}
645}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close