v0.3.0 99dc266

config_test.go RawHistoryFolder

1package daemon
2
3import (
4	"sync"
5	"testing"
6
7	"github.com/stretchr/testify/require"
8
9	renderer "github.com/bnema/vev-vt"
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/ports"
12	"github.com/bnema/vev/internal/usecase/keys"
13	themeui "github.com/bnema/vev/internal/usecase/theme"
14)
15
16type captureKeyHandler struct {
17	actions []keys.Action
18	forward [][]byte
19}
20
21func (h *captureKeyHandler) Forward(data []byte) {
22	h.forward = append(h.forward, append([]byte(nil), data...))
23}
24func (h *captureKeyHandler) Action(action keys.Action, _ []byte) {
25	h.actions = append(h.actions, action)
26}
27
28func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
29	d := newTestDaemon(t, nil, stubClock{})
30	h := &captureKeyHandler{}
31	r := keys.NewRouter(stubClock{}, h, &d.bindings)
32
33	r.Route([]byte("\x1bh"))
34	require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions)
35
36	d.ApplyConfig(domain.Config{
37		Theme: domain.ThemeAuto,
38		BindingEntries: []domain.ConfigEntry{
39			{Key: "focus-pane-left", Value: "alt+q"},
40		},
41		Codes: map[string]string{
42			"new-tab": "nt",
43		},
44	})
45
46	r.Route([]byte("\x1bh"))
47	require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions, "old key should no longer be intercepted")
48	require.Equal(t, [][]byte{{'\x1b', 'h'}}, h.forward)
49	r.Route([]byte("\x1bq"))
50	require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions)
51
52	cmd, ok := d.commandByEffectiveCode("NT")
53	require.True(t, ok)
54	require.Equal(t, "new-tab", cmd.Slug)
55	require.Equal(t, "NT", cmd.Code)
56}
57
58func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) {
59	p, release := newBlockingPTY(t)
60	defer release()
61	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
62
63	clientPalette := [16]renderer.RGB{}
64	clientPalette[1] = renderer.RGB{R: 1, G: 2, B: 3}
65	clientPalette[12] = renderer.RGB{R: 4, G: 5, B: 6}
66	d.applyTheme(sess, ac, ports.Theme{
67		HasForeground: true,
68		Foreground:    renderer.RGB{R: 7, G: 8, B: 9},
69		HasBackground: true,
70		Background:    renderer.RGB{R: 10, G: 11, B: 12},
71		Palette:       clientPalette,
72		PaletteKnown:  1<<1 | 1<<12,
73	})
74
75	require.Equal(t, clientPalette, ac.getClientTheme().Palette)
76	require.Equal(t, uint16(1<<1|1<<12), ac.getClientTheme().PaletteKnown)
77	require.True(t, ac.getAppliedTheme().Raw.UsePalette)
78
79	cfg := domain.Defaults()
80	cfg.ThemePalette = false
81	d.ApplyConfig(cfg)
82
83	// ApplyConfig reuses the live client report and repaints without requiring
84	// a reconnect; only palette inheritance changes.
85	reloaded := ac.getAppliedTheme().Raw
86	require.Equal(t, clientPalette, reloaded.Palette)
87	require.Equal(t, uint16(1<<1|1<<12), reloaded.PaletteKnown)
88	require.False(t, reloaded.UsePalette)
89}
90
91func TestEffectiveThemePaletteGate(t *testing.T) {
92	clientPalette := [16]renderer.RGB{}
93	clientPalette[3] = renderer.RGB{R: 1, G: 2, B: 3}
94	clientTheme := themeui.Theme{
95		Palette:      clientPalette,
96		PaletteKnown: 1 << 3,
97	}
98
99	tests := []struct {
100		name       string
101		config     domain.Config
102		want       themeui.Theme
103		usePalette bool
104	}{
105		{
106			name:       "auto inherits terminal palette by default",
107			config:     domain.Defaults(),
108			want:       clientTheme,
109			usePalette: true,
110		},
111		{
112			name: "auto disables terminal palette",
113			config: func() domain.Config {
114				cfg := domain.Defaults()
115				cfg.ThemePalette = false
116				return cfg
117			}(),
118			want: clientTheme,
119		},
120		{
121			name: "forced dark uses palette free builtin",
122			config: domain.Config{
123				Theme:        domain.ThemeDark,
124				ThemePalette: true,
125			},
126			want: themeui.BuiltinDark,
127		},
128		{
129			name: "forced dark ignores disabled palette inheritance",
130			config: domain.Config{
131				Theme:        domain.ThemeDark,
132				ThemePalette: false,
133			},
134			want: themeui.BuiltinDark,
135		},
136		{
137			name: "forced light uses palette free builtin",
138			config: domain.Config{
139				Theme:        domain.ThemeLight,
140				ThemePalette: true,
141			},
142			want: themeui.BuiltinLight,
143		},
144		{
145			name: "forced light ignores disabled palette inheritance",
146			config: domain.Config{
147				Theme:        domain.ThemeLight,
148				ThemePalette: false,
149			},
150			want: themeui.BuiltinLight,
151		},
152	}
153
154	for _, tt := range tests {
155		t.Run(tt.name, func(t *testing.T) {
156			d := newTestDaemon(t, nil, stubClock{})
157			d.ApplyConfig(tt.config)
158
159			got := d.effectiveTheme(clientTheme)
160			want := tt.want
161			want.UsePalette = tt.usePalette
162			require.Equal(t, want, got)
163		})
164	}
165}
166
167func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
168	d := newTestDaemon(t, nil, stubClock{})
169
170	// A nil atomic pointer is the zero/default snapshot: auto theme with
171	// terminal palette inheritance enabled.
172	require.Equal(t, themeConfigSnapshot{}, d.currentThemeConfig())
173
174	for _, tt := range []struct {
175		name string
176		mode domain.ThemeMode
177		gate bool
178	}{
179		{name: "auto palette enabled", mode: domain.ThemeAuto, gate: true},
180		{name: "auto palette disabled", mode: domain.ThemeAuto, gate: false},
181		{name: "dark palette enabled", mode: domain.ThemeDark, gate: true},
182		{name: "dark palette disabled", mode: domain.ThemeDark, gate: false},
183		{name: "light palette enabled", mode: domain.ThemeLight, gate: true},
184		{name: "light palette disabled", mode: domain.ThemeLight, gate: false},
185	} {
186		t.Run(tt.name, func(t *testing.T) {
187			d.storeThemeConfig(domain.Config{Theme: tt.mode, ThemePalette: tt.gate})
188			require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig())
189		})
190	}
191
192	// Publish complementary configurations concurrently. Every load must be a
193	// complete published snapshot, never mode from one update and gate from
194	// another. Coordination establishes concurrent readers/writers without
195	// timing sleeps.
196	first := domain.Config{Theme: domain.ThemeAuto, ThemePalette: false}
197	second := domain.Config{Theme: domain.ThemeDark, ThemePalette: true}
198	allowed := map[themeConfigSnapshot]struct{}{
199		{mode: domain.ThemeAuto, paletteOff: true}:  {},
200		{mode: domain.ThemeDark, paletteOff: false}: {},
201	}
202	firstPublished := make(chan struct{})
203	firstObserved := make(chan struct{})
204	secondPublished := make(chan struct{})
205	secondObserved := make(chan struct{})
206	observed := make(chan themeConfigSnapshot, 2)
207	invalid := make(chan themeConfigSnapshot, 1)
208	var wg sync.WaitGroup
209	wg.Add(2)
210	go func() {
211		defer wg.Done()
212		d.storeThemeConfig(first)
213		close(firstPublished)
214		<-firstObserved
215		d.storeThemeConfig(second)
216		close(secondPublished)
217		<-secondObserved
218		for range 10_000 {
219			d.storeThemeConfig(second)
220			d.storeThemeConfig(first)
221		}
222	}()
223	go func() {
224		defer wg.Done()
225		<-firstPublished
226		observed <- d.currentThemeConfig()
227		close(firstObserved)
228		<-secondPublished
229		observed <- d.currentThemeConfig()
230		close(secondObserved)
231		for range 20_000 {
232			snapshot := d.currentThemeConfig()
233			if _, ok := allowed[snapshot]; !ok {
234				select {
235				case invalid <- snapshot:
236				default:
237				}
238				return
239			}
240		}
241	}()
242	wg.Wait()
243	require.Equal(t, themeConfigSnapshot{mode: domain.ThemeAuto, paletteOff: true}, <-observed)
244	require.Equal(t, themeConfigSnapshot{mode: domain.ThemeDark, paletteOff: false}, <-observed)
245	select {
246	case snapshot := <-invalid:
247		t.Fatalf("observed mixed theme configuration: %#v", snapshot)
248	default:
249	}
250}
251
252func TestApplyConfigPublishesCopyConfig(t *testing.T) {
253	tests := []struct {
254		name  string
255		apply func(*Daemon)
256		want  domain.CopyConfig
257	}{
258		{
259			name: "default",
260			want: domain.CopyConfig{WordSeparators: domain.DefaultWordSeparators},
261		},
262		{
263			name: "custom",
264			apply: func(d *Daemon) {
265				cfg := domain.Defaults()
266				cfg.Copy = domain.CopyConfig{WordSeparators: "/:"}
267				d.ApplyConfig(cfg)
268			},
269			want: domain.CopyConfig{WordSeparators: "/:"},
270		},
271		{
272			name: "reload empty",
273			apply: func(d *Daemon) {
274				configured := domain.Defaults()
275				configured.Copy = domain.CopyConfig{WordSeparators: "/:"}
276				d.ApplyConfig(configured)
277
278				reloaded := domain.Defaults()
279				reloaded.Copy = domain.CopyConfig{}
280				d.ApplyConfig(reloaded)
281			},
282			want: domain.CopyConfig{},
283		},
284	}
285
286	for _, tt := range tests {
287		t.Run(tt.name, func(t *testing.T) {
288			d := newTestDaemon(t, nil, stubClock{})
289			if tt.apply != nil {
290				tt.apply(d)
291			}
292			require.Equal(t, tt.want, d.currentCopyConfig())
293		})
294	}
295}
296
297func TestApplyConfigPublishesImmutablePaletteSnapshot(t *testing.T) {
298	d := newTestDaemon(t, nil, stubClock{})
299	require.Equal(t, domain.Defaults().Palette, d.currentPaletteConfig())
300
301	firstConfig := domain.Defaults()
302	firstConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
303	d.ApplyConfig(firstConfig)
304	first := d.currentPaletteConfig()
305
306	secondConfig := domain.Defaults()
307	secondConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}
308	d.ApplyConfig(secondConfig)
309	second := d.currentPaletteConfig()
310
311	require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, first)
312	require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, second)
313}
314
315func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) {
316	p, release := newBlockingPTY(t)
317	defer release()
318	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
319	d.enterPalette(sess, ac)
320	awaitFrame(t, sends, ports.MsgOutput)
321	d.handlePaletteInput(ac, []byte("new"))
322	awaitFrame(t, sends, ports.MsgOutput)
323	d.handlePaletteInput(ac, []byte{0x0e})
324	awaitFrame(t, sends, ports.MsgOutput)
325
326	ac.overlays.paletteMu.Lock()
327	model := ac.overlays.palette
328	query := model.Query()
329	selected, ok := model.Selected()
330	ac.overlays.paletteMu.Unlock()
331	require.True(t, ok)
332
333	cfg := domain.Defaults()
334	cfg.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
335	d.ApplyConfig(cfg)
336	awaitFrame(t, sends, ports.MsgOutput)
337
338	ac.overlays.paletteMu.Lock()
339	defer ac.overlays.paletteMu.Unlock()
340	require.Same(t, model, ac.overlays.palette)
341	require.Equal(t, query, ac.overlays.palette.Query())
342	selectedAfter, ok := ac.overlays.palette.Selected()
343	require.True(t, ok)
344	selectedCommand, ok := selected.Command()
345	require.True(t, ok)
346	selectedAfterCommand, ok := selectedAfter.Command()
347	require.True(t, ok)
348	require.Equal(t, selectedCommand.Slug, selectedAfterCommand.Slug)
349	require.Equal(t, selectedCommand.Code, selectedAfterCommand.Code)
350}
351
352func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
353	d := newTestDaemon(t, nil, stubClock{})
354	require.Equal(t, domain.Defaults().Floating, d.currentFloatingConfig())
355
356	firstConfig := domain.Defaults()
357	firstConfig.Floating = domain.FloatingConfig{Command: "btop", Width: 70, Height: 60}
358	d.ApplyConfig(firstConfig)
359	first := d.currentFloatingConfig()
360
361	secondConfig := domain.Defaults()
362	secondConfig.Floating = domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}
363	d.ApplyConfig(secondConfig)
364	second := d.currentFloatingConfig()
365
366	require.Equal(t, domain.FloatingConfig{Command: "btop", Width: 70, Height: 60}, first)
367	require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second)
368}
369
370func TestApplyConfigPublishesImmutableNavSnapshot(t *testing.T) {
371	d := newTestDaemon(t, nil, stubClock{})
372	require.Equal(t, domain.NavConfig{}, d.currentNavConfig())
373
374	firstConfig := domain.Defaults()
375	firstConfig.Nav = domain.NavConfig{OverflowTabs: true}
376	d.ApplyConfig(firstConfig)
377	first := d.currentNavConfig()
378
379	secondConfig := domain.Defaults()
380	secondConfig.Nav = domain.NavConfig{OverflowSessions: true}
381	d.ApplyConfig(secondConfig)
382	second := d.currentNavConfig()
383
384	require.Equal(t, domain.NavConfig{OverflowTabs: true}, first)
385	require.Equal(t, domain.NavConfig{OverflowSessions: true}, second)
386}
387
388func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) {
389	d := newTestDaemon(t, nil, stubClock{})
390	require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig())
391
392	firstConfig := domain.Defaults()
393	firstConfig.Tabs = domain.TabsConfig{TerminalTitle: false}
394	d.ApplyConfig(firstConfig)
395	first := d.currentTabsConfig()
396
397	secondConfig := domain.Defaults()
398	secondConfig.Tabs = domain.TabsConfig{TerminalTitle: true}
399	d.ApplyConfig(secondConfig)
400	second := d.currentTabsConfig()
401
402	require.Equal(t, domain.TabsConfig{TerminalTitle: false}, first)
403	require.Equal(t, domain.TabsConfig{TerminalTitle: true}, second)
404}
405
406func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
407	tests := []struct {
408		name  string
409		cfg   domain.Config
410		check func(t *testing.T, allow map[string]struct{})
411	}{
412		{
413			name: "default allowlist",
414			check: func(t *testing.T, allow map[string]struct{}) {
415				t.Helper()
416				require.Contains(t, allow, "claude")
417			},
418		},
419		{
420			name: "explicit empty disables restore",
421			cfg:  domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}},
422			check: func(t *testing.T, allow map[string]struct{}) {
423				t.Helper()
424				require.Empty(t, allow)
425			},
426		},
427		{
428			name: "explicit values are trimmed",
429			cfg: domain.Config{Snapshot: domain.SnapshotConfig{
430				RestoreProcessesSet: true,
431				RestoreProcesses:    []string{"less", "", " pi "},
432			}},
433			check: func(t *testing.T, allow map[string]struct{}) {
434				t.Helper()
435				require.Contains(t, allow, "less")
436				require.Contains(t, allow, "pi")
437				require.NotContains(t, allow, "")
438			},
439		},
440	}
441
442	for _, tt := range tests {
443		t.Run(tt.name, func(t *testing.T) {
444			d := newTestDaemon(t, nil, stubClock{})
445			if tt.cfg.Snapshot.RestoreProcessesSet || len(tt.cfg.Snapshot.RestoreProcesses) > 0 {
446				d.ApplyConfig(tt.cfg)
447			}
448			tt.check(t, d.restoreProcessAllowlistSnapshot())
449		})
450	}
451}
452
453func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {
454	d := newTestDaemon(t, nil, stubClock{})
455	d.ApplyConfig(domain.Config{
456		Theme: domain.ThemeAuto,
457		Codes: map[string]string{
458			"new-tab": "NN",
459		},
460	})
461
462	commands := d.paletteCommands()
463	require.NotEmpty(t, commands)
464	require.Equal(t, "new-tab", commands[0].Slug)
465	require.Equal(t, "NN", commands[0].Code)
466
467	cmd, ok := d.commandByEffectiveCode("nn")
468	require.True(t, ok)
469	require.Equal(t, "new-tab", cmd.Slug)
470	require.Equal(t, "NN", cmd.Code)
471}
472
473func TestPaletteCommandsUseOverrideSnapshotForRecentAndListing(t *testing.T) {
474	d := newTestDaemon(t, nil, stubClock{})
475	d.ApplyConfig(domain.Config{
476		Theme: domain.ThemeAuto,
477		Codes: map[string]string{
478			"split-right": "SRX",
479		},
480	})
481	d.recordPaletteUse("SRX")
482
483	commands := d.paletteCommands()
484	require.NotEmpty(t, commands)
485	require.Equal(t, "split-right", commands[0].Slug)
486	require.Equal(t, "SRX", commands[0].Code)
487
488	seen := 0
489	for _, cmd := range commands {
490		if cmd.Slug == "split-right" {
491			seen++
492			require.Equal(t, "SRX", cmd.Code)
493		}
494	}
495	require.Equal(t, 1, seen)
496}
497
498func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) {
499	d := newTestDaemon(t, nil, stubClock{})
500	d.ApplyConfig(domain.Config{
501		Theme: domain.ThemeAuto,
502		Codes: map[string]string{
503			"new-session": "CNT",
504			"new-tab":     "DET",
505		},
506	})
507
508	commands := d.paletteCommands()
509	byCode := make(map[string]string, len(commands))
510	for _, cmd := range commands {
511		if existing, ok := byCode[cmd.Code]; ok {
512			t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
513		}
514		byCode[cmd.Code] = cmd.Slug
515	}
516	require.Equal(t, "new-tab", byCode["CNT"])
517	require.Equal(t, "new-session", byCode["CNS"])
518	require.Equal(t, "detach", byCode["DET"])
519}
520
521func TestCodeOverrideVacatedDefaultIsReusableRegardlessOfSlugOrder(t *testing.T) {
522	d := newTestDaemon(t, nil, stubClock{})
523	d.ApplyConfig(domain.Config{
524		Theme: domain.ThemeAuto,
525		Codes: map[string]string{
526			"new-tab":     "NT",
527			"new-session": "CNT",
528		},
529	})
530
531	commands := d.paletteCommands()
532	byCode := make(map[string]string, len(commands))
533	for _, cmd := range commands {
534		if existing, ok := byCode[cmd.Code]; ok {
535			t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
536		}
537		byCode[cmd.Code] = cmd.Slug
538	}
539	require.Equal(t, "new-tab", byCode["NT"])
540	require.Equal(t, "new-session", byCode["CNT"])
541}
542
543func TestBuildCodeOverridesOrdersIndependentConflictWarnings(t *testing.T) {
544	d := newTestDaemon(t, nil, stubClock{})
545
546	_, warnings := d.buildCodeOverrides(map[string]string{
547		"new-session": "ZZ",
548		"new-tab":     "ZZ",
549		"split-left":  "YY",
550		"split-right": "YY",
551	})
552
553	require.Equal(t, []domain.Warning{
554		{Msg: `command code "YY" for "split-right" conflicts with "split-left"`},
555		{Msg: `command code "ZZ" for "new-tab" conflicts with "new-session"`},
556	}, warnings)
557}
558
559func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
560	d := newTestDaemon(t, nil, stubClock{})
561	require.NotPanics(t, func() {
562		d.ApplyConfig(domain.Config{
563			Theme: domain.ThemeAuto,
564			BindingEntries: []domain.ConfigEntry{
565				{Key: "unknown-action", Value: "alt+x"},
566				{Key: "focus-pane-left", Value: "ctrl+x"},
567			},
568			Codes: map[string]string{
569				"new-tab":         "toolong",
570				"split-right":     "det",
571				"missing-command": "OK",
572			},
573		})
574	})
575
576	cmd, ok := d.commandByEffectiveCode("CNT")
577	require.True(t, ok)
578	require.Equal(t, "new-tab", cmd.Slug)
579	_, ok = d.commandByEffectiveCode("TOOLONG")
580	require.False(t, ok)
581}
582
583func TestConfigReloadNoticeMessageIncludesLineOnlyWhenKnown(t *testing.T) {
584	tests := []struct {
585		name     string
586		warnings []domain.Warning
587		want     string
588	}{
589		{
590			name:     "no source line",
591			warnings: []domain.Warning{{Msg: "unknown action"}},
592			want:     "config reloaded with 1 warning(s): unknown action",
593		},
594		{
595			name:     "source line",
596			warnings: []domain.Warning{{Line: 12, Msg: "unknown action"}},
597			want:     "config reloaded with 1 warning(s): line 12: unknown action",
598		},
599	}
600	for _, tt := range tests {
601		t.Run(tt.name, func(t *testing.T) {
602			require.Equal(t, tt.want, configReloadNoticeMessage(tt.warnings))
603		})
604	}
605}
606
607func TestApplyConfigSurfacesOneNoticeAggregatingAllWarnings(t *testing.T) {
608	d := newTestDaemon(t, nil, stubClock{})
609	d.ApplyConfig(domain.Config{
610		Theme: domain.ThemeAuto,
611		BindingEntries: []domain.ConfigEntry{
612			{Key: "unknown-action", Value: "alt+x"},
613		},
614		Codes: map[string]string{
615			"missing-command": "OK",
616			"new-tab":         "toolong",
617		},
618	})
619
620	history := d.notices.history()
621	var reloadNotices []domain.Notification
622	for _, n := range history {
623		if n.Code == domain.NoticeConfigReload {
624			reloadNotices = append(reloadNotices, n)
625		}
626	}
627	require.Len(t, reloadNotices, 1, "expected exactly one config-reload notice per ApplyConfig call")
628
629	n := reloadNotices[0]
630	require.Equal(t, domain.NoticeWarn, n.Severity)
631	require.Equal(t, domain.SessionID(""), n.SessionID, "config reload notice must be global")
632	require.Contains(t, n.Message, "config reloaded with 3 warning(s):")
633	require.NotContains(t, n.Message, "line 0:")
634	require.Contains(t, n.Details, `unknown action "unknown-action"`)
635	require.Contains(t, n.Details, `unknown command code slug "missing-command"`)
636	require.Contains(t, n.Details, `invalid command code for "new-tab"`)
637}

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 12

Tags 11

↑↓ moveenter openesc close