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/protocol"
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, protocol.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
167// updateRuntimeConfig publishes a modified copy of the current runtime config
168// snapshot, the way ApplyConfig does, without touching unrelated sections.
169func updateRuntimeConfig(d *Daemon, mutate func(*runtimeConfig)) {
170	next := *d.currentRuntimeConfig()
171	mutate(&next)
172	d.config.Store(&next)
173}
174
175func storeThemeForTest(d *Daemon, cfg domain.Config) {
176	updateRuntimeConfig(d, func(rc *runtimeConfig) {
177		rc.theme = themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}
178	})
179}
180
181func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
182	d := newTestDaemon(t, nil, stubClock{})
183
184	// A nil atomic pointer is the zero/default snapshot: auto theme with
185	// terminal palette inheritance enabled.
186	require.Equal(t, themeConfigSnapshot{}, d.currentThemeConfig())
187
188	for _, tt := range []struct {
189		name string
190		mode domain.ThemeMode
191		gate bool
192	}{
193		{name: "auto palette enabled", mode: domain.ThemeAuto, gate: true},
194		{name: "auto palette disabled", mode: domain.ThemeAuto, gate: false},
195		{name: "dark palette enabled", mode: domain.ThemeDark, gate: true},
196		{name: "dark palette disabled", mode: domain.ThemeDark, gate: false},
197		{name: "light palette enabled", mode: domain.ThemeLight, gate: true},
198		{name: "light palette disabled", mode: domain.ThemeLight, gate: false},
199	} {
200		t.Run(tt.name, func(t *testing.T) {
201			storeThemeForTest(d, domain.Config{Theme: tt.mode, ThemePalette: tt.gate})
202			require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig())
203		})
204	}
205
206	// Publish complementary configurations concurrently. Every load must be a
207	// complete published snapshot, never mode from one update and gate from
208	// another. Coordination establishes concurrent readers/writers without
209	// timing sleeps.
210	first := domain.Config{Theme: domain.ThemeAuto, ThemePalette: false}
211	second := domain.Config{Theme: domain.ThemeDark, ThemePalette: true}
212	allowed := map[themeConfigSnapshot]struct{}{
213		{mode: domain.ThemeAuto, paletteOff: true}:  {},
214		{mode: domain.ThemeDark, paletteOff: false}: {},
215	}
216	firstPublished := make(chan struct{})
217	firstObserved := make(chan struct{})
218	secondPublished := make(chan struct{})
219	secondObserved := make(chan struct{})
220	observed := make(chan themeConfigSnapshot, 2)
221	invalid := make(chan themeConfigSnapshot, 1)
222	var wg sync.WaitGroup
223	wg.Add(2)
224	go func() {
225		defer wg.Done()
226		storeThemeForTest(d, first)
227		close(firstPublished)
228		<-firstObserved
229		storeThemeForTest(d, second)
230		close(secondPublished)
231		<-secondObserved
232		for range 10_000 {
233			storeThemeForTest(d, second)
234			storeThemeForTest(d, first)
235		}
236	}()
237	go func() {
238		defer wg.Done()
239		<-firstPublished
240		observed <- d.currentThemeConfig()
241		close(firstObserved)
242		<-secondPublished
243		observed <- d.currentThemeConfig()
244		close(secondObserved)
245		for range 20_000 {
246			snapshot := d.currentThemeConfig()
247			if _, ok := allowed[snapshot]; !ok {
248				select {
249				case invalid <- snapshot:
250				default:
251				}
252				return
253			}
254		}
255	}()
256	wg.Wait()
257	require.Equal(t, themeConfigSnapshot{mode: domain.ThemeAuto, paletteOff: true}, <-observed)
258	require.Equal(t, themeConfigSnapshot{mode: domain.ThemeDark, paletteOff: false}, <-observed)
259	select {
260	case snapshot := <-invalid:
261		t.Fatalf("observed mixed theme configuration: %#v", snapshot)
262	default:
263	}
264}
265
266func TestApplyConfigPublishesCopyConfig(t *testing.T) {
267	tests := []struct {
268		name  string
269		apply func(*Daemon)
270		want  domain.CopyConfig
271	}{
272		{
273			name: "default",
274			want: domain.CopyConfig{WordSeparators: domain.DefaultWordSeparators},
275		},
276		{
277			name: "custom",
278			apply: func(d *Daemon) {
279				cfg := domain.Defaults()
280				cfg.Copy = domain.CopyConfig{WordSeparators: "/:"}
281				d.ApplyConfig(cfg)
282			},
283			want: domain.CopyConfig{WordSeparators: "/:"},
284		},
285		{
286			name: "reload empty",
287			apply: func(d *Daemon) {
288				configured := domain.Defaults()
289				configured.Copy = domain.CopyConfig{WordSeparators: "/:"}
290				d.ApplyConfig(configured)
291
292				reloaded := domain.Defaults()
293				reloaded.Copy = domain.CopyConfig{}
294				d.ApplyConfig(reloaded)
295			},
296			want: domain.CopyConfig{},
297		},
298	}
299
300	for _, tt := range tests {
301		t.Run(tt.name, func(t *testing.T) {
302			d := newTestDaemon(t, nil, stubClock{})
303			if tt.apply != nil {
304				tt.apply(d)
305			}
306			require.Equal(t, tt.want, d.currentCopyConfig())
307		})
308	}
309}
310
311func TestApplyConfigPublishesImmutablePaletteSnapshot(t *testing.T) {
312	d := newTestDaemon(t, nil, stubClock{})
313	require.Equal(t, domain.Defaults().Palette, d.currentPaletteConfig())
314
315	firstConfig := domain.Defaults()
316	firstConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
317	d.ApplyConfig(firstConfig)
318	first := d.currentPaletteConfig()
319
320	secondConfig := domain.Defaults()
321	secondConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}
322	d.ApplyConfig(secondConfig)
323	second := d.currentPaletteConfig()
324
325	require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, first)
326	require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, second)
327}
328
329func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) {
330	p, release := newBlockingPTY(t)
331	defer release()
332	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
333	d.enterPalette(sess, ac)
334	awaitFrame(t, sends, "Output")
335	d.handlePaletteInput(ac, []byte("new"))
336	awaitFrame(t, sends, "Output")
337	d.handlePaletteInput(ac, []byte{0x0e})
338	awaitFrame(t, sends, "Output")
339
340	ac.overlays.paletteMu.Lock()
341	model := ac.overlays.palette
342	query := model.Query()
343	selected, ok := model.Selected()
344	ac.overlays.paletteMu.Unlock()
345	require.True(t, ok)
346
347	cfg := domain.Defaults()
348	cfg.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
349	d.ApplyConfig(cfg)
350	awaitFrame(t, sends, "Output")
351
352	ac.overlays.paletteMu.Lock()
353	defer ac.overlays.paletteMu.Unlock()
354	require.Same(t, model, ac.overlays.palette)
355	require.Equal(t, query, ac.overlays.palette.Query())
356	selectedAfter, ok := ac.overlays.palette.Selected()
357	require.True(t, ok)
358	selectedCommand, ok := selected.Command()
359	require.True(t, ok)
360	selectedAfterCommand, ok := selectedAfter.Command()
361	require.True(t, ok)
362	require.Equal(t, selectedCommand.Slug, selectedAfterCommand.Slug)
363	require.Equal(t, selectedCommand.Code, selectedAfterCommand.Code)
364}
365
366func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
367	d := newTestDaemon(t, nil, stubClock{})
368	require.Equal(t, domain.Defaults().Floating, d.currentFloatingConfig())
369
370	firstConfig := domain.Defaults()
371	firstConfig.Floating = domain.FloatingConfig{Command: "btop", Width: 70, Height: 60}
372	d.ApplyConfig(firstConfig)
373	first := d.currentFloatingConfig()
374
375	secondConfig := domain.Defaults()
376	secondConfig.Floating = domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}
377	d.ApplyConfig(secondConfig)
378	second := d.currentFloatingConfig()
379
380	require.Equal(t, domain.FloatingConfig{Command: "btop", Width: 70, Height: 60}, first)
381	require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second)
382}
383
384func TestApplyConfigPublishesImmutableNavSnapshot(t *testing.T) {
385	d := newTestDaemon(t, nil, stubClock{})
386	require.Equal(t, domain.NavConfig{}, d.currentNavConfig())
387
388	firstConfig := domain.Defaults()
389	firstConfig.Nav = domain.NavConfig{OverflowTabs: true}
390	d.ApplyConfig(firstConfig)
391	first := d.currentNavConfig()
392
393	secondConfig := domain.Defaults()
394	secondConfig.Nav = domain.NavConfig{OverflowSessions: true}
395	d.ApplyConfig(secondConfig)
396	second := d.currentNavConfig()
397
398	require.Equal(t, domain.NavConfig{OverflowTabs: true}, first)
399	require.Equal(t, domain.NavConfig{OverflowSessions: true}, second)
400}
401
402func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) {
403	d := newTestDaemon(t, nil, stubClock{})
404	require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig())
405
406	firstConfig := domain.Defaults()
407	firstConfig.Tabs = domain.TabsConfig{TerminalTitle: false}
408	d.ApplyConfig(firstConfig)
409	first := d.currentTabsConfig()
410
411	secondConfig := domain.Defaults()
412	secondConfig.Tabs = domain.TabsConfig{TerminalTitle: true}
413	d.ApplyConfig(secondConfig)
414	second := d.currentTabsConfig()
415
416	require.Equal(t, domain.TabsConfig{TerminalTitle: false}, first)
417	require.Equal(t, domain.TabsConfig{TerminalTitle: true}, second)
418}
419
420func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
421	tests := []struct {
422		name  string
423		cfg   domain.Config
424		check func(t *testing.T, allow map[string]struct{})
425	}{
426		{
427			name: "default allowlist",
428			check: func(t *testing.T, allow map[string]struct{}) {
429				t.Helper()
430				require.Contains(t, allow, "claude")
431			},
432		},
433		{
434			name: "explicit empty disables restore",
435			cfg:  domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}},
436			check: func(t *testing.T, allow map[string]struct{}) {
437				t.Helper()
438				require.Empty(t, allow)
439			},
440		},
441		{
442			name: "explicit values are trimmed",
443			cfg: domain.Config{Snapshot: domain.SnapshotConfig{
444				RestoreProcessesSet: true,
445				RestoreProcesses:    []string{"less", "", " pi "},
446			}},
447			check: func(t *testing.T, allow map[string]struct{}) {
448				t.Helper()
449				require.Contains(t, allow, "less")
450				require.Contains(t, allow, "pi")
451				require.NotContains(t, allow, "")
452			},
453		},
454	}
455
456	for _, tt := range tests {
457		t.Run(tt.name, func(t *testing.T) {
458			d := newTestDaemon(t, nil, stubClock{})
459			if tt.cfg.Snapshot.RestoreProcessesSet || len(tt.cfg.Snapshot.RestoreProcesses) > 0 {
460				d.ApplyConfig(tt.cfg)
461			}
462			tt.check(t, d.restoreProcessAllowlistSnapshot())
463		})
464	}
465}
466
467func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {
468	d := newTestDaemon(t, nil, stubClock{})
469	d.ApplyConfig(domain.Config{
470		Theme: domain.ThemeAuto,
471		Codes: map[string]string{
472			"new-tab": "NN",
473		},
474	})
475
476	commands := d.paletteCommands()
477	require.NotEmpty(t, commands)
478	require.Equal(t, "new-tab", commands[0].Slug)
479	require.Equal(t, "NN", commands[0].Code)
480
481	cmd, ok := d.commandByEffectiveCode("nn")
482	require.True(t, ok)
483	require.Equal(t, "new-tab", cmd.Slug)
484	require.Equal(t, "NN", cmd.Code)
485}
486
487func TestPaletteCommandsUseOverrideSnapshotForRecentAndListing(t *testing.T) {
488	d := newTestDaemon(t, nil, stubClock{})
489	d.ApplyConfig(domain.Config{
490		Theme: domain.ThemeAuto,
491		Codes: map[string]string{
492			"split-right": "SRX",
493		},
494	})
495	d.paletteHistory.record("SRX")
496
497	commands := d.paletteCommands()
498	require.NotEmpty(t, commands)
499	require.Equal(t, "split-right", commands[0].Slug)
500	require.Equal(t, "SRX", commands[0].Code)
501
502	seen := 0
503	for _, cmd := range commands {
504		if cmd.Slug == "split-right" {
505			seen++
506			require.Equal(t, "SRX", cmd.Code)
507		}
508	}
509	require.Equal(t, 1, seen)
510}
511
512func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) {
513	d := newTestDaemon(t, nil, stubClock{})
514	d.ApplyConfig(domain.Config{
515		Theme: domain.ThemeAuto,
516		Codes: map[string]string{
517			"new-session": "CNT",
518			"new-tab":     "DET",
519		},
520	})
521
522	commands := d.paletteCommands()
523	byCode := make(map[string]string, len(commands))
524	for _, cmd := range commands {
525		if existing, ok := byCode[cmd.Code]; ok {
526			t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
527		}
528		byCode[cmd.Code] = cmd.Slug
529	}
530	require.Equal(t, "new-tab", byCode["CNT"])
531	require.Equal(t, "new-session", byCode["CNS"])
532	require.Equal(t, "detach", byCode["DET"])
533}
534
535func TestCodeOverrideVacatedDefaultIsReusableRegardlessOfSlugOrder(t *testing.T) {
536	d := newTestDaemon(t, nil, stubClock{})
537	d.ApplyConfig(domain.Config{
538		Theme: domain.ThemeAuto,
539		Codes: map[string]string{
540			"new-tab":     "NT",
541			"new-session": "CNT",
542		},
543	})
544
545	commands := d.paletteCommands()
546	byCode := make(map[string]string, len(commands))
547	for _, cmd := range commands {
548		if existing, ok := byCode[cmd.Code]; ok {
549			t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
550		}
551		byCode[cmd.Code] = cmd.Slug
552	}
553	require.Equal(t, "new-tab", byCode["NT"])
554	require.Equal(t, "new-session", byCode["CNT"])
555}
556
557func TestBuildCodeOverridesOrdersIndependentConflictWarnings(t *testing.T) {
558	d := newTestDaemon(t, nil, stubClock{})
559
560	_, warnings := d.buildCodeOverrides(map[string]string{
561		"new-session": "ZZ",
562		"new-tab":     "ZZ",
563		"split-left":  "YY",
564		"split-right": "YY",
565	})
566
567	require.Equal(t, []domain.Warning{
568		{Msg: `command code "YY" for "split-right" conflicts with "split-left"`},
569		{Msg: `command code "ZZ" for "new-tab" conflicts with "new-session"`},
570	}, warnings)
571}
572
573func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
574	d := newTestDaemon(t, nil, stubClock{})
575	require.NotPanics(t, func() {
576		d.ApplyConfig(domain.Config{
577			Theme: domain.ThemeAuto,
578			BindingEntries: []domain.ConfigEntry{
579				{Key: "unknown-action", Value: "alt+x"},
580				{Key: "focus-pane-left", Value: "ctrl+x"},
581			},
582			Codes: map[string]string{
583				"new-tab":         "toolong",
584				"split-right":     "det",
585				"missing-command": "OK",
586			},
587		})
588	})
589
590	cmd, ok := d.commandByEffectiveCode("CNT")
591	require.True(t, ok)
592	require.Equal(t, "new-tab", cmd.Slug)
593	_, ok = d.commandByEffectiveCode("TOOLONG")
594	require.False(t, ok)
595}
596
597func TestConfigReloadNoticeMessageIncludesLineOnlyWhenKnown(t *testing.T) {
598	tests := []struct {
599		name     string
600		warnings []domain.Warning
601		want     string
602	}{
603		{
604			name:     "no source line",
605			warnings: []domain.Warning{{Msg: "unknown action"}},
606			want:     "config reloaded with 1 warning(s): unknown action",
607		},
608		{
609			name:     "source line",
610			warnings: []domain.Warning{{Line: 12, Msg: "unknown action"}},
611			want:     "config reloaded with 1 warning(s): line 12: unknown action",
612		},
613	}
614	for _, tt := range tests {
615		t.Run(tt.name, func(t *testing.T) {
616			require.Equal(t, tt.want, configReloadNoticeMessage(tt.warnings))
617		})
618	}
619}
620
621func TestApplyConfigSurfacesOneNoticeAggregatingAllWarnings(t *testing.T) {
622	d := newTestDaemon(t, nil, stubClock{})
623	d.ApplyConfig(domain.Config{
624		Theme: domain.ThemeAuto,
625		BindingEntries: []domain.ConfigEntry{
626			{Key: "unknown-action", Value: "alt+x"},
627		},
628		Codes: map[string]string{
629			"missing-command": "OK",
630			"new-tab":         "toolong",
631		},
632	})
633
634	history := d.notices.history()
635	var reloadNotices []domain.Notification
636	for _, n := range history {
637		if n.Code == domain.NoticeConfigReload {
638			reloadNotices = append(reloadNotices, n)
639		}
640	}
641	require.Len(t, reloadNotices, 1, "expected exactly one config-reload notice per ApplyConfig call")
642
643	n := reloadNotices[0]
644	require.Equal(t, domain.NoticeWarn, n.Severity)
645	require.Equal(t, domain.SessionID(""), n.SessionID, "config reload notice must be global")
646	require.Contains(t, n.Message, "config reloaded with 3 warning(s):")
647	require.NotContains(t, n.Message, "line 0:")
648	require.Contains(t, n.Details, `unknown action "unknown-action"`)
649	require.Contains(t, n.Details, `unknown command code slug "missing-command"`)
650	require.Contains(t, n.Details, `invalid command code for "new-tab"`)
651}

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