config_test.go RawHistoryFolder

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

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