v0.2.0 80c61e3

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

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