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.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
359				tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
360			}
361			if tt.want.Copy.WordSeparators == "" {
362				tt.want.Copy = domain.Defaults().Copy
363			}
364			if !tt.want.Palette.AnchorSet {
365				tt.want.Palette = domain.Defaults().Palette
366			}
367			tt.want.Scrollback = domain.DefaultScrollbackConfig()
368			got, warnings, err := Parse(strings.NewReader(tt.input))
369			if err != nil {
370				t.Fatalf("Parse() error = %v", err)
371			}
372			if !reflect.DeepEqual(got, tt.want) {
373				t.Fatalf("Parse() config = %#v, want %#v", got, tt.want)
374			}
375			if !reflect.DeepEqual(warnings, tt.wantWarnings) {
376				t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
377			}
378		})
379	}
380}
381
382func TestParseThemePalette(t *testing.T) {
383	t.Parallel()
384
385	tests := []struct {
386		name         string
387		input        string
388		want         bool
389		wantWarnings []domain.Warning
390	}{
391		{
392			name:  "on enables terminal palette inheritance",
393			input: "theme.palette = on\n",
394			want:  true,
395		},
396		{
397			name:  "off disables terminal palette inheritance",
398			input: "theme.palette = off\n",
399			want:  false,
400		},
401		{
402			name:  "invalid value warns once and keeps default",
403			input: "theme.palette = maybe\n",
404			want:  true,
405			wantWarnings: []domain.Warning{
406				{Line: 1, Msg: "invalid theme.palette \"maybe\""},
407			},
408		},
409		{
410			name:  "absent key keeps default",
411			input: "theme = dark\n",
412			want:  true,
413		},
414	}
415
416	for _, tt := range tests {
417		t.Run(tt.name, func(t *testing.T) {
418			t.Parallel()
419
420			got, warnings, err := Parse(strings.NewReader(tt.input))
421			require.NoError(t, err)
422			require.Equal(t, tt.want, got.ThemePalette)
423			require.Equal(t, tt.wantWarnings, warnings)
424		})
425	}
426}
427
428func TestDefaultsThemePalette(t *testing.T) {
429	t.Parallel()
430
431	require.True(t, domain.Defaults().ThemePalette)
432}
433
434func TestParseTabsTerminalTitle(t *testing.T) {
435	t.Parallel()
436
437	tests := []struct {
438		name         string
439		input        string
440		want         bool
441		wantWarnings []domain.Warning
442	}{
443		{
444			name:  "on enables terminal title",
445			input: "tabs.terminal-title = on\n",
446			want:  true,
447		},
448		{
449			name:  "off disables terminal title",
450			input: "tabs.terminal-title = off\n",
451			want:  false,
452		},
453		{
454			name:  "invalid value warns and keeps default",
455			input: "tabs.terminal-title = maybe\n",
456			want:  true,
457			wantWarnings: []domain.Warning{
458				{Line: 1, Msg: "invalid tabs.terminal-title \"maybe\""},
459			},
460		},
461		{
462			name:  "absent key keeps default",
463			input: "theme = dark\n",
464			want:  true,
465		},
466	}
467
468	for _, tt := range tests {
469		t.Run(tt.name, func(t *testing.T) {
470			t.Parallel()
471
472			got, warnings, err := Parse(strings.NewReader(tt.input))
473			if err != nil {
474				t.Fatalf("Parse() error = %v", err)
475			}
476			if got.Tabs.TerminalTitle != tt.want {
477				t.Fatalf("Parse() Tabs.TerminalTitle = %v, want %v", got.Tabs.TerminalTitle, tt.want)
478			}
479			if !reflect.DeepEqual(warnings, tt.wantWarnings) {
480				t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
481			}
482		})
483	}
484}
485
486func TestParseEphemeralCloseOnExit(t *testing.T) {
487	t.Parallel()
488
489	tests := []struct {
490		name         string
491		input        string
492		want         bool
493		wantWarnings []domain.Warning
494	}{
495		{name: "absent key defaults on", input: "theme = dark\n", want: true},
496		{name: "on", input: "ephemeral.close-on-exit = on\n", want: true},
497		{name: "off", input: "ephemeral.close-on-exit = off\n", want: false},
498		{
499			name:  "invalid value warns and keeps default",
500			input: "ephemeral.close-on-exit = kill\n",
501			want:  true,
502			wantWarnings: []domain.Warning{
503				{Line: 1, Msg: "invalid ephemeral.close-on-exit \"kill\""},
504			},
505		},
506	}
507
508	for _, tt := range tests {
509		t.Run(tt.name, func(t *testing.T) {
510			t.Parallel()
511
512			got, warnings, err := Parse(strings.NewReader(tt.input))
513			if err != nil {
514				t.Fatalf("Parse() error = %v", err)
515			}
516			if got.Ephemeral.CloseOnExit != tt.want {
517				t.Fatalf("Parse() Ephemeral.CloseOnExit = %v, want %v", got.Ephemeral.CloseOnExit, tt.want)
518			}
519			if !reflect.DeepEqual(warnings, tt.wantWarnings) {
520				t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
521			}
522		})
523	}
524}
525
526func TestParseKeyboardKittyProtocol(t *testing.T) {
527	t.Parallel()
528
529	tests := []struct {
530		name         string
531		input        string
532		want         bool
533		wantWarnings []domain.Warning
534	}{
535		{name: "absent key defaults on", input: "theme = dark\n", want: true},
536		{name: "on", input: "keyboard.kitty-protocol = on\n", want: true},
537		{name: "off", input: "keyboard.kitty-protocol = off\n", want: false},
538		{
539			name:  "invalid value warns and keeps default",
540			input: "keyboard.kitty-protocol = maybe\n",
541			want:  true,
542			wantWarnings: []domain.Warning{
543				{Line: 1, Msg: "invalid keyboard.kitty-protocol \"maybe\""},
544			},
545		},
546	}
547
548	for _, tt := range tests {
549		t.Run(tt.name, func(t *testing.T) {
550			t.Parallel()
551
552			got, warnings, err := Parse(strings.NewReader(tt.input))
553			if err != nil {
554				t.Fatalf("Parse() error = %v", err)
555			}
556			if got.Keyboard.KittyProtocol != tt.want {
557				t.Fatalf("Parse() Keyboard.KittyProtocol = %v, want %v", got.Keyboard.KittyProtocol, tt.want)
558			}
559			if !reflect.DeepEqual(warnings, tt.wantWarnings) {
560				t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
561			}
562		})
563	}
564}
565
566func TestParseNavigationOverflow(t *testing.T) {
567	t.Parallel()
568
569	tests := []struct {
570		name         string
571		input        string
572		want         domain.NavConfig
573		wantWarnings []domain.Warning
574	}{
575		{
576			name: "absent defaults both settings off",
577		},
578		{
579			name:  "tabs on enables only tab overflow",
580			input: "nav.overflow-tabs = on\n",
581			want:  domain.NavConfig{OverflowTabs: true},
582		},
583		{
584			name:  "sessions on enables only session overflow",
585			input: "nav.overflow-sessions = on\n",
586			want:  domain.NavConfig{OverflowSessions: true},
587		},
588		{
589			name:  "off disables both settings",
590			input: "nav.overflow-tabs = off\nnav.overflow-sessions = off\n",
591		},
592		{
593			name:  "settings are independent",
594			input: "nav.overflow-tabs = on\nnav.overflow-sessions = off\n",
595			want:  domain.NavConfig{OverflowTabs: true},
596		},
597		{
598			name:  "invalid values warn and preserve defaults",
599			input: "nav.overflow-tabs = maybe\nnav.overflow-sessions = always\n",
600			wantWarnings: []domain.Warning{
601				{Line: 1, Msg: `invalid nav.overflow-tabs "maybe"`},
602				{Line: 2, Msg: `invalid nav.overflow-sessions "always"`},
603			},
604		},
605		{
606			name:  "duplicate keys warn and last valid value wins",
607			input: "nav.overflow-tabs = off\nnav.overflow-tabs = on\nnav.overflow-sessions = on\nnav.overflow-sessions = off\n",
608			want:  domain.NavConfig{OverflowTabs: true},
609			wantWarnings: []domain.Warning{
610				{Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
611				{Line: 4, Msg: `duplicate key "nav.overflow-sessions"`},
612			},
613		},
614		{
615			name:  "invalid duplicate still warns and keeps previous value",
616			input: "nav.overflow-tabs = on\nnav.overflow-tabs = maybe\n",
617			want:  domain.NavConfig{OverflowTabs: true},
618			wantWarnings: []domain.Warning{
619				{Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
620				{Line: 2, Msg: `invalid nav.overflow-tabs "maybe"`},
621			},
622		},
623	}
624
625	for _, tt := range tests {
626		t.Run(tt.name, func(t *testing.T) {
627			t.Parallel()
628
629			cfg, warnings, err := Parse(strings.NewReader(tt.input))
630			require.NoError(t, err)
631			require.Equal(t, tt.want, cfg.Nav)
632			require.Equal(t, tt.wantWarnings, warnings)
633		})
634	}
635}
636
637func TestDefaultsDisableNavigationOverflow(t *testing.T) {
638	t.Parallel()
639
640	require.Equal(t, domain.NavConfig{}, domain.Defaults().Nav)
641}
642
643func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
644	t.Parallel()
645
646	first := domain.Defaults()
647	first.Snapshot.RestoreProcesses[0] = "mutated"
648	second := domain.Defaults()
649	require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
650}
651
652func TestParseCopyWordSeparators(t *testing.T) {
653	t.Parallel()
654
655	tests := []struct {
656		name         string
657		input        string
658		want         string
659		wantWarnings []domain.Warning
660	}{
661		{
662			name:  "default",
663			input: "",
664			want:  domain.DefaultWordSeparators,
665		},
666		{
667			name:  "quoted preserves leading separator space",
668			input: `copy.word-separators = " -_@/"`,
669			want:  " -_@/",
670		},
671		{
672			name:  "quoted empty is valid",
673			input: `copy.word-separators = ""`,
674			want:  "",
675		},
676		{
677			name:  "unquoted empty is valid",
678			input: "copy.word-separators =",
679			want:  "",
680		},
681		{
682			name:  "malformed quote keeps default",
683			input: `copy.word-separators = "unterminated`,
684			want:  domain.DefaultWordSeparators,
685			wantWarnings: []domain.Warning{
686				{Line: 1, Msg: `invalid copy.word-separators "\"unterminated"`},
687			},
688		},
689		{
690			name:  "duplicate last value wins",
691			input: "copy.word-separators = /\ncopy.word-separators = _",
692			want:  "_",
693			wantWarnings: []domain.Warning{
694				{Line: 2, Msg: `duplicate key "copy.word-separators"`},
695			},
696		},
697	}
698
699	for _, tt := range tests {
700		t.Run(tt.name, func(t *testing.T) {
701			t.Parallel()
702
703			cfg, warnings, err := Parse(strings.NewReader(tt.input))
704			require.NoError(t, err)
705			require.Equal(t, tt.want, cfg.Copy.WordSeparators)
706			require.Equal(t, tt.wantWarnings, warnings)
707		})
708	}
709}
710
711func TestDefaultsCopyWordSeparators(t *testing.T) {
712	t.Parallel()
713
714	require.Equal(t, " -_@", domain.Defaults().Copy.WordSeparators)
715}
716
717func TestParsePreservesBindingFileOrder(t *testing.T) {
718	t.Parallel()
719
720	cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n"))
721	if err != nil {
722		t.Fatalf("Parse() error = %v", err)
723	}
724	if len(warnings) != 0 {
725		t.Fatalf("Parse() warnings = %#v, want none", warnings)
726	}
727	want := []domain.ConfigEntry{
728		{Key: "open-palette", Value: "alt+p"},
729		{Key: "focus-pane-left", Value: "alt+p"},
730	}
731	if !reflect.DeepEqual(cfg.BindingEntries, want) {
732		t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want)
733	}
734}
735
736func TestLoadMissingFileReturnsDefaults(t *testing.T) {
737	t.Parallel()
738
739	cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
740	if err != nil {
741		t.Fatalf("Load() error = %v", err)
742	}
743	if len(warnings) != 0 {
744		t.Fatalf("Load() warnings = %#v, want none", warnings)
745	}
746	if !reflect.DeepEqual(cfg, domain.Defaults()) {
747		t.Fatalf("Load() config = %#v, want defaults", cfg)
748	}
749}
750
751func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
752	t.Parallel()
753
754	path := filepath.Join(t.TempDir(), "config")
755	if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
756		t.Fatal(err)
757	}
758
759	clk := newWatchClockMock(t)
760	ctx, cancel := context.WithCancel(context.Background())
761	defer cancel()
762
763	type change struct {
764		cfg      domain.Config
765		warnings []domain.Warning
766	}
767	changes := make(chan change, 2)
768	done := make(chan error, 1)
769	go func() {
770		done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
771			changes <- change{cfg: cfg, warnings: warnings}
772		})
773	}()
774
775	clk.waitForTimer()
776	if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil {
777		t.Fatal(err)
778	}
779	clk.fire()
780
781	var got change
782	select {
783	case got = <-changes:
784	case <-time.After(2 * time.Second):
785		t.Fatal("timed out waiting for config reload")
786	}
787	if got.cfg.Theme != domain.ThemeLight {
788		t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight)
789	}
790	if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) {
791		t.Fatalf("Watch() warnings = %#v", got.warnings)
792	}
793
794	cancel()
795	if err := <-done; !errors.Is(err, context.Canceled) {
796		t.Fatalf("Watch() error = %v, want context.Canceled", err)
797	}
798}
799
800func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {
801	t.Parallel()
802
803	path := filepath.Join(t.TempDir(), "config")
804	if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
805		t.Fatal(err)
806	}
807
808	clk := newWatchClockMock(t)
809	ctx, cancel := context.WithCancel(context.Background())
810	defer cancel()
811
812	type change struct {
813		cfg      domain.Config
814		warnings []domain.Warning
815	}
816	changes := make(chan change, 2)
817	done := make(chan error, 1)
818	go func() {
819		done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
820			changes <- change{cfg: cfg, warnings: warnings}
821		})
822	}()
823
824	clk.waitForTimer()
825	tooLong := strings.Repeat("x", 70*1024)
826	if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil {
827		t.Fatal(err)
828	}
829	clk.fire()
830
831	got := <-changes
832	require.Equal(t, domain.Defaults(), got.cfg)
833	require.NotEmpty(t, got.warnings)
834
835	cancel()
836	if err := <-done; !errors.Is(err, context.Canceled) {
837		t.Fatalf("Watch() error = %v, want context.Canceled", err)
838	}
839}
840
841func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
842	t.Parallel()
843
844	path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
845	clk := newWatchClockMock(t)
846	ctx, cancel := context.WithCancel(context.Background())
847	defer cancel()
848
849	changes := make(chan []domain.Warning, 4)
850	done := make(chan error, 1)
851	go func() {
852		done <- Watch(ctx, clk.clock, path, func(_ domain.Config, warnings []domain.Warning) {
853			changes <- warnings
854		})
855	}()
856
857	first := <-changes
858	require.Len(t, first, 1)
859	clk.waitForTimer()
860	clk.fire()
861	clk.fire()
862
863	select {
864	case extra := <-changes:
865		t.Fatalf("Watch() repeated persistent stat error: %#v", extra)
866	case <-time.After(100 * time.Millisecond):
867	}
868
869	cancel()
870	if err := <-done; !errors.Is(err, context.Canceled) {
871		t.Fatalf("Watch() error = %v, want context.Canceled", err)
872	}
873}
874
875type watchClockMock struct {
876	t      *testing.T
877	clock  *portsmocks.MockClock
878	ticks  chan time.Time
879	ready  chan struct{}
880	resets chan struct{}
881}
882
883func newWatchClockMock(t *testing.T) *watchClockMock {
884	t.Helper()
885	timer := portsmocks.NewMockTimer(t)
886	clock := portsmocks.NewMockClock(t)
887	m := &watchClockMock{
888		t:      t,
889		clock:  clock,
890		ticks:  make(chan time.Time),
891		ready:  make(chan struct{}),
892		resets: make(chan struct{}),
893	}
894	timer.EXPECT().C().Return(m.ticks)
895	timer.EXPECT().Reset(pollInterval).Run(func(time.Duration) {
896		m.resets <- struct{}{}
897	}).Return(false)
898	timer.EXPECT().Stop().Return(true)
899	clock.EXPECT().NewTimer(pollInterval).Run(func(time.Duration) {
900		close(m.ready)
901	}).Return(timer).Once()
902	return m
903}
904
905func (m *watchClockMock) waitForTimer() {
906	m.t.Helper()
907	select {
908	case <-m.ready:
909	case <-time.After(time.Second):
910		m.t.Fatal("timed out waiting for watcher timer creation")
911	}
912}
913
914func (m *watchClockMock) fire() {
915	m.t.Helper()
916	select {
917	case m.ticks <- time.Now():
918	case <-time.After(time.Second):
919		m.t.Fatal("timed out firing watcher timer")
920	}
921	select {
922	case <-m.resets:
923	case <-time.After(time.Second):
924		m.t.Fatal("timed out waiting for watcher timer reset")
925	}
926}

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