detached ece7682

config_test.go RawHistoryFolder

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

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