v0.22.0 a85da87

migrate_test.go RawHistoryFolder

1package config
2
3import (
4	"os"
5	"path/filepath"
6	"testing"
7
8	"github.com/stretchr/testify/assert"
9	"github.com/stretchr/testify/require"
10)
11
12func TestMigrator_NewMigrator(t *testing.T) {
13	m := NewMigrator()
14	require.NotNil(t, m)
15	require.NotNil(t, m.defaultViper)
16	require.NotNil(t, m.defaultConfig)
17}
18
19func TestMigrator_GetAllDefaultKeys(t *testing.T) {
20	m := NewMigrator()
21	keys := m.getAllDefaultKeys()
22
23	// Should have many default keys
24	assert.Greater(t, len(keys), 50, "should have many default keys")
25
26	// Should not include database.path (it's dynamically set)
27	for _, key := range keys {
28		assert.NotEqual(t, "database.path", key, "should not include database.path")
29	}
30
31	// Should include some known keys
32	keySet := make(map[string]bool)
33	for _, k := range keys {
34		keySet[k] = true
35	}
36
37	assert.True(t, keySet["history.max_entries"], "should include history.max_entries")
38	assert.True(t, keySet["logging.level"], "should include logging.level")
39	assert.True(t, keySet["appearance.sans_font"], "should include appearance.sans_font")
40}
41
42func TestMigrator_GetUserConfigKeys(t *testing.T) {
43	m := NewMigrator()
44
45	// Create a temp config file
46	tmpDir := t.TempDir()
47	configFile := filepath.Join(tmpDir, "config.toml")
48
49	content := `
50[history]
51max_entries = 5000
52retention_period_days = 180
53
54[logging]
55level = "debug"
56
57[appearance]
58sans_font = "Arial"
59`
60	err := os.WriteFile(configFile, []byte(content), 0o644)
61	require.NoError(t, err)
62
63	keys, err := m.getUserConfigKeys(configFile)
64	require.NoError(t, err)
65
66	// Should have the keys we defined
67	assert.True(t, keys["history.max_entries"])
68	assert.True(t, keys["history.retention_period_days"])
69	assert.True(t, keys["logging.level"])
70	assert.True(t, keys["appearance.sans_font"])
71
72	// Should not have keys we didn't define
73	assert.False(t, keys["history.cleanup_interval_days"])
74	assert.False(t, keys["logging.format"])
75}
76
77func TestMigrator_FlattenMap(t *testing.T) {
78	m := NewMigrator()
79
80	tests := []struct {
81		name     string
82		input    map[string]any
83		prefix   string
84		expected []string
85	}{
86		{
87			name: "simple flat map",
88			input: map[string]any{
89				"key1": "value1",
90				"key2": 123,
91			},
92			prefix:   "",
93			expected: []string{"key1", "key2"},
94		},
95		{
96			name: "nested map",
97			input: map[string]any{
98				"parent": map[string]any{
99					"child1": "value1",
100					"child2": "value2",
101				},
102			},
103			prefix:   "",
104			expected: []string{"parent.child1", "parent.child2"},
105		},
106		{
107			name: "with prefix",
108			input: map[string]any{
109				"key": "value",
110			},
111			prefix:   "section",
112			expected: []string{"section.key"},
113		},
114		{
115			name: "search_shortcuts treated as single key",
116			input: map[string]any{
117				"search_shortcuts": map[string]any{
118					"g":   "https://google.com/?q=%s",
119					"ddg": "https://duckduckgo.com/?q=%s",
120				},
121			},
122			prefix:   "",
123			expected: []string{"search_shortcuts"}, // Should be treated as user data
124		},
125		{
126			name: "actions map treated as single key",
127			input: map[string]any{
128				"pane_mode": map[string]any{
129					"actions": map[string]any{
130						"split-right": []string{"r"},
131					},
132				},
133			},
134			prefix:   "",
135			expected: []string{"pane_mode.actions"}, // Actions treated as user data
136		},
137	}
138
139	for _, tt := range tests {
140		t.Run(tt.name, func(t *testing.T) {
141			keys := make(map[string]bool)
142			m.flattenMap(tt.input, tt.prefix, keys)
143
144			for _, expectedKey := range tt.expected {
145				assert.True(t, keys[expectedKey], "should contain key: %s", expectedKey)
146			}
147		})
148	}
149}
150
151func TestMigrator_IsUserDataSection(t *testing.T) {
152	m := NewMigrator()
153
154	tests := []struct {
155		name     string
156		keyPath  string
157		expected bool
158	}{
159		{
160			name:     "search_shortcuts is user data",
161			keyPath:  "search_shortcuts",
162			expected: true,
163		},
164		{
165			name:     "pane_mode.actions is user data",
166			keyPath:  "pane_mode.actions",
167			expected: true,
168		},
169		{
170			name:     "tab_mode.actions is user data",
171			keyPath:  "tab_mode.actions",
172			expected: true,
173		},
174		{
175			name:     "history is not user data",
176			keyPath:  "history",
177			expected: false,
178		},
179		{
180			name:     "history.max_entries is not user data",
181			keyPath:  "history.max_entries",
182			expected: false,
183		},
184		{
185			name:     "appearance.sans_font is not user data",
186			keyPath:  "appearance.sans_font",
187			expected: false,
188		},
189	}
190
191	for _, tt := range tests {
192		t.Run(tt.name, func(t *testing.T) {
193			result := m.isUserDataSection(tt.keyPath)
194			assert.Equal(t, tt.expected, result)
195		})
196	}
197}
198
199func TestMigrator_FindMissingKeys(t *testing.T) {
200	m := NewMigrator()
201
202	tests := []struct {
203		name        string
204		defaultKeys []string
205		userKeys    map[string]bool
206		expected    []string
207	}{
208		{
209			name:        "all keys present",
210			defaultKeys: []string{"key1", "key2"},
211			userKeys:    map[string]bool{"key1": true, "key2": true},
212			expected:    []string{},
213		},
214		{
215			name:        "some keys missing",
216			defaultKeys: []string{"key1", "key2", "key3"},
217			userKeys:    map[string]bool{"key1": true},
218			expected:    []string{"key2", "key3"},
219		},
220		{
221			name:        "all keys missing",
222			defaultKeys: []string{"key1", "key2"},
223			userKeys:    map[string]bool{},
224			expected:    []string{"key1", "key2"},
225		},
226		{
227			name:        "parent key covers children",
228			defaultKeys: []string{"parent.child1", "parent.child2"},
229			userKeys:    map[string]bool{"parent": true},
230			expected:    []string{}, // Parent covers children
231		},
232		{
233			name:        "child keys cover parent (struct defaults like palettes)",
234			defaultKeys: []string{"appearance.dark_palette"},
235			userKeys:    map[string]bool{"appearance.dark_palette.background": true, "appearance.dark_palette.accent": true},
236			expected:    []string{}, // Child keys mean parent is defined
237		},
238	}
239
240	for _, tt := range tests {
241		t.Run(tt.name, func(t *testing.T) {
242			result := m.findMissingKeys(tt.defaultKeys, tt.userKeys)
243			assert.ElementsMatch(t, tt.expected, result)
244		})
245	}
246}
247
248func TestMigrator_GetKeyInfo(t *testing.T) {
249	m := NewMigrator()
250
251	tests := []struct {
252		name         string
253		key          string
254		expectedType string
255	}{
256		{
257			name:         "bool key",
258			key:          "debug.enable_devtools",
259			expectedType: "bool",
260		},
261		{
262			name:         "int key",
263			key:          "history.max_entries",
264			expectedType: "int",
265		},
266		{
267			name:         "string key",
268			key:          "logging.level",
269			expectedType: "string",
270		},
271		{
272			name:         "float key",
273			key:          "default_webpage_zoom",
274			expectedType: "float",
275		},
276	}
277
278	for _, tt := range tests {
279		t.Run(tt.name, func(t *testing.T) {
280			info := m.GetKeyInfo(tt.key)
281			assert.Equal(t, tt.key, info.Key)
282			assert.Equal(t, tt.expectedType, info.Type)
283			assert.NotEmpty(t, info.DefaultValue)
284		})
285	}
286}
287
288func TestMigrator_GetTypeName(t *testing.T) {
289	m := NewMigrator()
290
291	tests := []struct {
292		name     string
293		value    any
294		expected string
295	}{
296		{name: "bool", value: true, expected: "bool"},
297		{name: "int", value: 42, expected: "int"},
298		{name: "int64", value: int64(42), expected: "int"},
299		{name: "float64", value: 3.14, expected: "float"},
300		{name: "string", value: "hello", expected: "string"},
301		{name: "slice", value: []any{"a", "b"}, expected: "list"},
302		{name: "map", value: map[string]any{"a": 1}, expected: "map"},
303		{name: "nil", value: nil, expected: "unknown"},
304	}
305
306	for _, tt := range tests {
307		t.Run(tt.name, func(t *testing.T) {
308			result := m.getTypeName(tt.value)
309			assert.Equal(t, tt.expected, result)
310		})
311	}
312}
313
314func TestMigrator_FormatValue(t *testing.T) {
315	m := NewMigrator()
316
317	tests := []struct {
318		name     string
319		value    any
320		expected string
321	}{
322		{name: "true", value: true, expected: "true"},
323		{name: "false", value: false, expected: "false"},
324		{name: "int", value: 42, expected: "42"},
325		{name: "float", value: 3.14, expected: "3.14"},
326		{name: "string", value: "hello", expected: `"hello"`},
327		{name: "empty string", value: "", expected: `""`},
328		{name: "empty slice", value: []any{}, expected: "[]"},
329		{name: "slice with items", value: []any{"a", "b", "c"}, expected: "[3 items]"},
330		{name: "empty map", value: map[string]any{}, expected: "{}"},
331		{name: "map with entries", value: map[string]any{"a": 1, "b": 2}, expected: "{2 entries}"},
332		{name: "nil", value: nil, expected: "null"},
333	}
334
335	for _, tt := range tests {
336		t.Run(tt.name, func(t *testing.T) {
337			result := m.formatValue(tt.value)
338			assert.Equal(t, tt.expected, result)
339		})
340	}
341}
342
343func TestKeysAreSimilar(t *testing.T) {
344	tests := []struct {
345		name     string
346		oldKey   string
347		newKey   string
348		expected bool
349	}{
350		{
351			name:     "identical keys",
352			oldKey:   "section.pane_mode_color",
353			newKey:   "section.pane_mode_color",
354			expected: true,
355		},
356		{
357			name:     "substring match - old contains new",
358			oldKey:   "section.pane_mode_border_color",
359			newKey:   "section.pane_mode_color",
360			expected: true,
361		},
362		{
363			name:     "substring match - new contains old",
364			oldKey:   "section.mode_color",
365			newKey:   "section.pane_mode_color",
366			expected: true,
367		},
368		{
369			name:     "token matching - border removed",
370			oldKey:   "section.tab_mode_border_color",
371			newKey:   "section.tab_mode_color",
372			expected: true,
373		},
374		{
375			name:     "different parent paths",
376			oldKey:   "section_a.pane_mode_color",
377			newKey:   "section_b.pane_mode_color",
378			expected: false,
379		},
380		{
381			name:     "different depth",
382			oldKey:   "pane_mode_color",
383			newKey:   "section.pane_mode_color",
384			expected: false,
385		},
386		{
387			name:     "completely different keys",
388			oldKey:   "section.foo_bar",
389			newKey:   "section.baz_qux",
390			expected: false,
391		},
392		{
393			name:     "top level keys rejected",
394			oldKey:   "foo",
395			newKey:   "bar",
396			expected: false,
397		},
398		{
399			name:     "no common tokens",
400			oldKey:   "section.aaa_bbb",
401			newKey:   "section.ccc_ddd",
402			expected: false,
403		},
404		{
405			name:     "single token difference allowed",
406			oldKey:   "section.pane_mode_border_width",
407			newKey:   "section.mode_border_width",
408			expected: true,
409		},
410		{
411			name:     "duplicate tokens in old key - no double counting",
412			oldKey:   "section.mode_mode_mode",
413			newKey:   "section.mode_other_another",
414			expected: false, // oldTokens=[mode,mode,mode], newTokens=[mode,other,another], matches=1 (only one mode can match), minTokens=3, needs 1>=2 which is false
415		},
416		{
417			name:     "enough common tokens with duplicates",
418			oldKey:   "section.pane_mode_border",
419			newKey:   "section.pane_mode_color",
420			expected: true, // oldTokens=[pane,mode,border], newTokens=[pane,mode,color], matches=2, minTokens=3, 2>=2 && 2>0 = true
421		},
422	}
423
424	for _, tt := range tests {
425		t.Run(tt.name, func(t *testing.T) {
426			result := keysAreSimilar(tt.oldKey, tt.newKey)
427			assert.Equal(t, tt.expected, result, "keysAreSimilar(%q, %q)", tt.oldKey, tt.newKey)
428		})
429	}
430}
431
432func TestMigrator_TypesAreCompatible(t *testing.T) {
433	m := NewMigrator()
434
435	tests := []struct {
436		name     string
437		oldKey   string
438		newKey   string
439		expected bool
440	}{
441		{
442			name:     "color to color - compatible (using real key)",
443			oldKey:   "workspace.styling.pane_mode_border_color",
444			newKey:   "workspace.styling.pane_mode_color",
445			expected: true,
446		},
447		{
448			name:     "width to width - compatible (using real key)",
449			oldKey:   "workspace.styling.pane_mode_border_width",
450			newKey:   "workspace.styling.mode_border_width",
451			expected: true,
452		},
453		{
454			name:     "width suffix to color type - incompatible",
455			oldKey:   "workspace.styling.some_width",
456			newKey:   "workspace.styling.pane_mode_color", // real key with string type
457			expected: false,
458		},
459		{
460			name:     "color suffix to int type - incompatible",
461			oldKey:   "workspace.styling.some_color",
462			newKey:   "workspace.styling.mode_border_width", // real key with int type
463			expected: false,
464		},
465		{
466			name:     "generic key to string type - compatible",
467			oldKey:   "workspace.styling.some_setting",
468			newKey:   "workspace.styling.pane_mode_color", // real key with string type
469			expected: true,
470		},
471		{
472			name:     "non-existent new key - incompatible",
473			oldKey:   "section.old_key",
474			newKey:   "section.nonexistent_key_xyz",
475			expected: false,
476		},
477	}
478
479	for _, tt := range tests {
480		t.Run(tt.name, func(t *testing.T) {
481			result := m.typesAreCompatible(tt.oldKey, tt.newKey)
482			assert.Equal(t, tt.expected, result, "typesAreCompatible(%q, %q)", tt.oldKey, tt.newKey)
483		})
484	}
485}
486
487func TestMigrator_FindDeprecatedKeys(t *testing.T) {
488	m := NewMigrator()
489
490	tests := []struct {
491		name        string
492		userKeys    map[string]any
493		defaultKeys map[string]bool
494		expected    []string
495	}{
496		{
497			name: "no deprecated keys",
498			userKeys: map[string]any{
499				"history.max_entries": 1000,
500				"logging.level":       "info",
501			},
502			defaultKeys: map[string]bool{
503				"history.max_entries": true,
504				"logging.level":       true,
505			},
506			expected: []string{},
507		},
508		{
509			name: "one deprecated key",
510			userKeys: map[string]any{
511				"history.max_entries": 1000,
512				"old.deprecated_key":  "value",
513			},
514			defaultKeys: map[string]bool{
515				"history.max_entries": true,
516			},
517			expected: []string{"old.deprecated_key"},
518		},
519		{
520			name: "multiple deprecated keys",
521			userKeys: map[string]any{
522				"old.key1": "a",
523				"old.key2": "b",
524				"old.key3": "c",
525			},
526			defaultKeys: map[string]bool{
527				"new.key1": true,
528			},
529			expected: []string{"old.key1", "old.key2", "old.key3"},
530		},
531		{
532			name: "parent key exists in defaults",
533			userKeys: map[string]any{
534				"parent.child": "value",
535			},
536			defaultKeys: map[string]bool{
537				"parent.child":       true,
538				"parent.other_child": true,
539			},
540			expected: []string{},
541		},
542	}
543
544	for _, tt := range tests {
545		t.Run(tt.name, func(t *testing.T) {
546			result := m.findDeprecatedKeys(tt.userKeys, tt.defaultKeys)
547			assert.ElementsMatch(t, tt.expected, result)
548		})
549	}
550}
551
552func TestMigrator_MatchRenamedKeys(t *testing.T) {
553	m := NewMigrator()
554
555	tests := []struct {
556		name                 string
557		deprecated           []string
558		missing              []string
559		expectedRenames      map[string]string
560		expectedUnmatchedOld []string
561		expectedUnmatchedNew []string
562	}{
563		{
564			name:                 "no keys to match",
565			deprecated:           []string{},
566			missing:              []string{},
567			expectedRenames:      map[string]string{},
568			expectedUnmatchedOld: nil,
569			expectedUnmatchedNew: nil,
570		},
571		{
572			name:       "simple rename match (using real keys)",
573			deprecated: []string{"workspace.styling.pane_mode_border_color"},
574			missing:    []string{"workspace.styling.pane_mode_color"},
575			expectedRenames: map[string]string{
576				"workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
577			},
578			expectedUnmatchedOld: nil,
579			expectedUnmatchedNew: nil,
580		},
581		{
582			name:                 "no match possible - different types (width vs color)",
583			deprecated:           []string{"workspace.styling.pane_mode_border_width"},
584			missing:              []string{"workspace.styling.pane_mode_color"},
585			expectedRenames:      map[string]string{},
586			expectedUnmatchedOld: []string{"workspace.styling.pane_mode_border_width"},
587			expectedUnmatchedNew: []string{"workspace.styling.pane_mode_color"},
588		},
589		{
590			name:                 "no match possible - different parents",
591			deprecated:           []string{"old_section.some_key"},
592			missing:              []string{"new_section.some_key"},
593			expectedRenames:      map[string]string{},
594			expectedUnmatchedOld: []string{"old_section.some_key"},
595			expectedUnmatchedNew: []string{"new_section.some_key"},
596		},
597		{
598			name:       "multiple renames (using real keys)",
599			deprecated: []string{"workspace.styling.pane_mode_border_color", "workspace.styling.tab_mode_border_color"},
600			missing:    []string{"workspace.styling.pane_mode_color", "workspace.styling.tab_mode_color"},
601			expectedRenames: map[string]string{
602				"workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
603				"workspace.styling.tab_mode_border_color":  "workspace.styling.tab_mode_color",
604			},
605			expectedUnmatchedOld: nil,
606			expectedUnmatchedNew: nil,
607		},
608	}
609
610	for _, tt := range tests {
611		t.Run(tt.name, func(t *testing.T) {
612			renames, unmatchedOld, unmatchedNew := m.matchRenamedKeys(tt.deprecated, tt.missing)
613			assert.Equal(t, tt.expectedRenames, renames, "renames mismatch")
614			assert.ElementsMatch(t, tt.expectedUnmatchedOld, unmatchedOld, "unmatched old mismatch")
615			assert.ElementsMatch(t, tt.expectedUnmatchedNew, unmatchedNew, "unmatched new mismatch")
616		})
617	}
618}
619
620func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) {
621	// This test would need to temporarily modify the config path
622	// which is difficult without refactoring. Skip for now.
623	t.Skip("requires config path mocking")
624}
625
626func TestMigrator_Migrate_NoConfigFile(t *testing.T) {
627	// This test would need to temporarily modify the config path
628	// which is difficult without refactoring. Skip for now.
629	t.Skip("requires config path mocking")
630}
631
632func TestMigrator_GetConfigType(t *testing.T) {
633	m := NewMigrator()
634
635	tests := []struct {
636		name       string
637		configFile string
638		expected   string
639	}{
640		{
641			name:       "toml file",
642			configFile: "/path/to/config.toml",
643			expected:   "toml",
644		},
645		{
646			name:       "yaml file",
647			configFile: "/path/to/config.yaml",
648			expected:   "yaml",
649		},
650		{
651			name:       "yml file",
652			configFile: "/path/to/config.yml",
653			expected:   "yaml",
654		},
655		{
656			name:       "json file",
657			configFile: "/path/to/config.json",
658			expected:   "json",
659		},
660		{
661			name:       "unknown extension defaults to toml",
662			configFile: "/path/to/config.unknown",
663			expected:   "toml",
664		},
665		{
666			name:       "no extension defaults to toml",
667			configFile: "/path/to/config",
668			expected:   "toml",
669		},
670	}
671
672	for _, tt := range tests {
673		t.Run(tt.name, func(t *testing.T) {
674			result := m.getConfigType(tt.configFile)
675			assert.Equal(t, tt.expected, result)
676		})
677	}
678}
679
680func TestMigrator_GetNestedValue(t *testing.T) {
681	m := NewMigrator()
682
683	config := map[string]any{
684		"level1": map[string]any{
685			"level2": map[string]any{
686				"level3": "deep_value",
687			},
688			"simple": "simple_value",
689		},
690		"top": "top_value",
691	}
692
693	tests := []struct {
694		name     string
695		key      string
696		expected any
697	}{
698		{
699			name:     "top level key",
700			key:      "top",
701			expected: "top_value",
702		},
703		{
704			name:     "nested key",
705			key:      "level1.simple",
706			expected: "simple_value",
707		},
708		{
709			name:     "deeply nested key",
710			key:      "level1.level2.level3",
711			expected: "deep_value",
712		},
713		{
714			name:     "non-existent key",
715			key:      "nonexistent",
716			expected: nil,
717		},
718		{
719			name:     "non-existent nested key",
720			key:      "level1.nonexistent",
721			expected: nil,
722		},
723	}
724
725	for _, tt := range tests {
726		t.Run(tt.name, func(t *testing.T) {
727			result := m.getNestedValue(config, tt.key)
728			assert.Equal(t, tt.expected, result)
729		})
730	}
731}
732
733func TestMigrator_SetNestedValue(t *testing.T) {
734	m := NewMigrator()
735
736	t.Run("set top level value", func(t *testing.T) {
737		config := make(map[string]any)
738		m.setNestedValue(config, "top", "value")
739		assert.Equal(t, "value", config["top"])
740	})
741
742	t.Run("set nested value with existing parent", func(t *testing.T) {
743		config := map[string]any{
744			"parent": map[string]any{},
745		}
746		m.setNestedValue(config, "parent.child", "value")
747		parent := config["parent"].(map[string]any)
748		assert.Equal(t, "value", parent["child"])
749	})
750
751	t.Run("set nested value creates intermediate maps", func(t *testing.T) {
752		config := make(map[string]any)
753		m.setNestedValue(config, "a.b.c", "deep_value")
754
755		a := config["a"].(map[string]any)
756		b := a["b"].(map[string]any)
757		assert.Equal(t, "deep_value", b["c"])
758	})
759}
760
761func TestMigrator_DeleteNestedKey(t *testing.T) {
762	m := NewMigrator()
763
764	t.Run("delete top level key", func(t *testing.T) {
765		config := map[string]any{
766			"keep":   "value",
767			"delete": "value",
768		}
769		m.deleteNestedKey(config, "delete")
770		assert.Contains(t, config, "keep")
771		assert.NotContains(t, config, "delete")
772	})
773
774	t.Run("delete nested key", func(t *testing.T) {
775		config := map[string]any{
776			"parent": map[string]any{
777				"keep":   "value",
778				"delete": "value",
779			},
780		}
781		m.deleteNestedKey(config, "parent.delete")
782		parent := config["parent"].(map[string]any)
783		assert.Contains(t, parent, "keep")
784		assert.NotContains(t, parent, "delete")
785	})
786
787	t.Run("delete nested key cleans up empty parents", func(t *testing.T) {
788		config := map[string]any{
789			"keep": "value",
790			"parent": map[string]any{
791				"child": map[string]any{
792					"leaf": "value",
793				},
794			},
795		}
796		m.deleteNestedKey(config, "parent.child.leaf")
797		// Both parent and child should be removed since they're empty
798		assert.NotContains(t, config, "parent")
799		assert.Contains(t, config, "keep")
800	})
801
802	t.Run("delete non-existent key is no-op", func(t *testing.T) {
803		config := map[string]any{
804			"keep": "value",
805		}
806		m.deleteNestedKey(config, "nonexistent")
807		assert.Contains(t, config, "keep")
808	})
809}
810
811func TestMigrator_ReadRawConfig(t *testing.T) {
812	m := NewMigrator()
813
814	t.Run("read TOML config", func(t *testing.T) {
815		tmpDir := t.TempDir()
816		configFile := filepath.Join(tmpDir, "config.toml")
817
818		content := `
819[section]
820key = "value"
821number = 42
822`
823		err := os.WriteFile(configFile, []byte(content), 0o644)
824		require.NoError(t, err)
825
826		config, err := m.readRawConfig(configFile)
827		require.NoError(t, err)
828
829		section := config["section"].(map[string]any)
830		assert.Equal(t, "value", section["key"])
831		assert.Equal(t, int64(42), section["number"])
832	})
833
834	t.Run("read YAML config", func(t *testing.T) {
835		tmpDir := t.TempDir()
836		configFile := filepath.Join(tmpDir, "config.yaml")
837
838		content := `
839section:
840  key: value
841  number: 42
842`
843		err := os.WriteFile(configFile, []byte(content), 0o644)
844		require.NoError(t, err)
845
846		config, err := m.readRawConfig(configFile)
847		require.NoError(t, err)
848
849		section := config["section"].(map[string]any)
850		assert.Equal(t, "value", section["key"])
851		assert.Equal(t, 42, section["number"])
852	})
853
854	t.Run("read JSON config", func(t *testing.T) {
855		tmpDir := t.TempDir()
856		configFile := filepath.Join(tmpDir, "config.json")
857
858		content := `{
859  "section": {
860    "key": "value",
861    "number": 42
862  }
863}`
864		err := os.WriteFile(configFile, []byte(content), 0o644)
865		require.NoError(t, err)
866
867		config, err := m.readRawConfig(configFile)
868		require.NoError(t, err)
869
870		section := config["section"].(map[string]any)
871		assert.Equal(t, "value", section["key"])
872		// JSON numbers are float64
873		assert.InDelta(t, 42.0, section["number"].(float64), 0.001)
874	})
875}

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 8

Tags 42

↑↓ moveenter openesc close