v0.29.0 21b3150

migrate_test.go RawHistoryFolder

1package config
2
3import (
4	"os"
5	"path/filepath"
6	"strings"
7	"testing"
8
9	port "github.com/bnema/dumber/internal/application/port"
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/require"
12)
13
14func TestMigrator_NewMigrator(t *testing.T) {
15	m := NewMigrator()
16	require.NotNil(t, m)
17	require.NotNil(t, m.defaultViper)
18	require.NotNil(t, m.defaultConfig)
19}
20
21func TestMigrator_GetAllDefaultKeys(t *testing.T) {
22	m := NewMigrator()
23	keys := m.getAllDefaultKeys()
24
25	// Should have many default keys
26	assert.Greater(t, len(keys), 50, "should have many default keys")
27
28	// Should not include database.path (it's dynamically set)
29	for _, key := range keys {
30		assert.NotEqual(t, "database.path", key, "should not include database.path")
31	}
32
33	// Should include some known keys
34	keySet := make(map[string]bool)
35	for _, k := range keys {
36		keySet[k] = true
37	}
38
39	assert.True(t, keySet["history.max_entries"], "should include history.max_entries")
40	assert.True(t, keySet["logging.level"], "should include logging.level")
41	assert.True(t, keySet["appearance.sans_font"], "should include appearance.sans_font")
42}
43
44func TestMigrator_GetUserConfigKeys(t *testing.T) {
45	m := NewMigrator()
46
47	// Create a temp config file
48	tmpDir := t.TempDir()
49	configFile := filepath.Join(tmpDir, "config.toml")
50
51	content := `
52[history]
53max_entries = 5000
54retention_period_days = 180
55
56[logging]
57level = "debug"
58
59[appearance]
60sans_font = "Arial"
61`
62	err := os.WriteFile(configFile, []byte(content), 0o644)
63	require.NoError(t, err)
64
65	keys, err := m.getUserConfigKeys(configFile)
66	require.NoError(t, err)
67
68	// Should have the keys we defined
69	assert.True(t, keys["history.max_entries"])
70	assert.True(t, keys["history.retention_period_days"])
71	assert.True(t, keys["logging.level"])
72	assert.True(t, keys["appearance.sans_font"])
73
74	// Should not have keys we didn't define
75	assert.False(t, keys["history.cleanup_interval_days"])
76	assert.False(t, keys["logging.format"])
77}
78
79func TestMigrator_FlattenMap(t *testing.T) {
80	m := NewMigrator()
81
82	tests := []struct {
83		name     string
84		input    map[string]any
85		prefix   string
86		expected []string
87	}{
88		{
89			name: "simple flat map",
90			input: map[string]any{
91				"key1": "value1",
92				"key2": 123,
93			},
94			prefix:   "",
95			expected: []string{"key1", "key2"},
96		},
97		{
98			name: "nested map",
99			input: map[string]any{
100				"parent": map[string]any{
101					"child1": "value1",
102					"child2": "value2",
103				},
104			},
105			prefix:   "",
106			expected: []string{"parent.child1", "parent.child2"},
107		},
108		{
109			name: "with prefix",
110			input: map[string]any{
111				"key": "value",
112			},
113			prefix:   "section",
114			expected: []string{"section.key"},
115		},
116		{
117			name: "search_shortcuts treated as single key",
118			input: map[string]any{
119				"search_shortcuts": map[string]any{
120					"g":   "https://google.com/?q=%s",
121					"ddg": "https://duckduckgo.com/?q=%s",
122				},
123			},
124			prefix:   "",
125			expected: []string{"search_shortcuts"}, // Should be treated as user data
126		},
127		{
128			name: "actions map treated as single key",
129			input: map[string]any{
130				"pane_mode": map[string]any{
131					"actions": map[string]any{
132						"split-right": []string{"r"},
133					},
134				},
135			},
136			prefix:   "",
137			expected: []string{"pane_mode.actions"}, // Actions treated as user data
138		},
139	}
140
141	for _, tt := range tests {
142		t.Run(tt.name, func(t *testing.T) {
143			keys := make(map[string]bool)
144			m.flattenMap(tt.input, tt.prefix, keys)
145
146			for _, expectedKey := range tt.expected {
147				assert.True(t, keys[expectedKey], "should contain key: %s", expectedKey)
148			}
149		})
150	}
151}
152
153func TestMigrator_IsUserDataSection(t *testing.T) {
154	m := NewMigrator()
155
156	tests := []struct {
157		name     string
158		keyPath  string
159		expected bool
160	}{
161		{
162			name:     "search_shortcuts is user data",
163			keyPath:  "search_shortcuts",
164			expected: true,
165		},
166		{
167			name:     "pane_mode.actions is user data",
168			keyPath:  "pane_mode.actions",
169			expected: true,
170		},
171		{
172			name:     "tab_mode.actions is user data",
173			keyPath:  "tab_mode.actions",
174			expected: true,
175		},
176		{
177			name:     "history is not user data",
178			keyPath:  "history",
179			expected: false,
180		},
181		{
182			name:     "history.max_entries is not user data",
183			keyPath:  "history.max_entries",
184			expected: false,
185		},
186		{
187			name:     "appearance.sans_font is not user data",
188			keyPath:  "appearance.sans_font",
189			expected: false,
190		},
191	}
192
193	for _, tt := range tests {
194		t.Run(tt.name, func(t *testing.T) {
195			result := m.isUserDataSection(tt.keyPath)
196			assert.Equal(t, tt.expected, result)
197		})
198	}
199}
200
201func TestMigrator_FindMissingKeys(t *testing.T) {
202	m := NewMigrator()
203
204	tests := []struct {
205		name        string
206		defaultKeys []string
207		userKeys    map[string]bool
208		expected    []string
209	}{
210		{
211			name:        "all keys present",
212			defaultKeys: []string{"key1", "key2"},
213			userKeys:    map[string]bool{"key1": true, "key2": true},
214			expected:    []string{},
215		},
216		{
217			name:        "some keys missing",
218			defaultKeys: []string{"key1", "key2", "key3"},
219			userKeys:    map[string]bool{"key1": true},
220			expected:    []string{"key2", "key3"},
221		},
222		{
223			name:        "all keys missing",
224			defaultKeys: []string{"key1", "key2"},
225			userKeys:    map[string]bool{},
226			expected:    []string{"key1", "key2"},
227		},
228		{
229			name:        "parent key covers children",
230			defaultKeys: []string{"parent.child1", "parent.child2"},
231			userKeys:    map[string]bool{"parent": true},
232			expected:    []string{}, // Parent covers children
233		},
234		{
235			name:        "child keys cover parent (struct defaults like palettes)",
236			defaultKeys: []string{"appearance.dark_palette"},
237			userKeys:    map[string]bool{"appearance.dark_palette.background": true, "appearance.dark_palette.accent": true},
238			expected:    []string{}, // Child keys mean parent is defined
239		},
240	}
241
242	for _, tt := range tests {
243		t.Run(tt.name, func(t *testing.T) {
244			result := m.findMissingKeys(tt.defaultKeys, tt.userKeys)
245			assert.ElementsMatch(t, tt.expected, result)
246		})
247	}
248}
249
250func TestMigrator_GetKeyInfo(t *testing.T) {
251	m := NewMigrator()
252
253	tests := []struct {
254		name         string
255		key          string
256		expectedType string
257	}{
258		{
259			name:         "bool key",
260			key:          "debug.enable_devtools",
261			expectedType: "bool",
262		},
263		{
264			name:         "int key",
265			key:          "history.max_entries",
266			expectedType: "int",
267		},
268		{
269			name:         "string key",
270			key:          "logging.level",
271			expectedType: "string",
272		},
273		{
274			name:         "float key",
275			key:          "default_webpage_zoom",
276			expectedType: "float",
277		},
278	}
279
280	for _, tt := range tests {
281		t.Run(tt.name, func(t *testing.T) {
282			info := m.GetKeyInfo(tt.key)
283			assert.Equal(t, tt.key, info.Key)
284			assert.Equal(t, tt.expectedType, info.Type)
285			assert.NotEmpty(t, info.DefaultValue)
286		})
287	}
288}
289
290func TestMigrator_GetTypeName(t *testing.T) {
291	m := NewMigrator()
292
293	tests := []struct {
294		name     string
295		value    any
296		expected string
297	}{
298		{name: "bool", value: true, expected: "bool"},
299		{name: "int", value: 42, expected: "int"},
300		{name: "int64", value: int64(42), expected: "int"},
301		{name: "float64", value: 3.14, expected: "float"},
302		{name: "string", value: "hello", expected: "string"},
303		{name: "slice", value: []any{"a", "b"}, expected: "list"},
304		{name: "map", value: map[string]any{"a": 1}, expected: "map"},
305		{name: "nil", value: nil, expected: "unknown"},
306	}
307
308	for _, tt := range tests {
309		t.Run(tt.name, func(t *testing.T) {
310			result := m.getTypeName(tt.value)
311			assert.Equal(t, tt.expected, result)
312		})
313	}
314}
315
316func TestMigrator_FormatValue(t *testing.T) {
317	m := NewMigrator()
318
319	tests := []struct {
320		name     string
321		value    any
322		expected string
323	}{
324		{name: "true", value: true, expected: "true"},
325		{name: "false", value: false, expected: "false"},
326		{name: "int", value: 42, expected: "42"},
327		{name: "float", value: 3.14, expected: "3.14"},
328		{name: "string", value: "hello", expected: `"hello"`},
329		{name: "empty string", value: "", expected: `""`},
330		{name: "empty slice", value: []any{}, expected: "[]"},
331		{name: "slice with items", value: []any{"a", "b", "c"}, expected: "[3 items]"},
332		{name: "empty map", value: map[string]any{}, expected: "{}"},
333		{name: "map with entries", value: map[string]any{"a": 1, "b": 2}, expected: "{2 entries}"},
334		{name: "nil", value: nil, expected: "null"},
335	}
336
337	for _, tt := range tests {
338		t.Run(tt.name, func(t *testing.T) {
339			result := m.formatValue(tt.value)
340			assert.Equal(t, tt.expected, result)
341		})
342	}
343}
344
345func TestKeysAreSimilar(t *testing.T) {
346	tests := []struct {
347		name     string
348		oldKey   string
349		newKey   string
350		expected bool
351	}{
352		{
353			name:     "identical keys",
354			oldKey:   "section.pane_mode_color",
355			newKey:   "section.pane_mode_color",
356			expected: true,
357		},
358		{
359			name:     "substring match - old contains new",
360			oldKey:   "section.pane_mode_border_color",
361			newKey:   "section.pane_mode_color",
362			expected: true,
363		},
364		{
365			name:     "substring match - new contains old",
366			oldKey:   "section.mode_color",
367			newKey:   "section.pane_mode_color",
368			expected: true,
369		},
370		{
371			name:     "token matching - border removed",
372			oldKey:   "section.tab_mode_border_color",
373			newKey:   "section.tab_mode_color",
374			expected: true,
375		},
376		{
377			name:     "different parent paths",
378			oldKey:   "section_a.pane_mode_color",
379			newKey:   "section_b.pane_mode_color",
380			expected: false,
381		},
382		{
383			name:     "different depth",
384			oldKey:   "pane_mode_color",
385			newKey:   "section.pane_mode_color",
386			expected: false,
387		},
388		{
389			name:     "completely different keys",
390			oldKey:   "section.foo_bar",
391			newKey:   "section.baz_qux",
392			expected: false,
393		},
394		{
395			name:     "top level keys rejected",
396			oldKey:   "foo",
397			newKey:   "bar",
398			expected: false,
399		},
400		{
401			name:     "no common tokens",
402			oldKey:   "section.aaa_bbb",
403			newKey:   "section.ccc_ddd",
404			expected: false,
405		},
406		{
407			name:     "single token difference allowed",
408			oldKey:   "section.pane_mode_border_width",
409			newKey:   "section.mode_border_width",
410			expected: true,
411		},
412		{
413			name:     "duplicate tokens in old key - no double counting",
414			oldKey:   "section.mode_mode_mode",
415			newKey:   "section.mode_other_another",
416			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
417		},
418		{
419			name:     "enough common tokens with duplicates",
420			oldKey:   "section.pane_mode_border",
421			newKey:   "section.pane_mode_color",
422			expected: true, // oldTokens=[pane,mode,border], newTokens=[pane,mode,color], matches=2, minTokens=3, 2>=2 && 2>0 = true
423		},
424	}
425
426	for _, tt := range tests {
427		t.Run(tt.name, func(t *testing.T) {
428			result := keysAreSimilar(tt.oldKey, tt.newKey)
429			assert.Equal(t, tt.expected, result, "keysAreSimilar(%q, %q)", tt.oldKey, tt.newKey)
430		})
431	}
432}
433
434func TestMigrator_TypesAreCompatible(t *testing.T) {
435	m := NewMigrator()
436
437	tests := []struct {
438		name     string
439		oldKey   string
440		newKey   string
441		expected bool
442	}{
443		{
444			name:     "color to color - compatible (using real key)",
445			oldKey:   "workspace.styling.pane_mode_border_color",
446			newKey:   "workspace.styling.pane_mode_color",
447			expected: true,
448		},
449		{
450			name:     "width to width - compatible (using real key)",
451			oldKey:   "workspace.styling.pane_mode_border_width",
452			newKey:   "workspace.styling.mode_border_width",
453			expected: true,
454		},
455		{
456			name:     "width suffix to color type - incompatible",
457			oldKey:   "workspace.styling.some_width",
458			newKey:   "workspace.styling.pane_mode_color", // real key with string type
459			expected: false,
460		},
461		{
462			name:     "color suffix to int type - incompatible",
463			oldKey:   "workspace.styling.some_color",
464			newKey:   "workspace.styling.mode_border_width", // real key with int type
465			expected: false,
466		},
467		{
468			name:     "generic key to string type - compatible",
469			oldKey:   "workspace.styling.some_setting",
470			newKey:   "workspace.styling.pane_mode_color", // real key with string type
471			expected: true,
472		},
473		{
474			name:     "non-existent new key - incompatible",
475			oldKey:   "section.old_key",
476			newKey:   "section.nonexistent_key_xyz",
477			expected: false,
478		},
479	}
480
481	for _, tt := range tests {
482		t.Run(tt.name, func(t *testing.T) {
483			result := m.typesAreCompatible(tt.oldKey, tt.newKey)
484			assert.Equal(t, tt.expected, result, "typesAreCompatible(%q, %q)", tt.oldKey, tt.newKey)
485		})
486	}
487}
488
489func TestMigrator_FindDeprecatedKeys(t *testing.T) {
490	m := NewMigrator()
491
492	tests := []struct {
493		name        string
494		userKeys    map[string]any
495		defaultKeys map[string]bool
496		expected    []string
497	}{
498		{
499			name: "no deprecated keys",
500			userKeys: map[string]any{
501				"history.max_entries": 1000,
502				"logging.level":       "info",
503			},
504			defaultKeys: map[string]bool{
505				"history.max_entries": true,
506				"logging.level":       true,
507			},
508			expected: []string{},
509		},
510		{
511			name: "one deprecated key",
512			userKeys: map[string]any{
513				"history.max_entries": 1000,
514				"old.deprecated_key":  "value",
515			},
516			defaultKeys: map[string]bool{
517				"history.max_entries": true,
518			},
519			expected: []string{"old.deprecated_key"},
520		},
521		{
522			name: "multiple deprecated keys",
523			userKeys: map[string]any{
524				"old.key1": "a",
525				"old.key2": "b",
526				"old.key3": "c",
527			},
528			defaultKeys: map[string]bool{
529				"new.key1": true,
530			},
531			expected: []string{"old.key1", "old.key2", "old.key3"},
532		},
533		{
534			name: "parent key exists in defaults",
535			userKeys: map[string]any{
536				"parent.child": "value",
537			},
538			defaultKeys: map[string]bool{
539				"parent.child":       true,
540				"parent.other_child": true,
541			},
542			expected: []string{},
543		},
544		{
545			name: "database.path ignored as deprecated",
546			userKeys: map[string]any{
547				"database.path": "",
548			},
549			defaultKeys: map[string]bool{
550				"history.max_entries": true,
551			},
552			expected: []string{},
553		},
554	}
555
556	for _, tt := range tests {
557		t.Run(tt.name, func(t *testing.T) {
558			result := m.findDeprecatedKeys(tt.userKeys, tt.defaultKeys)
559			assert.ElementsMatch(t, tt.expected, result)
560		})
561	}
562}
563
564func TestMigrator_MatchRenamedKeys(t *testing.T) {
565	m := NewMigrator()
566
567	tests := []struct {
568		name                 string
569		deprecated           []string
570		missing              []string
571		expectedRenames      map[string]string
572		expectedUnmatchedOld []string
573		expectedUnmatchedNew []string
574	}{
575		{
576			name:                 "no keys to match",
577			deprecated:           []string{},
578			missing:              []string{},
579			expectedRenames:      map[string]string{},
580			expectedUnmatchedOld: nil,
581			expectedUnmatchedNew: nil,
582		},
583		{
584			name:       "simple rename match (using real keys)",
585			deprecated: []string{"workspace.styling.pane_mode_border_color"},
586			missing:    []string{"workspace.styling.pane_mode_color"},
587			expectedRenames: map[string]string{
588				"workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
589			},
590			expectedUnmatchedOld: nil,
591			expectedUnmatchedNew: nil,
592		},
593		{
594			name:                 "no match possible - different types (width vs color)",
595			deprecated:           []string{"workspace.styling.pane_mode_border_width"},
596			missing:              []string{"workspace.styling.pane_mode_color"},
597			expectedRenames:      map[string]string{},
598			expectedUnmatchedOld: []string{"workspace.styling.pane_mode_border_width"},
599			expectedUnmatchedNew: []string{"workspace.styling.pane_mode_color"},
600		},
601		{
602			name:                 "no match possible - different parents",
603			deprecated:           []string{"old_section.some_key"},
604			missing:              []string{"new_section.some_key"},
605			expectedRenames:      map[string]string{},
606			expectedUnmatchedOld: []string{"old_section.some_key"},
607			expectedUnmatchedNew: []string{"new_section.some_key"},
608		},
609		{
610			name:       "multiple renames (using real keys)",
611			deprecated: []string{"workspace.styling.pane_mode_border_color", "workspace.styling.tab_mode_border_color"},
612			missing:    []string{"workspace.styling.pane_mode_color", "workspace.styling.tab_mode_color"},
613			expectedRenames: map[string]string{
614				"workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
615				"workspace.styling.tab_mode_border_color":  "workspace.styling.tab_mode_color",
616			},
617			expectedUnmatchedOld: nil,
618			expectedUnmatchedNew: nil,
619		},
620	}
621
622	for _, tt := range tests {
623		t.Run(tt.name, func(t *testing.T) {
624			renames, unmatchedOld, unmatchedNew := m.matchRenamedKeys(tt.deprecated, tt.missing)
625			assert.Equal(t, tt.expectedRenames, renames, "renames mismatch")
626			assert.ElementsMatch(t, tt.expectedUnmatchedOld, unmatchedOld, "unmatched old mismatch")
627			assert.ElementsMatch(t, tt.expectedUnmatchedNew, unmatchedNew, "unmatched new mismatch")
628		})
629	}
630}
631
632func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) {
633	// This test would need to temporarily modify the config path
634	// which is difficult without refactoring. Skip for now.
635	t.Skip("requires config path mocking")
636}
637
638func TestMigrator_Migrate_NoConfigFile(t *testing.T) {
639	// This test would need to temporarily modify the config path
640	// which is difficult without refactoring. Skip for now.
641	t.Skip("requires config path mocking")
642}
643
644func TestMigrator_GetConfigType(t *testing.T) {
645	m := NewMigrator()
646
647	tests := []struct {
648		name       string
649		configFile string
650		expected   string
651	}{
652		{
653			name:       "toml file",
654			configFile: "/path/to/config.toml",
655			expected:   "toml",
656		},
657		{
658			name:       "yaml file",
659			configFile: "/path/to/config.yaml",
660			expected:   "yaml",
661		},
662		{
663			name:       "yml file",
664			configFile: "/path/to/config.yml",
665			expected:   "yaml",
666		},
667		{
668			name:       "json file",
669			configFile: "/path/to/config.json",
670			expected:   "json",
671		},
672		{
673			name:       "unknown extension defaults to toml",
674			configFile: "/path/to/config.unknown",
675			expected:   "toml",
676		},
677		{
678			name:       "no extension defaults to toml",
679			configFile: "/path/to/config",
680			expected:   "toml",
681		},
682	}
683
684	for _, tt := range tests {
685		t.Run(tt.name, func(t *testing.T) {
686			result := m.getConfigType(tt.configFile)
687			assert.Equal(t, tt.expected, result)
688		})
689	}
690}
691
692func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) {
693	m := NewMigrator()
694
695	defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
696	userActions := make(map[string]any, len(defaultActions))
697	for actionName, value := range defaultActions {
698		userActions[actionName] = value
699	}
700	delete(userActions, "toggle-floating-pane")
701
702	missing := m.detectMissingWorkspaceShortcutActions(map[string]any{
703		"workspace.shortcuts.actions": userActions,
704	})
705
706	assert.Equal(t, []string{"workspace.shortcuts.actions.toggle-floating-pane"}, missing)
707}
708
709func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
710	m := NewMigrator()
711
712	rawConfig := map[string]any{
713		"workspace": map[string]any{
714			"shortcuts": map[string]any{
715				"actions": map[string]any{
716					"close_pane": map[string]any{
717						"keys": []string{"ctrl+w", "ctrl+i"},
718						"desc": "Custom close pane",
719					},
720					"next_tab": map[string]any{
721						"keys": []string{"ctrl+tab"},
722						"desc": "Switch to next tab",
723					},
724					"previous_tab": map[string]any{
725						"keys": []string{"ctrl+shift+tab"},
726						"desc": "Switch to previous tab",
727					},
728					"consume_or_expel_left": map[string]any{
729						"keys": []string{"alt+["},
730						"desc": "Consume/expel pane left",
731					},
732					"consume_or_expel_right": map[string]any{
733						"keys": []string{"alt+]"},
734						"desc": "Consume/expel pane right",
735					},
736					"consume_or_expel_up": map[string]any{
737						"keys": []string{"alt+{"},
738						"desc": "Consume/expel pane up",
739					},
740					"consume_or_expel_down": map[string]any{
741						"keys": []string{"alt+}"},
742						"desc": "Consume/expel pane down",
743					},
744				},
745			},
746		},
747	}
748
749	m.mergeMissingWorkspaceShortcutActions(rawConfig)
750
751	actionsAny := m.getNestedValue(rawConfig, "workspace.shortcuts.actions")
752	actions, ok := actionsAny.(map[string]any)
753	require.True(t, ok)
754
755	toggleAny, hasToggle := actions["toggle-floating-pane"]
756	assert.True(t, hasToggle)
757	assert.NotNil(t, toggleAny)
758
759	closePaneAny, hasClosePane := actions["close_pane"]
760	require.True(t, hasClosePane)
761	closePane, ok := closePaneAny.(map[string]any)
762	require.True(t, ok)
763	assert.Equal(t, "Custom close pane", closePane["desc"])
764	assert.NotContains(t, actions, "close-pane")
765	assert.NotContains(t, actions, "next-tab")
766	assert.NotContains(t, actions, "previous-tab")
767	assert.NotContains(t, actions, "consume-or-expel-left")
768	assert.NotContains(t, actions, "consume-or-expel-right")
769	assert.NotContains(t, actions, "consume-or-expel-up")
770	assert.NotContains(t, actions, "consume-or-expel-down")
771}
772
773func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
774	m := NewMigrator()
775
776	value := m.defaultValueForKey("workspace.shortcuts.actions.toggle-floating-pane")
777
778	action, ok := value.(ActionBinding)
779	require.True(t, ok)
780	assert.Equal(t, []string{"alt+f"}, action.Keys)
781}
782
783func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) {
784	m := NewMigrator()
785
786	value := m.defaultValueForKey("history.max_entries")
787	assert.Equal(t, 10000, value)
788}
789
790func TestMigrator_GetNestedValue(t *testing.T) {
791	m := NewMigrator()
792
793	config := map[string]any{
794		"level1": map[string]any{
795			"level2": map[string]any{
796				"level3": "deep_value",
797			},
798			"simple": "simple_value",
799		},
800		"top": "top_value",
801	}
802
803	tests := []struct {
804		name     string
805		key      string
806		expected any
807	}{
808		{
809			name:     "top level key",
810			key:      "top",
811			expected: "top_value",
812		},
813		{
814			name:     "nested key",
815			key:      "level1.simple",
816			expected: "simple_value",
817		},
818		{
819			name:     "deeply nested key",
820			key:      "level1.level2.level3",
821			expected: "deep_value",
822		},
823		{
824			name:     "non-existent key",
825			key:      "nonexistent",
826			expected: nil,
827		},
828		{
829			name:     "non-existent nested key",
830			key:      "level1.nonexistent",
831			expected: nil,
832		},
833	}
834
835	for _, tt := range tests {
836		t.Run(tt.name, func(t *testing.T) {
837			result := m.getNestedValue(config, tt.key)
838			assert.Equal(t, tt.expected, result)
839		})
840	}
841}
842
843func TestMigrator_SetNestedValue(t *testing.T) {
844	m := NewMigrator()
845
846	t.Run("set top level value", func(t *testing.T) {
847		config := make(map[string]any)
848		m.setNestedValue(config, "top", "value")
849		assert.Equal(t, "value", config["top"])
850	})
851
852	t.Run("set nested value with existing parent", func(t *testing.T) {
853		config := map[string]any{
854			"parent": map[string]any{},
855		}
856		m.setNestedValue(config, "parent.child", "value")
857		parent := config["parent"].(map[string]any)
858		assert.Equal(t, "value", parent["child"])
859	})
860
861	t.Run("set nested value creates intermediate maps", func(t *testing.T) {
862		config := make(map[string]any)
863		m.setNestedValue(config, "a.b.c", "deep_value")
864
865		a := config["a"].(map[string]any)
866		b := a["b"].(map[string]any)
867		assert.Equal(t, "deep_value", b["c"])
868	})
869}
870
871func TestMigrator_DeleteNestedKey(t *testing.T) {
872	m := NewMigrator()
873
874	t.Run("delete top level key", func(t *testing.T) {
875		config := map[string]any{
876			"keep":   "value",
877			"delete": "value",
878		}
879		m.deleteNestedKey(config, "delete")
880		assert.Contains(t, config, "keep")
881		assert.NotContains(t, config, "delete")
882	})
883
884	t.Run("delete nested key", func(t *testing.T) {
885		config := map[string]any{
886			"parent": map[string]any{
887				"keep":   "value",
888				"delete": "value",
889			},
890		}
891		m.deleteNestedKey(config, "parent.delete")
892		parent := config["parent"].(map[string]any)
893		assert.Contains(t, parent, "keep")
894		assert.NotContains(t, parent, "delete")
895	})
896
897	t.Run("delete nested key cleans up empty parents", func(t *testing.T) {
898		config := map[string]any{
899			"keep": "value",
900			"parent": map[string]any{
901				"child": map[string]any{
902					"leaf": "value",
903				},
904			},
905		}
906		m.deleteNestedKey(config, "parent.child.leaf")
907		// Both parent and child should be removed since they're empty
908		assert.NotContains(t, config, "parent")
909		assert.Contains(t, config, "keep")
910	})
911
912	t.Run("delete non-existent key is no-op", func(t *testing.T) {
913		config := map[string]any{
914			"keep": "value",
915		}
916		m.deleteNestedKey(config, "nonexistent")
917		assert.Contains(t, config, "keep")
918	})
919}
920
921// --- Migrate-path popup → browsing_contexts transform tests ---
922
923func TestMigrator_GetUserConfigKeysWithValues_TransformsLegacyPopups(t *testing.T) {
924	m := NewMigrator()
925
926	tmpDir := t.TempDir()
927	configFile := filepath.Join(tmpDir, "config.toml")
928
929	content := `
930[workspace]
931new_pane_url = "about:blank"
932
933[workspace.popups]
934behavior = "tabbed"
935placement = "left"
936oauth_auto_close = false
937`
938	err := os.WriteFile(configFile, []byte(content), 0o644)
939	require.NoError(t, err)
940
941	keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
942	require.NoError(t, err)
943
944	// Legacy popups keys should NOT appear after transform.
945	_, hasPopupsBehavior := keysWithValues["workspace.popups.behavior"]
946	assert.False(t, hasPopupsBehavior, "workspace.popups.behavior should not exist after transform")
947
948	// Browsing_contexts flattened keys should carry the legacy values.
949	assert.Equal(t, "tabbed", keysWithValues["workspace.browsing_contexts.behavior"])
950	assert.Equal(t, "left", keysWithValues["workspace.browsing_contexts.placement"])
951	assert.Equal(t, false, keysWithValues["workspace.browsing_contexts.oauth_auto_close"])
952}
953
954func TestMigrator_GetUserConfigKeysWithValues_BrowsingContextsAlreadyPresent(t *testing.T) {
955	m := NewMigrator()
956
957	tmpDir := t.TempDir()
958	configFile := filepath.Join(tmpDir, "config.toml")
959
960	// Both popups and browsing_contexts present — transform should be a no-op for popups.
961	content := `
962[workspace]
963new_pane_url = "about:blank"
964
965[workspace.popups]
966behavior = "tabbed"
967
968[workspace.browsing_contexts]
969behavior = "split"
970placement = "right"
971`
972	err := os.WriteFile(configFile, []byte(content), 0o644)
973	require.NoError(t, err)
974
975	keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
976	require.NoError(t, err)
977
978	// Browsing_contexts flattened keys should retain their original values.
979	assert.Equal(t, "split", keysWithValues["workspace.browsing_contexts.behavior"])
980	assert.Equal(t, "right", keysWithValues["workspace.browsing_contexts.placement"])
981
982	// Popups should still exist (transform skipped when browsing_contexts already present).
983	_, hasPopups := keysWithValues["workspace.popups.behavior"]
984	assert.True(t, hasPopups, "popups should still exist when browsing_contexts already present")
985}
986
987func TestMigrator_GetUserConfigKeysWithValues_NoPopupsKey(t *testing.T) {
988	m := NewMigrator()
989
990	tmpDir := t.TempDir()
991	configFile := filepath.Join(tmpDir, "config.toml")
992
993	content := `
994[workspace]
995new_pane_url = "about:blank"
996
997[workspace.browsing_contexts]
998behavior = "stacked"
999`
1000	err := os.WriteFile(configFile, []byte(content), 0o644)
1001	require.NoError(t, err)
1002
1003	keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1004	require.NoError(t, err)
1005
1006	// Should only have browsing_contexts, no popups.
1007	assert.Equal(t, "stacked", keysWithValues["workspace.browsing_contexts.behavior"])
1008
1009	_, hasPopups := keysWithValues["workspace.popups.behavior"]
1010	assert.False(t, hasPopups, "popups should not exist when absent from config")
1011}
1012
1013func TestMigrator_DetectChanges_ReportsLegacyBrowsingContextsRename(t *testing.T) {
1014	home := t.TempDir()
1015	t.Setenv("HOME", home)
1016	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1017	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1018	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1019	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1020
1021	configFile, err := GetConfigFile()
1022	require.NoError(t, err)
1023	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1024
1025	content, err := encodeConfigToTOML(DefaultConfig())
1026	require.NoError(t, err)
1027	content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1028	require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1029
1030	m := NewMigrator()
1031	changes, err := m.DetectChanges()
1032	require.NoError(t, err)
1033
1034	found := false
1035	for _, change := range changes {
1036		if change.Type == port.KeyChangeRenamed &&
1037			change.OldKey == "workspace.popups.behavior" &&
1038			change.NewKey == "workspace.browsing_contexts.behavior" {
1039			found = true
1040			break
1041		}
1042	}
1043	assert.True(t, found, "expected legacy workspace.popups.behavior rename to be reported")
1044}
1045
1046func TestMigrator_Migrate_AppliesLegacyBrowsingContextsRename(t *testing.T) {
1047	home := t.TempDir()
1048	t.Setenv("HOME", home)
1049	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1050	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1051	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1052	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1053
1054	configFile, err := GetConfigFile()
1055	require.NoError(t, err)
1056	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1057
1058	content, err := encodeConfigToTOML(DefaultConfig())
1059	require.NoError(t, err)
1060	content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1061	require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1062
1063	m := NewMigrator()
1064	appliedKeys, err := m.Migrate()
1065	require.NoError(t, err)
1066	assert.Contains(t, appliedKeys, "workspace.popups.behavior -> workspace.browsing_contexts.behavior")
1067
1068	migrated, err := os.ReadFile(configFile)
1069	require.NoError(t, err)
1070	assert.Contains(t, string(migrated), "[workspace.browsing_contexts]")
1071	assert.NotContains(t, string(migrated), "[workspace.popups]")
1072}
1073
1074func TestMigrator_DetectChanges_MixedBrowsingContextsDoesNotDoubleReportLegacyRename(t *testing.T) {
1075	home := t.TempDir()
1076	t.Setenv("HOME", home)
1077	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1078	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1079	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1080	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1081
1082	configFile, err := GetConfigFile()
1083	require.NoError(t, err)
1084	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1085	require.NoError(t, os.WriteFile(configFile, []byte(`
1086[workspace.popups]
1087placement = "left"
1088
1089[workspace.browsing_contexts]
1090behavior = "tabbed"
1091`), 0o644))
1092
1093	m := NewMigrator()
1094	changes, err := m.DetectChanges()
1095	require.NoError(t, err)
1096
1097	renameCount := 0
1098	for _, change := range changes {
1099		if change.Type == port.KeyChangeRenamed &&
1100			change.OldKey == "workspace.popups.placement" &&
1101			change.NewKey == "workspace.browsing_contexts.placement" {
1102			renameCount++
1103		}
1104		assert.False(t,
1105			change.Type == port.KeyChangeRemoved && change.OldKey == "workspace.popups.placement",
1106			"legacy popup placement should be reported as a rename, not also a removal",
1107		)
1108		assert.False(t,
1109			change.Type == port.KeyChangeAdded && change.NewKey == "workspace.browsing_contexts.placement",
1110			"canonical browsing_contexts placement should be reported as a rename, not also an addition",
1111		)
1112	}
1113	assert.Equal(t, 1, renameCount)
1114}
1115
1116func TestMigrator_ReadRawConfig(t *testing.T) {
1117	m := NewMigrator()
1118
1119	t.Run("read TOML config", func(t *testing.T) {
1120		tmpDir := t.TempDir()
1121		configFile := filepath.Join(tmpDir, "config.toml")
1122
1123		content := `
1124[section]
1125key = "value"
1126number = 42
1127`
1128		err := os.WriteFile(configFile, []byte(content), 0o644)
1129		require.NoError(t, err)
1130
1131		config, err := m.readRawConfig(configFile)
1132		require.NoError(t, err)
1133
1134		section := config["section"].(map[string]any)
1135		assert.Equal(t, "value", section["key"])
1136		assert.Equal(t, int64(42), section["number"])
1137	})
1138
1139	t.Run("read YAML config", func(t *testing.T) {
1140		tmpDir := t.TempDir()
1141		configFile := filepath.Join(tmpDir, "config.yaml")
1142
1143		content := `
1144section:
1145  key: value
1146  number: 42
1147`
1148		err := os.WriteFile(configFile, []byte(content), 0o644)
1149		require.NoError(t, err)
1150
1151		config, err := m.readRawConfig(configFile)
1152		require.NoError(t, err)
1153
1154		section := config["section"].(map[string]any)
1155		assert.Equal(t, "value", section["key"])
1156		assert.Equal(t, 42, section["number"])
1157	})
1158
1159	t.Run("read JSON config", func(t *testing.T) {
1160		tmpDir := t.TempDir()
1161		configFile := filepath.Join(tmpDir, "config.json")
1162
1163		content := `{
1164  "section": {
1165    "key": "value",
1166    "number": 42
1167  }
1168}`
1169		err := os.WriteFile(configFile, []byte(content), 0o644)
1170		require.NoError(t, err)
1171
1172		config, err := m.readRawConfig(configFile)
1173		require.NoError(t, err)
1174
1175		section := config["section"].(map[string]any)
1176		assert.Equal(t, "value", section["key"])
1177		// JSON numbers are float64
1178		assert.InDelta(t, 42.0, section["number"].(float64), 0.001)
1179	})
1180}

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