v0.8.1 1f54f61

config_test.go RawHistoryFolder

1package config
2
3import (
4	"context"
5	"errors"
6	"os"
7	"path/filepath"
8	"reflect"
9	"strings"
10	"sync/atomic"
11	"testing"
12	"time"
13
14	"github.com/bnema/kvconf"
15	"github.com/bnema/vev/internal/domain"
16	"github.com/stretchr/testify/require"
17)
18
19func TestDefaultsDisableBarCommands(t *testing.T) {
20	bar := domain.Defaults().Bar
21	require.Empty(t, bar.TopRight)
22	require.Empty(t, bar.BottomRight)
23}
24
25func TestParse(t *testing.T) {
26	t.Parallel()
27
28	tests := []struct {
29		name         string
30		input        string
31		want         domain.Config
32		wantWarnings []domain.Warning
33	}{
34		{
35			name:  "comments and inline comments",
36			input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n",
37			want: domain.Config{
38				Theme: domain.ThemeAuto,
39				BindingEntries: []domain.ConfigEntry{
40					{Key: "new-tab", Value: "alt+t"},
41					{Key: "detach", Value: "alt+#"},
42				},
43				Codes: map[string]string{},
44			},
45		},
46		{
47			name:  "crlf and code prefix",
48			input: "theme = light\r\ncode.detach = dt\r\n",
49			want: domain.Config{
50				Theme:          domain.ThemeLight,
51				BindingEntries: []domain.ConfigEntry{},
52				Codes:          map[string]string{"detach": "dt"},
53			},
54		},
55		{
56			name:  "invalid theme warns and keeps default",
57			input: "theme = blue\n",
58			want: domain.Config{
59				Theme:          domain.ThemeAuto,
60				BindingEntries: []domain.ConfigEntry{},
61				Codes:          map[string]string{},
62			},
63			wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid theme \"blue\""}},
64		},
65		{
66			name:  "unknown binding key stored",
67			input: "unknown.action = alt+x\n",
68			want: domain.Config{
69				Theme:          domain.ThemeAuto,
70				BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}},
71				Codes:          map[string]string{},
72			},
73		},
74		{
75			name:  "duplicates warn and last wins",
76			input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n",
77			want: domain.Config{
78				Theme:          domain.ThemeAuto,
79				BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}},
80				Codes:          map[string]string{"detach": "DX"},
81			},
82			wantWarnings: []domain.Warning{
83				{Line: 2, Msg: "duplicate key \"new-tab\""},
84				{Line: 4, Msg: "duplicate key \"code.detach\""},
85			},
86		},
87		{
88			name:  "invalid lines report line numbers",
89			input: "\nno equals\n = value\ntheme = dark\n",
90			want: domain.Config{
91				Theme:          domain.ThemeDark,
92				BindingEntries: []domain.ConfigEntry{},
93				Codes:          map[string]string{},
94			},
95			wantWarnings: []domain.Warning{
96				{Line: 2, Msg: "missing '='"},
97				{Line: 3, Msg: "missing key"},
98			},
99		},
100		{
101			name:  "floating settings custom values",
102			input: "floating.command = \"btop --utf-force\"\nfloating.width = 75%\nfloating.height = 60%\n",
103			want: domain.Config{
104				Theme: domain.ThemeAuto,
105				Floating: domain.FloatingConfig{
106					Command: "btop --utf-force",
107					Width:   75,
108					Height:  60,
109				},
110				BindingEntries: []domain.ConfigEntry{},
111				Codes:          map[string]string{},
112			},
113		},
114		{
115			name:  "floating command can be empty or quoted",
116			input: "floating.command = echo ready\nfloating.command = \"\"\n",
117			want: domain.Config{
118				Theme: domain.ThemeAuto,
119				Floating: domain.FloatingConfig{
120					Width:  80,
121					Height: 80,
122				},
123				BindingEntries: []domain.ConfigEntry{},
124				Codes:          map[string]string{},
125			},
126			wantWarnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"floating.command\""}},
127		},
128		{
129			name:  "a value that is only a comment is empty",
130			input: "floating.command = echo ready\nfloating.command = # default shell\n",
131			want: domain.Config{
132				Theme: domain.ThemeAuto,
133				Floating: domain.FloatingConfig{
134					Width:  80,
135					Height: 80,
136				},
137				BindingEntries: []domain.ConfigEntry{},
138				Codes:          map[string]string{},
139			},
140			wantWarnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"floating.command\""}},
141		},
142		{
143			name:  "floating percentage endpoints",
144			input: "floating.width = 1%\nfloating.height = 100%\n",
145			want: domain.Config{
146				Theme: domain.ThemeAuto,
147				Floating: domain.FloatingConfig{
148					Width:  1,
149					Height: 100,
150				},
151				BindingEntries: []domain.ConfigEntry{},
152				Codes:          map[string]string{},
153			},
154		},
155		{
156			name:  "floating invalid percentages warn and preserve defaults",
157			input: "floating.width = 80\nfloating.height = 0%\nfloating.width = 101%\nfloating.height = eighty%\n",
158			want: domain.Config{
159				Theme: domain.ThemeAuto,
160				Floating: domain.FloatingConfig{
161					Width:  80,
162					Height: 80,
163				},
164				BindingEntries: []domain.ConfigEntry{},
165				Codes:          map[string]string{},
166			},
167			wantWarnings: []domain.Warning{
168				{Line: 1, Msg: "invalid floating.width \"80\""},
169				{Line: 2, Msg: "invalid floating.height \"0%\""},
170				{Line: 3, Msg: "duplicate key \"floating.width\""},
171				{Line: 3, Msg: "invalid floating.width \"101%\""},
172				{Line: 4, Msg: "duplicate key \"floating.height\""},
173				{Line: 4, Msg: "invalid floating.height \"eighty%\""},
174			},
175		},
176		{
177			name:  "floating duplicate keys warn and last valid value wins",
178			input: "floating.command = btop\nfloating.command = lazygit\nfloating.width = 70%\nfloating.width = 90%\nfloating.height = 60%\nfloating.height = 85%\n",
179			want: domain.Config{
180				Theme: domain.ThemeAuto,
181				Floating: domain.FloatingConfig{
182					Command: "lazygit",
183					Width:   90,
184					Height:  85,
185				},
186				BindingEntries: []domain.ConfigEntry{},
187				Codes:          map[string]string{},
188			},
189			wantWarnings: []domain.Warning{
190				{Line: 2, Msg: "duplicate key \"floating.command\""},
191				{Line: 4, Msg: "duplicate key \"floating.width\""},
192				{Line: 6, Msg: "duplicate key \"floating.height\""},
193			},
194		},
195		{
196			name:  "floating malformed quoted command warns and keeps default",
197			input: "floating.command = \"bad\\q\"\n",
198			want: domain.Config{
199				Theme:          domain.ThemeAuto,
200				BindingEntries: []domain.ConfigEntry{},
201				Codes:          map[string]string{},
202			},
203			wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid floating.command \"\\\"bad\\\\q\\\"\""}},
204		},
205		{
206			name:  "bar settings custom values",
207			input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",
208			want: domain.Config{
209				Theme: domain.ThemeAuto,
210				Bar: domain.BarConfig{
211					TopRight:    "custom-top",
212					BottomRight: "custom-bottom",
213					Interval:    2 * time.Second,
214				},
215				BindingEntries: []domain.ConfigEntry{},
216				Codes:          map[string]string{},
217			},
218		},
219		{
220			name:  "bar settings quoted commands unquote",
221			input: "bar.top-right = \"date +%H:%M\"\nbar.bottom-right = \"\"\n",
222			want: domain.Config{
223				Theme: domain.ThemeAuto,
224				Bar: domain.BarConfig{
225					TopRight:    "date +%H:%M",
226					BottomRight: "",
227					Interval:    5 * time.Second,
228				},
229				BindingEntries: []domain.ConfigEntry{},
230				Codes:          map[string]string{},
231			},
232		},
233		{
234			name:  "bar settings quoted commands preserve hash",
235			input: "bar.top-right = \"echo foo #1\" # inline comment\n",
236			want: domain.Config{
237				Theme: domain.ThemeAuto,
238				Bar: domain.BarConfig{
239					TopRight:    "echo foo #1",
240					BottomRight: "",
241					Interval:    5 * time.Second,
242				},
243				BindingEntries: []domain.ConfigEntry{},
244				Codes:          map[string]string{},
245			},
246		},
247		{
248			name:  "bar settings malformed quoted command warns and keeps default",
249			input: "bar.top-right = \"bad\\q\"\n",
250			want: domain.Config{
251				Theme: domain.ThemeAuto,
252				Bar: domain.BarConfig{
253					TopRight:    "",
254					BottomRight: "",
255					Interval:    5 * time.Second,
256				},
257				BindingEntries: []domain.ConfigEntry{},
258				Codes:          map[string]string{},
259			},
260			wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.top-right \"\\\"bad\\\\q\\\"\""}},
261		},
262		{
263			name:  "bar settings empty commands disable anchors",
264			input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",
265			want: domain.Config{
266				Theme: domain.ThemeAuto,
267				Bar: domain.BarConfig{
268					TopRight:    "",
269					BottomRight: "",
270					Interval:    5 * time.Second,
271				},
272				BindingEntries: []domain.ConfigEntry{},
273				Codes:          map[string]string{},
274			},
275		},
276		{
277			name:  "bar interval invalid warns and keeps default",
278			input: "bar.interval = nope\n",
279			want: domain.Config{
280				Theme: domain.ThemeAuto,
281				Bar: domain.BarConfig{
282					TopRight:    "",
283					BottomRight: "",
284					Interval:    5 * time.Second,
285				},
286				BindingEntries: []domain.ConfigEntry{},
287				Codes:          map[string]string{},
288			},
289			wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}},
290		},
291		{
292			name:  "bar interval below minimum clamps and warns",
293			input: "bar.interval = 100ms\n",
294			want: domain.Config{
295				Theme: domain.ThemeAuto,
296				Bar: domain.BarConfig{
297					TopRight:    "",
298					BottomRight: "",
299					Interval:    time.Second,
300				},
301				BindingEntries: []domain.ConfigEntry{},
302				Codes:          map[string]string{},
303			},
304			wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}},
305		},
306		{
307			name:  "snapshot restore processes",
308			input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",
309			want: domain.Config{
310				Theme:          domain.ThemeAuto,
311				BindingEntries: []domain.ConfigEntry{},
312				Codes:          map[string]string{},
313				Snapshot: domain.SnapshotConfig{
314					RestoreProcesses:    []string{"claude", "codex", "pi", "opencode", "btop"},
315					RestoreProcessesSet: true,
316				},
317			},
318		},
319		{
320			name:  "snapshot restore processes empty disables",
321			input: "snapshot.restore_processes =\n",
322			want: domain.Config{
323				Theme:          domain.ThemeAuto,
324				BindingEntries: []domain.ConfigEntry{},
325				Codes:          map[string]string{},
326				Snapshot: domain.SnapshotConfig{
327					RestoreProcessesSet: true,
328				},
329			},
330		},
331		{
332			name:  "snapshot restore processes warns on malformed entries",
333			input: "snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n",
334			want: domain.Config{
335				Theme:          domain.ThemeAuto,
336				BindingEntries: []domain.ConfigEntry{},
337				Codes:          map[string]string{},
338				Snapshot: domain.SnapshotConfig{
339					RestoreProcesses:    []string{"claude", "custom-tool", "zsh"},
340					RestoreProcessesSet: true,
341				},
342			},
343			wantWarnings: []domain.Warning{
344				{Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""},
345				{Line: 1, Msg: "invalid snapshot restore process \":all:\""},
346				{Line: 1, Msg: "invalid snapshot restore process \"bad name\""},
347			},
348		},
349	}
350
351	for _, tt := range tests {
352		t.Run(tt.name, func(t *testing.T) {
353			t.Parallel()
354
355			if !tt.want.ThemePalette {
356				tt.want.ThemePalette = domain.Defaults().ThemePalette
357			}
358			if tt.want.Bar.Interval == 0 {
359				tt.want.Bar = domain.Defaults().Bar
360			}
361			if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {
362				tt.want.Floating = domain.Defaults().Floating
363			}
364			if !tt.want.Tabs.TerminalTitle {
365				tt.want.Tabs = domain.Defaults().Tabs
366			}
367			if !tt.want.Ephemeral.CloseOnExit {
368				tt.want.Ephemeral = domain.Defaults().Ephemeral
369			}
370			if !tt.want.Keyboard.KittyProtocol {
371				tt.want.Keyboard = domain.Defaults().Keyboard
372			}
373			if tt.want.Echo.Predict == domain.EchoPredictNever {
374				tt.want.Echo = domain.Defaults().Echo
375			}
376			if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
377				tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
378			}
379			if tt.want.Copy.WordSeparators == "" {
380				tt.want.Copy = domain.Defaults().Copy
381			}
382			if !tt.want.Palette.AnchorSet {
383				tt.want.Palette = domain.Defaults().Palette
384			}
385			tt.want.Scrollback = domain.DefaultScrollbackConfig()
386			got, warnings, err := Parse(strings.NewReader(tt.input))
387			if err != nil {
388				t.Fatalf("Parse() error = %v", err)
389			}
390			if !reflect.DeepEqual(got, tt.want) {
391				t.Fatalf("Parse() config = %#v, want %#v", got, tt.want)
392			}
393			if !reflect.DeepEqual(warnings, tt.wantWarnings) {
394				t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
395			}
396		})
397	}
398}
399
400func TestParseThemePalette(t *testing.T) {
401	t.Parallel()
402
403	tests := []struct {
404		name         string
405		input        string
406		want         bool
407		wantWarnings []domain.Warning
408	}{
409		{
410			name:  "on enables terminal palette inheritance",
411			input: "theme.palette = on\n",
412			want:  true,
413		},
414		{
415			name:  "off disables terminal palette inheritance",
416			input: "theme.palette = off\n",
417			want:  false,
418		},
419		{
420			name:  "invalid value warns once and keeps default",
421			input: "theme.palette = maybe\n",
422			want:  true,
423			wantWarnings: []domain.Warning{
424				{Line: 1, Msg: "invalid theme.palette \"maybe\""},
425			},
426		},
427		{
428			name:  "absent key keeps default",
429			input: "theme = dark\n",
430			want:  true,
431		},
432	}
433
434	for _, tt := range tests {
435		t.Run(tt.name, func(t *testing.T) {
436			t.Parallel()
437
438			got, warnings, err := Parse(strings.NewReader(tt.input))
439			require.NoError(t, err)
440			require.Equal(t, tt.want, got.ThemePalette)
441			require.Equal(t, tt.wantWarnings, warnings)
442		})
443	}
444}
445
446func TestParseBarBackground(t *testing.T) {
447	t.Parallel()
448
449	tests := []struct {
450		name            string
451		input           string
452		wantTransparent bool
453		wantWarnings    []domain.Warning
454	}{
455		{name: "on keeps the theme surface", input: "bar.background = on\n"},
456		{name: "off makes bars transparent", input: "bar.background = off\n", wantTransparent: true},
457		{
458			name:  "invalid value warns and keeps default",
459			input: "bar.background = none\n",
460			wantWarnings: []domain.Warning{
461				{Line: 1, Msg: "invalid bar.background \"none\""},
462			},
463		},
464		{name: "absent key keeps default", input: "bar.interval = 2s\n"},
465	}
466
467	for _, tt := range tests {
468		t.Run(tt.name, func(t *testing.T) {
469			t.Parallel()
470
471			got, warnings, err := Parse(strings.NewReader(tt.input))
472			require.NoError(t, err)
473			require.Equal(t, tt.wantTransparent, got.Bar.Transparent)
474			require.Equal(t, tt.wantWarnings, warnings)
475		})
476	}
477}
478
479func TestDefaultsThemePalette(t *testing.T) {
480	t.Parallel()
481
482	require.True(t, domain.Defaults().ThemePalette)
483}
484
485func TestParseTabsTerminalTitle(t *testing.T) {
486	t.Parallel()
487
488	tests := []struct {
489		name         string
490		input        string
491		want         bool
492		wantWarnings []domain.Warning
493	}{
494		{
495			name:  "on enables terminal title",
496			input: "tabs.terminal-title = on\n",
497			want:  true,
498		},
499		{
500			name:  "off disables terminal title",
501			input: "tabs.terminal-title = off\n",
502			want:  false,
503		},
504		{
505			name:  "invalid value warns and keeps default",
506			input: "tabs.terminal-title = maybe\n",
507			want:  true,
508			wantWarnings: []domain.Warning{
509				{Line: 1, Msg: "invalid tabs.terminal-title \"maybe\""},
510			},
511		},
512		{
513			name:  "absent key keeps default",
514			input: "theme = dark\n",
515			want:  true,
516		},
517	}
518
519	for _, tt := range tests {
520		t.Run(tt.name, func(t *testing.T) {
521			t.Parallel()
522
523			got, warnings, err := Parse(strings.NewReader(tt.input))
524			if err != nil {
525				t.Fatalf("Parse() error = %v", err)
526			}
527			if got.Tabs.TerminalTitle != tt.want {
528				t.Fatalf("Parse() Tabs.TerminalTitle = %v, want %v", got.Tabs.TerminalTitle, tt.want)
529			}
530			if !reflect.DeepEqual(warnings, tt.wantWarnings) {
531				t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
532			}
533		})
534	}
535}
536
537func TestParseEphemeralCloseOnExit(t *testing.T) {
538	t.Parallel()
539
540	tests := []struct {
541		name         string
542		input        string
543		want         bool
544		wantWarnings []domain.Warning
545	}{
546		{name: "absent key defaults on", input: "theme = dark\n", want: true},
547		{name: "on", input: "ephemeral.close-on-exit = on\n", want: true},
548		{name: "off", input: "ephemeral.close-on-exit = off\n", want: false},
549		{
550			name:  "invalid value warns and keeps default",
551			input: "ephemeral.close-on-exit = kill\n",
552			want:  true,
553			wantWarnings: []domain.Warning{
554				{Line: 1, Msg: "invalid ephemeral.close-on-exit \"kill\""},
555			},
556		},
557	}
558
559	for _, tt := range tests {
560		t.Run(tt.name, func(t *testing.T) {
561			t.Parallel()
562
563			got, warnings, err := Parse(strings.NewReader(tt.input))
564			if err != nil {
565				t.Fatalf("Parse() error = %v", err)
566			}
567			if got.Ephemeral.CloseOnExit != tt.want {
568				t.Fatalf("Parse() Ephemeral.CloseOnExit = %v, want %v", got.Ephemeral.CloseOnExit, tt.want)
569			}
570			if !reflect.DeepEqual(warnings, tt.wantWarnings) {
571				t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
572			}
573		})
574	}
575}
576
577func TestParseKeyboardKittyProtocol(t *testing.T) {
578	t.Parallel()
579
580	tests := []struct {
581		name         string
582		input        string
583		want         bool
584		wantWarnings []domain.Warning
585	}{
586		{name: "absent key defaults on", input: "theme = dark\n", want: true},
587		{name: "on", input: "keyboard.kitty-protocol = on\n", want: true},
588		{name: "off", input: "keyboard.kitty-protocol = off\n", want: false},
589		{
590			name:  "invalid value warns and keeps default",
591			input: "keyboard.kitty-protocol = maybe\n",
592			want:  true,
593			wantWarnings: []domain.Warning{
594				{Line: 1, Msg: "invalid keyboard.kitty-protocol \"maybe\""},
595			},
596		},
597	}
598
599	for _, tt := range tests {
600		t.Run(tt.name, func(t *testing.T) {
601			t.Parallel()
602
603			got, warnings, err := Parse(strings.NewReader(tt.input))
604			if err != nil {
605				t.Fatalf("Parse() error = %v", err)
606			}
607			if got.Keyboard.KittyProtocol != tt.want {
608				t.Fatalf("Parse() Keyboard.KittyProtocol = %v, want %v", got.Keyboard.KittyProtocol, tt.want)
609			}
610			if !reflect.DeepEqual(warnings, tt.wantWarnings) {
611				t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
612			}
613		})
614	}
615}
616
617func TestParseEchoPredict(t *testing.T) {
618	t.Parallel()
619
620	tests := []struct {
621		name         string
622		input        string
623		want         domain.EchoPredictMode
624		wantWarnings []domain.Warning
625	}{
626		{name: "absent key defaults adaptive", input: "theme = dark\n", want: domain.EchoPredictAdaptive},
627		{name: "adaptive", input: "echo.predict = adaptive\n", want: domain.EchoPredictAdaptive},
628		{name: "always", input: "echo.predict = always\n", want: domain.EchoPredictAlways},
629		{name: "never", input: "echo.predict = Never\n", want: domain.EchoPredictNever},
630		{
631			name:  "invalid value warns and keeps default",
632			input: "echo.predict = sometimes\n",
633			want:  domain.EchoPredictAdaptive,
634			wantWarnings: []domain.Warning{
635				{Line: 1, Msg: "invalid echo.predict \"sometimes\" (want adaptive, always, or never)"},
636			},
637		},
638		{
639			name:  "duplicate key warns and keeps last",
640			input: "echo.predict = always\necho.predict = never\n",
641			want:  domain.EchoPredictNever,
642			wantWarnings: []domain.Warning{
643				{Line: 2, Msg: "duplicate key \"echo.predict\""},
644			},
645		},
646	}
647
648	for _, tt := range tests {
649		t.Run(tt.name, func(t *testing.T) {
650			t.Parallel()
651
652			got, warnings, err := Parse(strings.NewReader(tt.input))
653			if err != nil {
654				t.Fatalf("Parse() error = %v", err)
655			}
656			if got.Echo.Predict != tt.want {
657				t.Fatalf("Parse() Echo.Predict = %v, want %v", got.Echo.Predict, tt.want)
658			}
659			if !reflect.DeepEqual(warnings, tt.wantWarnings) {
660				t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
661			}
662		})
663	}
664}
665
666func TestParseNavigationOverflow(t *testing.T) {
667	t.Parallel()
668
669	tests := []struct {
670		name         string
671		input        string
672		want         domain.NavConfig
673		wantWarnings []domain.Warning
674	}{
675		{
676			name: "absent defaults both settings off",
677		},
678		{
679			name:  "tabs on enables only tab overflow",
680			input: "nav.overflow-tabs = on\n",
681			want:  domain.NavConfig{OverflowTabs: true},
682		},
683		{
684			name:  "sessions on enables only session overflow",
685			input: "nav.overflow-sessions = on\n",
686			want:  domain.NavConfig{OverflowSessions: true},
687		},
688		{
689			name:  "off disables both settings",
690			input: "nav.overflow-tabs = off\nnav.overflow-sessions = off\n",
691		},
692		{
693			name:  "settings are independent",
694			input: "nav.overflow-tabs = on\nnav.overflow-sessions = off\n",
695			want:  domain.NavConfig{OverflowTabs: true},
696		},
697		{
698			name:  "invalid values warn and preserve defaults",
699			input: "nav.overflow-tabs = maybe\nnav.overflow-sessions = always\n",
700			wantWarnings: []domain.Warning{
701				{Line: 1, Msg: `invalid nav.overflow-tabs "maybe"`},
702				{Line: 2, Msg: `invalid nav.overflow-sessions "always"`},
703			},
704		},
705		{
706			name:  "duplicate keys warn and last valid value wins",
707			input: "nav.overflow-tabs = off\nnav.overflow-tabs = on\nnav.overflow-sessions = on\nnav.overflow-sessions = off\n",
708			want:  domain.NavConfig{OverflowTabs: true},
709			wantWarnings: []domain.Warning{
710				{Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
711				{Line: 4, Msg: `duplicate key "nav.overflow-sessions"`},
712			},
713		},
714		{
715			name:  "invalid duplicate still warns and keeps previous value",
716			input: "nav.overflow-tabs = on\nnav.overflow-tabs = maybe\n",
717			want:  domain.NavConfig{OverflowTabs: true},
718			wantWarnings: []domain.Warning{
719				{Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
720				{Line: 2, Msg: `invalid nav.overflow-tabs "maybe"`},
721			},
722		},
723	}
724
725	for _, tt := range tests {
726		t.Run(tt.name, func(t *testing.T) {
727			t.Parallel()
728
729			cfg, warnings, err := Parse(strings.NewReader(tt.input))
730			require.NoError(t, err)
731			require.Equal(t, tt.want, cfg.Nav)
732			require.Equal(t, tt.wantWarnings, warnings)
733		})
734	}
735}
736
737func TestDefaultsDisableNavigationOverflow(t *testing.T) {
738	t.Parallel()
739
740	require.Equal(t, domain.NavConfig{}, domain.Defaults().Nav)
741}
742
743func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
744	t.Parallel()
745
746	first := domain.Defaults()
747	first.Snapshot.RestoreProcesses[0] = "mutated"
748	second := domain.Defaults()
749	require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
750}
751
752func TestParseCopyWordSeparators(t *testing.T) {
753	t.Parallel()
754
755	tests := []struct {
756		name         string
757		input        string
758		want         string
759		wantWarnings []domain.Warning
760	}{
761		{
762			name:  "default",
763			input: "",
764			want:  domain.DefaultWordSeparators,
765		},
766		{
767			name:  "quoted preserves leading separator space",
768			input: `copy.word-separators = " -_@/"`,
769			want:  " -_@/",
770		},
771		{
772			name:  "quoted empty is valid",
773			input: `copy.word-separators = ""`,
774			want:  "",
775		},
776		{
777			name:  "unquoted empty is valid",
778			input: "copy.word-separators =",
779			want:  "",
780		},
781		{
782			name:  "malformed quote keeps default",
783			input: `copy.word-separators = "unterminated`,
784			want:  domain.DefaultWordSeparators,
785			wantWarnings: []domain.Warning{
786				{Line: 1, Msg: `invalid copy.word-separators "\"unterminated"`},
787			},
788		},
789		{
790			name:  "duplicate last value wins",
791			input: "copy.word-separators = /\ncopy.word-separators = _",
792			want:  "_",
793			wantWarnings: []domain.Warning{
794				{Line: 2, Msg: `duplicate key "copy.word-separators"`},
795			},
796		},
797	}
798
799	for _, tt := range tests {
800		t.Run(tt.name, func(t *testing.T) {
801			t.Parallel()
802
803			cfg, warnings, err := Parse(strings.NewReader(tt.input))
804			require.NoError(t, err)
805			require.Equal(t, tt.want, cfg.Copy.WordSeparators)
806			require.Equal(t, tt.wantWarnings, warnings)
807		})
808	}
809}
810
811func TestDefaultsCopyWordSeparators(t *testing.T) {
812	t.Parallel()
813
814	require.Equal(t, " -_@", domain.Defaults().Copy.WordSeparators)
815}
816
817func TestParsePreservesBindingFileOrder(t *testing.T) {
818	t.Parallel()
819
820	cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n"))
821	if err != nil {
822		t.Fatalf("Parse() error = %v", err)
823	}
824	if len(warnings) != 0 {
825		t.Fatalf("Parse() warnings = %#v, want none", warnings)
826	}
827	want := []domain.ConfigEntry{
828		{Key: "open-palette", Value: "alt+p"},
829		{Key: "focus-pane-left", Value: "alt+p"},
830	}
831	if !reflect.DeepEqual(cfg.BindingEntries, want) {
832		t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want)
833	}
834}
835
836func TestLoadMissingFileReturnsDefaults(t *testing.T) {
837	t.Parallel()
838
839	cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
840	if err != nil {
841		t.Fatalf("Load() error = %v", err)
842	}
843	if len(warnings) != 0 {
844		t.Fatalf("Load() warnings = %#v, want none", warnings)
845	}
846	if !reflect.DeepEqual(cfg, domain.Defaults()) {
847		t.Fatalf("Load() config = %#v, want defaults", cfg)
848	}
849}
850
851type watchChange struct {
852	cfg      domain.Config
853	warnings []domain.Warning
854}
855
856// startWatch runs watch on path and returns the onChange stream once the
857// watches are installed. The returned stop cancels it and checks its result.
858func startWatch(t *testing.T, path string) (<-chan watchChange, func()) {
859	t.Helper()
860	ctx, cancel := context.WithCancel(context.Background())
861	t.Cleanup(cancel)
862
863	changes := make(chan watchChange, 8)
864	ready := make(chan struct{})
865	done := make(chan error, 1)
866	go func() {
867		done <- watch(ctx, path, kvconf.WatchOptions{Debounce: 10 * time.Millisecond, Ready: ready},
868			func(cfg domain.Config, warnings []domain.Warning) {
869				changes <- watchChange{cfg: cfg, warnings: warnings}
870			})
871	}()
872	select {
873	case <-ready:
874	case err := <-done:
875		t.Fatalf("watch() returned before ready: %v", err)
876	case <-time.After(5 * time.Second):
877		t.Fatal("timed out waiting for the watcher to be ready")
878	}
879	return changes, func() {
880		t.Helper()
881		cancel()
882		select {
883		case err := <-done:
884			require.ErrorIs(t, err, context.Canceled)
885		case <-time.After(5 * time.Second):
886			t.Fatal("timed out waiting for watch() to return")
887		}
888	}
889}
890
891// writeAtomic replaces path in one step, like most editors: the watcher
892// never sees a truncated file, whatever the scheduling.
893func writeAtomic(t *testing.T, path string, data []byte) {
894	t.Helper()
895	tmp := path + ".tmp"
896	require.NoError(t, os.WriteFile(tmp, data, 0o600))
897	require.NoError(t, os.Rename(tmp, path))
898}
899
900func nextChange(t *testing.T, changes <-chan watchChange) watchChange {
901	t.Helper()
902	select {
903	case got := <-changes:
904		return got
905	case <-time.After(5 * time.Second):
906		t.Fatal("timed out waiting for config reload")
907		return watchChange{}
908	}
909}
910
911func TestWatchReloadsOnChange(t *testing.T) {
912	t.Parallel()
913
914	light := domain.Defaults()
915	light.Theme = domain.ThemeLight
916
917	tests := []struct {
918		name         string
919		initial      *string // nil: the file does not exist yet
920		write        func(t *testing.T, path string)
921		wantConfig   domain.Config
922		wantWarnings []domain.Warning
923		wantAnyWarn  bool
924	}{
925		{
926			name:    "changed file reloads with parse warnings",
927			initial: ptr("theme = dark\n"),
928			write: func(t *testing.T, path string) {
929				writeAtomic(t, path, []byte("theme = light\nbad line\n"))
930			},
931			wantConfig:   light,
932			wantWarnings: []domain.Warning{{Line: 2, Msg: "missing '='"}},
933		},
934		{
935			name:    "created file loads",
936			initial: nil,
937			write: func(t *testing.T, path string) {
938				writeAtomic(t, path, []byte("theme = light\n"))
939			},
940			wantConfig: light,
941		},
942		{
943			name:    "deleted file reloads to defaults",
944			initial: ptr("theme = light\n"),
945			write: func(t *testing.T, path string) {
946				require.NoError(t, os.Remove(path))
947			},
948			wantConfig: domain.Defaults(),
949		},
950		{
951			name:    "load failure reports defaults and a warning",
952			initial: ptr("theme = light\n"),
953			write: func(t *testing.T, path string) {
954				// Larger than kvconf.DefaultMaxBytes.
955				writeAtomic(t, path, []byte(strings.Repeat("# x\n", kvconf.DefaultMaxBytes/4+1)))
956			},
957			wantConfig:  domain.Defaults(),
958			wantAnyWarn: true,
959		},
960		{
961			name:    "invalid UTF-8 reports defaults and a warning",
962			initial: ptr("theme = light\n"),
963			write: func(t *testing.T, path string) {
964				writeAtomic(t, path, []byte("theme = \xff\n"))
965			},
966			wantConfig:  domain.Defaults(),
967			wantAnyWarn: true,
968		},
969	}
970	for _, tt := range tests {
971		t.Run(tt.name, func(t *testing.T) {
972			t.Parallel()
973
974			path := filepath.Join(t.TempDir(), "config")
975			if tt.initial != nil {
976				writeAtomic(t, path, []byte(*tt.initial))
977			}
978			changes, stop := startWatch(t, path)
979			tt.write(t, path)
980
981			got := nextChange(t, changes)
982			require.Equal(t, tt.wantConfig, got.cfg)
983			if tt.wantAnyWarn {
984				require.NotEmpty(t, got.warnings)
985			} else {
986				require.Equal(t, tt.wantWarnings, got.warnings)
987			}
988			stop()
989		})
990	}
991}
992
993// A load error is reported once until a good load, then again.
994func TestWatchReportsLoadErrorOnce(t *testing.T) {
995	t.Parallel()
996
997	path := filepath.Join(t.TempDir(), "config")
998	writeAtomic(t, path, []byte("theme = light\n"))
999	changes, stop := startWatch(t, path)
1000	invalid := []byte("theme = \xff\n")
1001
1002	writeAtomic(t, path, invalid)
1003	got := nextChange(t, changes)
1004	require.Equal(t, domain.Defaults(), got.cfg)
1005	require.Len(t, got.warnings, 1)
1006
1007	// The same failure again is not reported; the next good load is.
1008	writeAtomic(t, path, invalid)
1009	writeAtomic(t, path, []byte("theme = dark\n"))
1010	got = nextChange(t, changes)
1011	require.Equal(t, domain.ThemeDark, got.cfg.Theme)
1012	require.Empty(t, got.warnings)
1013
1014	// After a good load, the failure is news again.
1015	writeAtomic(t, path, invalid)
1016	got = nextChange(t, changes)
1017	require.Len(t, got.warnings, 1)
1018	stop()
1019}
1020
1021// A stat error at startup other than a missing file is reported.
1022func TestWatchReportsStartupStatError(t *testing.T) {
1023	t.Parallel()
1024
1025	// A name longer than NAME_MAX makes every stat fail.
1026	path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
1027	ctx, cancel := context.WithCancel(context.Background())
1028	defer cancel()
1029	changes := make(chan watchChange, 4)
1030	done := make(chan error, 1)
1031	go func() {
1032		done <- watch(ctx, path, kvconf.WatchOptions{Poll: time.Hour}, func(cfg domain.Config, warnings []domain.Warning) {
1033			changes <- watchChange{cfg: cfg, warnings: warnings}
1034		})
1035	}()
1036	got := nextChange(t, changes)
1037	require.Equal(t, domain.Defaults(), got.cfg)
1038	require.Len(t, got.warnings, 1)
1039	cancel()
1040	select {
1041	case <-done:
1042	case <-time.After(5 * time.Second):
1043		t.Fatal("timed out waiting for watch() to return")
1044	}
1045}
1046
1047func ptr[T any](v T) *T { return &v }
1048
1049// When native watching fails (inotify limits), watch keeps the current
1050// config, warns once and polls instead of giving up.
1051// Not parallel: it replaces kvWatch.
1052func TestWatchFallsBackToPolling(t *testing.T) {
1053	path := filepath.Join(t.TempDir(), "config")
1054	writeAtomic(t, path, []byte("theme = light\n"))
1055	var polled atomic.Bool
1056	orig := kvWatch
1057	t.Cleanup(func() { kvWatch = orig })
1058	kvWatch = func(ctx context.Context, path string, opts kvconf.WatchOptions, out chan<- struct{}) error {
1059		if opts.Poll == 0 {
1060			return errors.New("too many open files")
1061		}
1062		polled.Store(true)
1063		<-ctx.Done()
1064		return ctx.Err()
1065	}
1066	ctx, cancel := context.WithCancel(context.Background())
1067	changes := make(chan watchChange, 4)
1068	done := make(chan error, 1)
1069	go func() {
1070		done <- watch(ctx, path, kvconf.WatchOptions{}, func(cfg domain.Config, warnings []domain.Warning) {
1071			changes <- watchChange{cfg: cfg, warnings: warnings}
1072		})
1073	}()
1074	got := nextChange(t, changes)
1075	require.Equal(t, domain.ThemeLight, got.cfg.Theme, "the current config is kept")
1076	require.Len(t, got.warnings, 1)
1077	require.Contains(t, got.warnings[0].Msg, "polling every")
1078	cancel()
1079	require.ErrorIs(t, <-done, context.Canceled)
1080	require.True(t, polled.Load())
1081}

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