app.go RawHistoryFolder

1// Package app coordinates the application lifecycle.
2package app
3
4import (
5	"bufio"
6	"context"
7	"errors"
8	"fmt"
9	"io"
10	"os"
11	"runtime"
12	"sync"
13	"time"
14
15	"github.com/bnema/neferwl/internal/adapters/captureallow"
16	"github.com/bnema/neferwl/internal/adapters/clock"
17	"github.com/bnema/neferwl/internal/adapters/config"
18	"github.com/bnema/neferwl/internal/adapters/drm"
19	"github.com/bnema/neferwl/internal/adapters/headlessinput"
20	"github.com/bnema/neferwl/internal/adapters/launcher"
21	"github.com/bnema/neferwl/internal/adapters/libinput"
22	"github.com/bnema/neferwl/internal/adapters/sched"
23	"github.com/bnema/neferwl/internal/adapters/screensaver"
24	"github.com/bnema/neferwl/internal/adapters/sessionsecurity"
25	"github.com/bnema/neferwl/internal/adapters/statefile"
26	"github.com/bnema/neferwl/internal/adapters/vulkan"
27	"github.com/bnema/neferwl/internal/adapters/wayland"
28	"github.com/bnema/neferwl/internal/adapters/workspaceid"
29	"github.com/bnema/neferwl/internal/adapters/xkb"
30	"github.com/bnema/neferwl/internal/adapters/xwayland"
31	"github.com/bnema/neferwl/internal/core"
32	"github.com/bnema/neferwl/internal/logging"
33	"github.com/bnema/neferwl/internal/ports"
34	"github.com/bnema/zerowrap"
35	"golang.org/x/sys/unix"
36)
37
38type Options struct {
39	Backend    string
40	Config     ports.Config
41	ConfigPath string
42	Timeout    time.Duration
43	NoTerminal bool
44	// NoXwayland skips the X11 display even when the config enables it.
45	NoXwayland bool
46	// HeadlessHDR enables a virtual HDR output for protocol testing only.
47	HeadlessHDR bool
48	// Session exports the displays to D-Bus and systemd user services
49	// before any client starts, and withdraws them on exit.
50	Session       bool
51	ScreenshotDir string
52	// Sizes are the headless outputs (width, height), left to right; empty
53	// means one 1920x1080 output. With several outputs, screenshots go to
54	// one subdirectory per output (HEADLESS-1, HEADLESS-2, ...).
55	Sizes [][2]int
56	// Run closes Script on shutdown; the reader goroutine exits after Close.
57	Script     io.ReadCloser
58	testScenes chan<- []ports.Scene
59	// captureAllowPath replaces the fixed allowlist path (tests).
60	captureAllowPath string
61	// captureAllowOwner is the uid that must own that file (tests use their
62	// own; root, 0, otherwise).
63	captureAllowOwner uint32
64}
65
66func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
67
68func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
69	log := logging.For(ctx, "app")
70	log.Info().Str("backend", opts.Backend).Msg("starting neferwl")
71	var cancel context.CancelFunc
72	if opts.Timeout > 0 {
73		ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout)
74	} else {
75		ctx, cancel = context.WithCancel(ctx)
76	}
77	defer cancel()
78	sizes := opts.Sizes
79	if len(sizes) == 0 {
80		sizes = [][2]int{{1920, 1080}}
81	}
82	var hw *drmBackend
83	if opts.Backend == "drm" {
84		var err error
85		if hw, err = openDRM(ctx, opts.Config); err != nil {
86			return err
87		}
88		defer hw.close()
89	}
90	security := &sessionsecurity.Gate{}
91	securityChanges := make(chan ports.SecurityState, 8)
92	securityEvents := make(chan ports.SecurityBackendEvent, 64)
93	client := make(chan ports.ClientEvent, 32)
94	input := make(chan ports.InputEvent, 32)
95	output := make(chan ports.OutputEvent, 32)
96	configChanges := make(chan ports.ConfigChanged, 8)
97	commands := make(chan ports.ClientCommand, 32)
98	spawn := make(chan ports.SpawnRequest, 32)
99	scenes := make(chan []ports.Scene, 1)
100	layouts := make(chan ports.Layout, 1)
101	constraints := make(chan ports.PointerConstraint, 1)
102	states := make(chan ports.State, 1)
103	workspaces := make(chan ports.Workspaces, 1)
104	configErrors := make(chan error, 8)
105	renderScenes := make(chan []ports.Scene, 1)
106	contents := make(chan ports.SurfaceContent, 64)
107	cursorChanges := make(chan ports.CursorChange, 1)
108	presented := make(chan ports.OutputPresented, 64)
109	// Outputs report on flips; relayPresented passes them to wayland and
110	// tells core about each flip (frames) to move running slides.
111	flips := make(chan ports.OutputPresented, 64)
112	frames := make(chan ports.OutputFrame, 8)
113	captures := make(chan ports.CaptureRequest, ports.MaxCaptureInflight)
114	// Every admitted capture owns one reply slot until Wayland consumes it.
115	// Keep this capacity tied to admission so output routing never waits.
116	captured := make(chan ports.CaptureDone, ports.MaxCaptureInflight)
117	outputFormats := make(chan ports.OutputFormats, 8)
118	outputHeads := make(chan ports.OutputHeads, 8)
119	leaseRequests := make(chan ports.LeaseMessage, 32)
120	leaseEvents := make(chan ports.LeaseMessage, 32)
121	applyOutput := make(chan ports.OutputApply, 8)
122	appliedOutput := make(chan ports.OutputApplied, 8)
123	idleInhibited := make(chan bool, 1)
124	idleActivity := make(chan struct{}, 1)
125	var scales chan ports.ScaleChanged
126	if hw != nil {
127		// Only real sessions save scales: headless runs never touch the config file.
128		scales = make(chan ports.ScaleChanged, 8)
129	}
130	ch := core.Channels{Security: security, Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal, Clock: clock.System{}, Frames: frames}
131	c, err := core.New(opts.Config, ch)
132	if err != nil {
133		return err
134	}
135	km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
136	if err != nil {
137		return err
138	}
139	keymap := km.String()
140	runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
141	// Every output renders on the same GPU: its formats are the clients'.
142	dmabuf := vulkan.Probe()
143	log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
144	// One launch prefix for ext-workspace ids and the state file.
145	wsIDs := workspaceid.New()
146	// The path is fixed: a config key would let a program of the same user
147	// widen who may capture.
148	// Production: the fixed path, a root-owned file. Only tests set a path,
149	// and an owner for it.
150	captureAllowLog := logging.For(ctx, "captureallow")
151	var captureAllow *captureallow.Store
152	if opts.captureAllowPath != "" {
153		captureAllow = captureallow.NewStoreOwnedBy(opts.captureAllowPath, opts.captureAllowOwner, captureAllowLog)
154	} else {
155		captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)
156	}
157	server, err := wayland.New(wayland.Options{CaptureAllow: captureAllow, WorkspaceIDs: wsIDs, Security: security, RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{SecurityChanges: securityChanges, SecurityEvents: securityEvents, Events: client, Commands: commands, Workspaces: workspaces, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, LeaseRequests: leaseRequests, LeaseEvents: leaseEvents, OutputApply: applyOutput, OutputApplied: appliedOutput, IdleInhibited: idleInhibited, IdleActivity: idleActivity}, logging.For(ctx, "wayland"))
158	if err != nil {
159		km.Close()
160		return err
161	}
162	log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")
163	childEnv := launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir, cursorSize())
164	statePath, err := statefile.Path(runtimeDir, server.SocketName())
165	if err != nil {
166		log.Warn().Err(err).Msg("state file disabled")
167	} else {
168		childEnv = append(childEnv, statefile.Env+"="+statePath)
169	}
170	xdisplay := openXwayland(ctx, opts, childEnv, logging.For(ctx, "xwayland"))
171	if xdisplay != nil {
172		defer xdisplay.Close()
173		childEnv = append(childEnv, "DISPLAY="+xdisplay.Name())
174	}
175	if opts.Session {
176		launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))
177		launcher.NotifyReady(os.Getenv("NOTIFY_SOCKET"), logging.For(ctx, "launcher"))
178		defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
179	}
180	child := launcher.New(childEnv, logging.For(ctx, "launcher"))
181	child.Security = security
182	if inject != nil {
183		inject(input)
184	}
185	var workers sync.WaitGroup
186	workers.Add(8)
187	done := make(chan error, 8)
188	path := opts.ConfigPath
189	if path == "" {
190		path = config.DefaultPath()
191	}
192	watched := make(chan ports.ConfigChanged, 8)
193	keymaps := make(chan *xkb.Keymap, 1)
194	touchpads := make(chan ports.TouchpadConfig, 1)
195	go func() {
196		defer workers.Done()
197		done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))
198	}()
199	if scales != nil {
200		workers.Add(1)
201		go func() {
202			defer workers.Done()
203			core.PersistScales(ctx, scales, config.ScaleStore{Path: path}, launcher.NewNotifier(ctx, childEnv, logging.For(ctx, "launcher")), clock.System{}, time.Second)
204		}()
205	}
206	filtered := make(chan ports.ConfigChanged, 8)
207	curs := newCursors(clock.System{}, opts.Config.Cursor.HideAfter)
208	defer curs.stop()
209	workers.Add(1)
210	go func() {
211		defer workers.Done()
212		relayConfig(ctx, opts.Config, watched, filtered, keymaps, touchpads, curs, commands, logging.For(ctx, "config"))
213	}()
214	script := make(chan string)
215	go func() {
216		defer workers.Done()
217		if hw != nil {
218			// Core sends the first layout once DRM reports the outputs.
219			var layout ports.Layout
220			select {
221			case layout = <-layouts:
222			case <-ctx.Done():
223				km.Close()
224				return
225			}
226			done <- safe("input", func() error {
227				if opts.Config.Performance.Realtime {
228					runtime.LockOSThread()
229					// Let this dedicated thread exit with its scheduling policy.
230					if err := sched.Realtime(logging.For(ctx, "sched")); err != nil {
231						log.Warn().Str("component", "sched").Err(err).Msg("input scheduling")
232					}
233				}
234				return libinput.Run(ctx, libinput.Options{Security: security, Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion"), LogKeys: logging.Enabled(ctx, "input-keys")}, input)
235			})
236			return
237		}
238		if err := headlessinput.RunSecure(ctx, km, keymaps, script, input, layouts, func(o string, x, y float64) { curs.move(o, x, y, true) }, logging.For(ctx, "input"), security); err != nil && !errors.Is(err, context.Canceled) {
239			select {
240			case done <- err:
241			case <-ctx.Done():
242			}
243		}
244	}()
245	if opts.Script != nil {
246		go func() { <-ctx.Done(); _ = opts.Script.Close() }()
247		go func() {
248			defer close(script)
249			scanner := bufio.NewScanner(opts.Script)
250			for scanner.Scan() {
251				select {
252				case <-ctx.Done():
253					return
254				case script <- scanner.Text():
255				}
256			}
257			if err := scanner.Err(); err != nil {
258				log.Warn().Err(err).Msg("reading input script")
259			}
260		}()
261	} else {
262		close(script)
263	}
264	go func() { defer workers.Done(); done <- server.Run(ctx) }()
265	go func() {
266		defer workers.Done()
267		// Without a watch the allowlist stays as loaded: restart to apply.
268		if err := captureAllow.Run(ctx); err != nil {
269			log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
270		}
271	}()
272	go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
273	go func() { defer workers.Done(); done <- c.Run(ctx) }()
274	if xdisplay != nil {
275		workers.Add(1)
276		// X11 failing leaves the Wayland session running; Run logs why.
277		go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
278	}
279	if statePath != "" {
280		workers.Add(1)
281		go func() {
282			defer workers.Done()
283			if err := statefile.Run(ctx, statePath, states, wsIDs, logging.For(ctx, "statefile")); err != nil {
284				// Scripts lose their state; the session goes on.
285				log.Warn().Err(err).Msg("state file disabled")
286			}
287		}()
288	}
289	go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }()
290	workers.Add(1)
291	go func() { defer workers.Done(); relayPresented(ctx, flips, presented, frames) }()
292
293	go func() {
294		defer workers.Done()
295		outputIO := outputChannels{security: security, securityChanges: securityChanges, securityEvents: securityEvents, events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: flips, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, leaseRequests: leaseRequests, leaseEvents: leaseEvents, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput}
296		apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
297		renderLog := logging.For(ctx, "render")
298		newRenderer := func(w, h int) (ports.Renderer, error) {
299			r, err := vulkan.New(w, h)
300			if err != nil {
301				return nil, err
302			}
303			renderLog.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
304			return r, nil
305		}
306		if hw != nil {
307			done <- safe("output", func() error {
308				want := func(cfg ports.Config) drm.Want {
309					w := wantFromConfig(cfg)
310					w.Sampled = dmabuf.Formats
311					w.TraceFlips = logging.Enabled(ctx, "drm-flip")
312					return w
313				}
314				return hw.runOutputs(ctx, want, opts.Config, apply, outputIO, curs, newRenderer, logging.For(ctx, "drm"))
315			})
316			return
317		}
318		done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, apply, outputIO, curs, newRenderer, logging.For(ctx, "render"))
319	}()
320
321	if hw != nil {
322		workers.Add(1)
323		go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
324		// Only a real session serves the session bus: headless runs would take
325		// the name from the desktop they run in.
326		workers.Add(1)
327		go func() {
328			defer workers.Done()
329			reports := screensaver.Reports{Held: idleInhibited, Activity: idleActivity}
330			screensaver.Serve(ctx, "", reports, screensaverRetry, logging.For(ctx, "screensaver"))
331		}()
332	}
333	var result error
334	select {
335	case <-ctx.Done():
336		if errors.Is(context.Cause(ctx), errTimeout) {
337			log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
338		}
339	case result = <-done:
340		log.Info().AnErr("cause", result).Msg("stopping")
341	}
342	cancel()
343	workers.Wait()
344	// The wayland server has stopped: no request can be queued any more.
345	drainCaptures(ctx, captures, nil)
346	// A keymap the input goroutine never took is still ours to free.
347	select {
348	case km := <-keymaps:
349		km.Close()
350	default:
351	}
352	for len(done) > 0 {
353		err := <-done
354		if result == nil && err != nil {
355			result = err
356		}
357	}
358	if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
359		return nil
360	}
361	return result
362}
363
364// screensaverRetry is the first delay before the screensaver service tries
365// the session bus again: without one, only surface inhibitors count, and
366// the session goes on either way.
367const screensaverRetry = 2 * time.Second
368
369// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
370// to the input goroutine and sends it to clients; a repeat-only change just updates
371// clients. A keymap that fails to build keeps the previous layout. A touchpad
372// change goes to the input goroutine; only the newest one waits there. The
373// cursor idle delay goes straight to the cursor router.
374func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, touchpads chan ports.TouchpadConfig, curs *cursors, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
375	kb := cur.Keyboard
376	tp := cur.Touchpad
377	for {
378		var ev ports.ConfigChanged
379		select {
380		case <-ctx.Done():
381			return
382		case ev = <-in:
383		}
384		next := ev.Config.Keyboard
385		layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
386		repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
387		cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
388		if layoutChanged {
389			km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
390			if err != nil {
391				log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
392				ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
393				layoutChanged = false
394			} else {
395				cmd.Keymap = km.String()
396				// Drop a keymap the input goroutine has not taken yet; the newest wins.
397				select {
398				case old := <-keymaps:
399					old.Close()
400				default:
401				}
402				select {
403				case keymaps <- km:
404				case <-ctx.Done():
405					km.Close()
406					return
407				}
408			}
409		}
410		curs.setHideAfter(ev.Config.Cursor.HideAfter)
411		if ev.Config.Touchpad != tp {
412			tp = ev.Config.Touchpad
413			select {
414			case <-touchpads:
415			default:
416			}
417			touchpads <- tp // buffered and just drained: this goroutine is the only sender
418		}
419		if layoutChanged || repeatChanged {
420			select {
421			case commands <- cmd:
422			case <-ctx.Done():
423				return
424			}
425			if layoutChanged {
426				kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
427			}
428			kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
429			log.Info().Str("layout", kb.Layout).Str("variant", kb.Variant).Str("options", kb.Options).Int("rate", kb.RepeatRate).Int("delay", kb.RepeatDelay).Bool("keymap", layoutChanged).Msg("keyboard reloaded")
430		}
431		select {
432		case out <- ev:
433		case <-ctx.Done():
434			return
435		}
436	}
437}
438
439// relayPresented passes output reports to wayland in order. Each flip is
440// also a frame for core; a frame core has not read yet is enough, so
441// frames never wait.
442func relayPresented(ctx context.Context, in <-chan ports.OutputPresented, out chan<- ports.OutputPresented, frames chan<- ports.OutputFrame) {
443	for {
444		var p ports.OutputPresented
445		select {
446		case <-ctx.Done():
447			return
448		case p = <-in:
449		}
450		if p.Flip != nil {
451			select {
452			case frames <- ports.OutputFrame{Output: p.Output}:
453			default:
454			}
455		}
456		select {
457		case out <- p:
458		case <-ctx.Done():
459			return
460		}
461	}
462}
463
464func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
465	log := logging.For(ctx, "core")
466	configLog := logging.For(ctx, "config")
467	for {
468		select {
469		case <-ctx.Done():
470			return
471		case set := <-scenes:
472			if tap != nil {
473				select {
474				case tap <- set:
475				default:
476				}
477			}
478			select {
479			case renderScenes <- set:
480			default:
481				select {
482				case <-renderScenes:
483				default:
484				}
485				select {
486				case renderScenes <- set:
487				default:
488				}
489			}
490			for _, s := range set {
491				ev := log.Debug().Str("output", s.Output).Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight).Float64("scale", s.Scale)
492				rects := make([]string, 0, len(s.Windows))
493				for _, w := range s.Windows {
494					rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
495					if w.FocusEffect > 0 {
496						// The focus indicator frame by frame: window and effect.
497						ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
498					}
499				}
500				ev.Strs("windows", rects).Msg("scene")
501			}
502		case err := <-configErrors:
503			configLog.Warn().Err(err).Msg("config rejected")
504		}
505	}
506}
507
508// openXwayland reserves an X11 display for xwayland-satellite, or returns
509// nil when X11 is off or unavailable: the session runs without it.
510func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
511	bin := opts.Config.Xwayland
512	if bin == "" || opts.NoXwayland {
513		return nil
514	}
515	if !xwayland.Supported(ctx, bin) {
516		log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
517		return nil
518	}
519	d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
520	if err != nil {
521		log.Warn().Err(err).Msg("X11 display disabled")
522		return nil
523	}
524	log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
525	return d
526}
527
528// renderNode is the /dev/dri path of a render node dev_t, "" when none.
529func renderNode(dev uint64) string {
530	if dev == 0 {
531		return ""
532	}
533	return fmt.Sprintf("/dev/dri/renderD%d", unix.Minor(dev))
534}

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