v0.31.0 bf0756b

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_DetectMissingDefaultActions(t *testing.T) {
693	m := NewMigrator()
694
695	shortcutActions := make(map[string]any, len(m.defaultConfig.Workspace.Shortcuts.Actions))
696	for actionName, value := range m.defaultConfig.Workspace.Shortcuts.Actions {
697		shortcutActions[actionName] = value
698	}
699	delete(shortcutActions, "toggle-floating-pane")
700
701	paneActions := make(map[string]any, len(m.defaultConfig.Workspace.PaneMode.Actions))
702	for actionName, value := range m.defaultConfig.Workspace.PaneMode.Actions {
703		paneActions[actionName] = value
704	}
705	delete(paneActions, "eject-pane-to-window")
706
707	missing := m.detectMissingDefaultActions(map[string]any{
708		"workspace.shortcuts.actions": shortcutActions,
709		"workspace.pane_mode.actions": paneActions,
710	})
711
712	assert.Equal(t, []string{
713		"workspace.pane_mode.actions.eject-pane-to-window",
714		"workspace.shortcuts.actions.toggle-floating-pane",
715	}, missing)
716}
717
718func TestMigrator_MergeMissingDefaultActions(t *testing.T) {
719	m := NewMigrator()
720
721	rawConfig := map[string]any{
722		"workspace": map[string]any{
723			"shortcuts": map[string]any{
724				"actions": map[string]any{
725					"close_pane": map[string]any{
726						"keys": []string{"ctrl+w", "ctrl+i"},
727						"desc": "Custom close pane",
728					},
729					"next_tab": map[string]any{
730						"keys": []string{"ctrl+tab"},
731						"desc": "Switch to next tab",
732					},
733					"previous_tab": map[string]any{
734						"keys": []string{"ctrl+shift+tab"},
735						"desc": "Switch to previous tab",
736					},
737					"consume_or_expel_left": map[string]any{
738						"keys": []string{"alt+["},
739						"desc": "Consume/expel pane left",
740					},
741					"consume_or_expel_right": map[string]any{
742						"keys": []string{"alt+]"},
743						"desc": "Consume/expel pane right",
744					},
745					"consume_or_expel_up": map[string]any{
746						"keys": []string{"alt+{"},
747						"desc": "Consume/expel pane up",
748					},
749					"consume_or_expel_down": map[string]any{
750						"keys": []string{"alt+}"},
751						"desc": "Consume/expel pane down",
752					},
753				},
754			},
755		},
756	}
757
758	m.mergeMissingDefaultActions(rawConfig)
759
760	actionsAny := m.getNestedValue(rawConfig, "workspace.shortcuts.actions")
761	actions, ok := actionsAny.(map[string]any)
762	require.True(t, ok)
763
764	toggleAny, hasToggle := actions["toggle-floating-pane"]
765	assert.True(t, hasToggle)
766	assert.NotNil(t, toggleAny)
767
768	closePaneAny, hasClosePane := actions["close_pane"]
769	require.True(t, hasClosePane)
770	closePane, ok := closePaneAny.(map[string]any)
771	require.True(t, ok)
772	assert.Equal(t, "Custom close pane", closePane["desc"])
773	assert.NotContains(t, actions, "close-pane")
774	assert.NotContains(t, actions, "next-tab")
775	assert.NotContains(t, actions, "previous-tab")
776	assert.NotContains(t, actions, "consume-or-expel-left")
777	assert.NotContains(t, actions, "consume-or-expel-right")
778	assert.NotContains(t, actions, "consume-or-expel-up")
779	assert.NotContains(t, actions, "consume-or-expel-down")
780}
781
782func TestMigrator_MergeMissingDefaultActions_PaneMode(t *testing.T) {
783	m := NewMigrator()
784
785	rawConfig := map[string]any{
786		"workspace": map[string]any{
787			"pane_mode": map[string]any{
788				"actions": map[string]any{
789					"stack-pane": map[string]any{
790						"keys": []string{"s"},
791						"desc": "Stack pane with sibling",
792					},
793					"move-pane-to-tab": map[string]any{
794						"keys": []string{"m"},
795						"desc": "Custom move pane",
796					},
797				},
798			},
799		},
800	}
801
802	m.mergeMissingDefaultActions(rawConfig)
803
804	actionsAny := m.getNestedValue(rawConfig, "workspace.pane_mode.actions")
805	actions, ok := actionsAny.(map[string]any)
806	require.True(t, ok)
807
808	ejectAny, hasEject := actions["eject-pane-to-window"]
809	assert.True(t, hasEject)
810	assert.NotNil(t, ejectAny)
811
812	moveAny, hasMove := actions["move-pane-to-tab"]
813	require.True(t, hasMove)
814	move, ok := moveAny.(map[string]any)
815	require.True(t, ok)
816	assert.Equal(t, "Custom move pane", move["desc"])
817}
818
819func TestMigrator_MergeMissingDefaultActions_VimMode(t *testing.T) {
820	m := NewMigrator()
821
822	rawConfig := map[string]any{
823		"workspace": map[string]any{
824			"vim_mode": map[string]any{
825				"actions": map[string]any{
826					"heading-next": map[string]any{
827						"keys": []string{"]]"},
828						"desc": "Custom next heading",
829					},
830				},
831			},
832		},
833	}
834
835	m.mergeMissingDefaultActions(rawConfig)
836
837	actionsAny := m.getNestedValue(rawConfig, "workspace.vim_mode.actions")
838	actions, ok := actionsAny.(map[string]any)
839	require.True(t, ok)
840
841	focusAny, hasFocus := actions["focus-input"]
842	require.True(t, hasFocus)
843	focus, ok := focusAny.(ActionBinding)
844	require.True(t, ok)
845	assert.Equal(t, []string{"gi"}, focus.Keys)
846
847	headingAny, hasHeading := actions["heading-next"]
848	require.True(t, hasHeading)
849	heading, ok := headingAny.(map[string]any)
850	require.True(t, ok)
851	assert.Equal(t, "Custom next heading", heading["desc"])
852}
853
854func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
855	m := NewMigrator()
856
857	value := m.defaultValueForKey("workspace.shortcuts.actions.toggle-floating-pane")
858
859	action, ok := value.(ActionBinding)
860	require.True(t, ok)
861	assert.Equal(t, []string{"alt+f"}, action.Keys)
862}
863
864func TestMigrator_DefaultValueForKey_PaneModeAction(t *testing.T) {
865	m := NewMigrator()
866
867	value := m.defaultValueForKey("workspace.pane_mode.actions.eject-pane-to-window")
868
869	action, ok := value.(ActionBinding)
870	require.True(t, ok)
871	assert.Equal(t, []string{"w"}, action.Keys)
872}
873
874func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) {
875	m := NewMigrator()
876
877	value := m.defaultValueForKey("history.max_entries")
878	assert.Equal(t, 10000, value)
879}
880
881func TestMigrator_GetNestedValue(t *testing.T) {
882	m := NewMigrator()
883
884	config := map[string]any{
885		"level1": map[string]any{
886			"level2": map[string]any{
887				"level3": "deep_value",
888			},
889			"simple": "simple_value",
890		},
891		"top": "top_value",
892	}
893
894	tests := []struct {
895		name     string
896		key      string
897		expected any
898	}{
899		{
900			name:     "top level key",
901			key:      "top",
902			expected: "top_value",
903		},
904		{
905			name:     "nested key",
906			key:      "level1.simple",
907			expected: "simple_value",
908		},
909		{
910			name:     "deeply nested key",
911			key:      "level1.level2.level3",
912			expected: "deep_value",
913		},
914		{
915			name:     "non-existent key",
916			key:      "nonexistent",
917			expected: nil,
918		},
919		{
920			name:     "non-existent nested key",
921			key:      "level1.nonexistent",
922			expected: nil,
923		},
924	}
925
926	for _, tt := range tests {
927		t.Run(tt.name, func(t *testing.T) {
928			result := m.getNestedValue(config, tt.key)
929			assert.Equal(t, tt.expected, result)
930		})
931	}
932}
933
934func TestMigrator_SetNestedValue(t *testing.T) {
935	m := NewMigrator()
936
937	t.Run("set top level value", func(t *testing.T) {
938		config := make(map[string]any)
939		m.setNestedValue(config, "top", "value")
940		assert.Equal(t, "value", config["top"])
941	})
942
943	t.Run("set nested value with existing parent", func(t *testing.T) {
944		config := map[string]any{
945			"parent": map[string]any{},
946		}
947		m.setNestedValue(config, "parent.child", "value")
948		parent := config["parent"].(map[string]any)
949		assert.Equal(t, "value", parent["child"])
950	})
951
952	t.Run("set nested value creates intermediate maps", func(t *testing.T) {
953		config := make(map[string]any)
954		m.setNestedValue(config, "a.b.c", "deep_value")
955
956		a := config["a"].(map[string]any)
957		b := a["b"].(map[string]any)
958		assert.Equal(t, "deep_value", b["c"])
959	})
960}
961
962func TestMigrator_DeleteNestedKey(t *testing.T) {
963	m := NewMigrator()
964
965	t.Run("delete top level key", func(t *testing.T) {
966		config := map[string]any{
967			"keep":   "value",
968			"delete": "value",
969		}
970		m.deleteNestedKey(config, "delete")
971		assert.Contains(t, config, "keep")
972		assert.NotContains(t, config, "delete")
973	})
974
975	t.Run("delete nested key", func(t *testing.T) {
976		config := map[string]any{
977			"parent": map[string]any{
978				"keep":   "value",
979				"delete": "value",
980			},
981		}
982		m.deleteNestedKey(config, "parent.delete")
983		parent := config["parent"].(map[string]any)
984		assert.Contains(t, parent, "keep")
985		assert.NotContains(t, parent, "delete")
986	})
987
988	t.Run("delete nested key cleans up empty parents", func(t *testing.T) {
989		config := map[string]any{
990			"keep": "value",
991			"parent": map[string]any{
992				"child": map[string]any{
993					"leaf": "value",
994				},
995			},
996		}
997		m.deleteNestedKey(config, "parent.child.leaf")
998		// Both parent and child should be removed since they're empty
999		assert.NotContains(t, config, "parent")
1000		assert.Contains(t, config, "keep")
1001	})
1002
1003	t.Run("delete non-existent key is no-op", func(t *testing.T) {
1004		config := map[string]any{
1005			"keep": "value",
1006		}
1007		m.deleteNestedKey(config, "nonexistent")
1008		assert.Contains(t, config, "keep")
1009	})
1010}
1011
1012func TestMigrator_Migrate_IsIdempotentForExternalThemePathAndEmptyProfiles(t *testing.T) {
1013	home := t.TempDir()
1014	t.Setenv("HOME", home)
1015	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1016	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1017	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1018	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1019
1020	configFile, err := GetConfigFile()
1021	require.NoError(t, err)
1022	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1023
1024	content, err := encodeConfigToTOML(DefaultConfig())
1025	require.NoError(t, err)
1026	require.Contains(t, content, "path = '")
1027	require.Contains(t, content, "[workspace.floating_pane.profiles]")
1028	content = strings.Replace(content, "\n    [workspace.floating_pane.profiles]\n", "", 1)
1029	require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1030
1031	m := NewMigrator()
1032	firstChanges, err := m.DetectChanges()
1033	require.NoError(t, err)
1034	assertContainsChange(t, firstChanges, port.KeyChangeAdded, "", "workspace.floating_pane.profiles")
1035
1036	appliedKeys, err := m.Migrate()
1037	require.NoError(t, err)
1038	require.NotEmpty(t, appliedKeys, "first migration should apply the missing profiles default")
1039
1040	secondChanges, err := m.DetectChanges()
1041	require.NoError(t, err)
1042	assert.Empty(t, secondChanges, "second migration detection should report no changes after a successful migration")
1043
1044	secondAppliedKeys, err := m.Migrate()
1045	require.NoError(t, err)
1046	assert.Empty(t, secondAppliedKeys, "second migration should be a no-op")
1047}
1048
1049func assertContainsChange(t *testing.T, changes []port.KeyChange, changeType port.KeyChangeType, oldKey, newKey string) {
1050	t.Helper()
1051
1052	for _, change := range changes {
1053		if change.Type == changeType && change.OldKey == oldKey && change.NewKey == newKey {
1054			return
1055		}
1056	}
1057	assert.Failf(t, "missing expected change", "type=%v old=%q new=%q changes=%v", changeType, oldKey, newKey, changes)
1058}
1059
1060// --- Migrate-path popup → browsing_contexts transform tests ---
1061
1062func TestMigrator_GetUserConfigKeysWithValues_TransformsLegacyPopups(t *testing.T) {
1063	m := NewMigrator()
1064
1065	tmpDir := t.TempDir()
1066	configFile := filepath.Join(tmpDir, "config.toml")
1067
1068	content := `
1069[workspace]
1070new_pane_url = "about:blank"
1071
1072[workspace.popups]
1073behavior = "tabbed"
1074placement = "left"
1075oauth_auto_close = false
1076`
1077	err := os.WriteFile(configFile, []byte(content), 0o644)
1078	require.NoError(t, err)
1079
1080	keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1081	require.NoError(t, err)
1082
1083	// Legacy popups keys should NOT appear after transform.
1084	_, hasPopupsBehavior := keysWithValues["workspace.popups.behavior"]
1085	assert.False(t, hasPopupsBehavior, "workspace.popups.behavior should not exist after transform")
1086
1087	// Browsing_contexts flattened keys should carry the legacy values.
1088	assert.Equal(t, "tabbed", keysWithValues["workspace.browsing_contexts.behavior"])
1089	assert.Equal(t, "left", keysWithValues["workspace.browsing_contexts.placement"])
1090	assert.Equal(t, false, keysWithValues["workspace.browsing_contexts.oauth_auto_close"])
1091}
1092
1093func TestMigrator_GetUserConfigKeysWithValues_BrowsingContextsAlreadyPresent(t *testing.T) {
1094	m := NewMigrator()
1095
1096	tmpDir := t.TempDir()
1097	configFile := filepath.Join(tmpDir, "config.toml")
1098
1099	// Both popups and browsing_contexts present — transform should be a no-op for popups.
1100	content := `
1101[workspace]
1102new_pane_url = "about:blank"
1103
1104[workspace.popups]
1105behavior = "tabbed"
1106
1107[workspace.browsing_contexts]
1108behavior = "split"
1109placement = "right"
1110`
1111	err := os.WriteFile(configFile, []byte(content), 0o644)
1112	require.NoError(t, err)
1113
1114	keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1115	require.NoError(t, err)
1116
1117	// Browsing_contexts flattened keys should retain their original values.
1118	assert.Equal(t, "split", keysWithValues["workspace.browsing_contexts.behavior"])
1119	assert.Equal(t, "right", keysWithValues["workspace.browsing_contexts.placement"])
1120
1121	// Popups should still exist (transform skipped when browsing_contexts already present).
1122	_, hasPopups := keysWithValues["workspace.popups.behavior"]
1123	assert.True(t, hasPopups, "popups should still exist when browsing_contexts already present")
1124}
1125
1126func TestMigrator_GetUserConfigKeysWithValues_NoPopupsKey(t *testing.T) {
1127	m := NewMigrator()
1128
1129	tmpDir := t.TempDir()
1130	configFile := filepath.Join(tmpDir, "config.toml")
1131
1132	content := `
1133[workspace]
1134new_pane_url = "about:blank"
1135
1136[workspace.browsing_contexts]
1137behavior = "stacked"
1138`
1139	err := os.WriteFile(configFile, []byte(content), 0o644)
1140	require.NoError(t, err)
1141
1142	keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1143	require.NoError(t, err)
1144
1145	// Should only have browsing_contexts, no popups.
1146	assert.Equal(t, "stacked", keysWithValues["workspace.browsing_contexts.behavior"])
1147
1148	_, hasPopups := keysWithValues["workspace.popups.behavior"]
1149	assert.False(t, hasPopups, "popups should not exist when absent from config")
1150}
1151
1152func TestMigrator_DetectChanges_ReportsLegacyBrowsingContextsRename(t *testing.T) {
1153	home := t.TempDir()
1154	t.Setenv("HOME", home)
1155	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1156	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1157	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1158	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1159
1160	configFile, err := GetConfigFile()
1161	require.NoError(t, err)
1162	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1163
1164	content, err := encodeConfigToTOML(DefaultConfig())
1165	require.NoError(t, err)
1166	content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1167	require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1168
1169	m := NewMigrator()
1170	changes, err := m.DetectChanges()
1171	require.NoError(t, err)
1172
1173	found := false
1174	for _, change := range changes {
1175		if change.Type == port.KeyChangeRenamed &&
1176			change.OldKey == "workspace.popups.behavior" &&
1177			change.NewKey == "workspace.browsing_contexts.behavior" {
1178			found = true
1179			break
1180		}
1181	}
1182	assert.True(t, found, "expected legacy workspace.popups.behavior rename to be reported")
1183}
1184
1185func TestMigrator_Migrate_AppliesLegacyBrowsingContextsRename(t *testing.T) {
1186	home := t.TempDir()
1187	t.Setenv("HOME", home)
1188	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1189	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1190	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1191	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1192
1193	configFile, err := GetConfigFile()
1194	require.NoError(t, err)
1195	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1196
1197	content, err := encodeConfigToTOML(DefaultConfig())
1198	require.NoError(t, err)
1199	content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1200	require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1201
1202	m := NewMigrator()
1203	appliedKeys, err := m.Migrate()
1204	require.NoError(t, err)
1205	assert.Contains(t, appliedKeys, "workspace.popups.behavior -> workspace.browsing_contexts.behavior")
1206
1207	migrated, err := os.ReadFile(configFile)
1208	require.NoError(t, err)
1209	assert.Contains(t, string(migrated), "[workspace.browsing_contexts]")
1210	assert.NotContains(t, string(migrated), "[workspace.popups]")
1211}
1212
1213func TestMigrator_Migrate_RenamesLegacyPageModeToVimMode(t *testing.T) {
1214	home := t.TempDir()
1215	t.Setenv("HOME", home)
1216	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1217	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1218	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1219	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1220
1221	configFile, err := GetConfigFile()
1222	require.NoError(t, err)
1223	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1224	require.NoError(t, os.WriteFile(configFile, []byte(`
1225[workspace.page_mode]
1226activation_shortcut = "ctrl+alt+y"
1227timeout_ms = 250
1228
1229[workspace.page_mode.actions.page-scroll-down]
1230keys = ["n"]
1231desc = "Scroll down"
1232
1233[workspace.page_mode.actions.page_scroll_up]
1234keys = ["u"]
1235desc = "Scroll up"
1236
1237[workspace.page_mode.actions.page_scroll_down_fast]
1238keys = ["d"]
1239desc = "Scroll down fast"
1240`), 0o644))
1241
1242	migrator := NewMigrator()
1243	applied, err := migrator.Migrate()
1244	require.NoError(t, err)
1245	assert.Contains(t, applied, "workspace.page_mode.activation_shortcut -> workspace.vim_mode.activation_shortcut")
1246	assert.Contains(t, applied, "workspace.page_mode.timeout_ms -> workspace.vim_mode.timeout_ms")
1247	assert.Contains(t, applied, "workspace.page_mode.actions -> workspace.vim_mode.actions")
1248
1249	migrated, err := os.ReadFile(configFile)
1250	require.NoError(t, err)
1251	assert.Contains(t, string(migrated), "[workspace.vim_mode]")
1252	assert.Contains(t, string(migrated), "activation_shortcut = 'ctrl+alt+y'")
1253	assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down]")
1254	assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-up]")
1255	assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down-fast]")
1256	assert.Contains(t, string(migrated), "keys = ['u']")
1257	assert.NotContains(t, string(migrated), "page_mode")
1258	assert.NotContains(t, string(migrated), "page-scroll-down")
1259}
1260
1261func TestMigrator_DetectChanges_CanonicalizesLegacyVimActionNames(t *testing.T) {
1262	home := t.TempDir()
1263	t.Setenv("HOME", home)
1264	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1265	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1266	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1267	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1268
1269	configFile, err := GetConfigFile()
1270	require.NoError(t, err)
1271	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1272	require.NoError(t, os.WriteFile(configFile, []byte(`
1273[workspace.page_mode.actions.page_scroll_down_fast]
1274keys = ["d"]
1275desc = "Scroll down fast"
1276`), 0o644))
1277
1278	changes, err := NewMigrator().DetectChanges()
1279	require.NoError(t, err)
1280
1281	foundRename := false
1282	for _, change := range changes {
1283		if change.Type == port.KeyChangeRenamed &&
1284			change.OldKey == "workspace.page_mode.actions" &&
1285			change.NewKey == "workspace.vim_mode.actions" {
1286			foundRename = true
1287		}
1288	}
1289	assert.True(t, foundRename, "DetectChanges must report the canonical Vim Mode actions key")
1290	assert.Equal(t, "vim-scroll-down-fast", legacyVimActionName("page_scroll_down_fast"))
1291}
1292
1293func TestMigrator_Migrate_MergesLegacyPageModeActionsIntoExistingVimMode(t *testing.T) {
1294	home := t.TempDir()
1295	t.Setenv("HOME", home)
1296	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1297	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1298	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1299	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1300
1301	configFile, err := GetConfigFile()
1302	require.NoError(t, err)
1303	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1304	require.NoError(t, os.WriteFile(configFile, []byte(`
1305[workspace.page_mode.actions.page-scroll-down]
1306keys = ["n"]
1307
1308[workspace.page_mode.actions.page-scroll-up]
1309keys = ["u"]
1310
1311[workspace.vim_mode.actions.vim-scroll-down]
1312keys = ["j"]
1313`), 0o644))
1314
1315	_, err = NewMigrator().Migrate()
1316	require.NoError(t, err)
1317
1318	migrated, err := os.ReadFile(configFile)
1319	require.NoError(t, err)
1320	assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down]\n        keys = ['j']")
1321	assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-up]\n        keys = ['u']")
1322	assert.NotContains(t, string(migrated), "page_mode")
1323}
1324
1325func TestMigrator_DetectChanges_MixedBrowsingContextsDoesNotDoubleReportLegacyRename(t *testing.T) {
1326	home := t.TempDir()
1327	t.Setenv("HOME", home)
1328	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1329	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1330	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1331	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1332
1333	configFile, err := GetConfigFile()
1334	require.NoError(t, err)
1335	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1336	require.NoError(t, os.WriteFile(configFile, []byte(`
1337[workspace.popups]
1338placement = "left"
1339
1340[workspace.browsing_contexts]
1341behavior = "tabbed"
1342`), 0o644))
1343
1344	m := NewMigrator()
1345	changes, err := m.DetectChanges()
1346	require.NoError(t, err)
1347
1348	renameCount := 0
1349	for _, change := range changes {
1350		if change.Type == port.KeyChangeRenamed &&
1351			change.OldKey == "workspace.popups.placement" &&
1352			change.NewKey == "workspace.browsing_contexts.placement" {
1353			renameCount++
1354		}
1355		assert.False(t,
1356			change.Type == port.KeyChangeRemoved && change.OldKey == "workspace.popups.placement",
1357			"legacy popup placement should be reported as a rename, not also a removal",
1358		)
1359		assert.False(t,
1360			change.Type == port.KeyChangeAdded && change.NewKey == "workspace.browsing_contexts.placement",
1361			"canonical browsing_contexts placement should be reported as a rename, not also an addition",
1362		)
1363	}
1364	assert.Equal(t, 1, renameCount)
1365}
1366
1367func TestMigrator_ReadRawConfig(t *testing.T) {
1368	m := NewMigrator()
1369
1370	t.Run("read TOML config", func(t *testing.T) {
1371		tmpDir := t.TempDir()
1372		configFile := filepath.Join(tmpDir, "config.toml")
1373
1374		content := `
1375[section]
1376key = "value"
1377number = 42
1378`
1379		err := os.WriteFile(configFile, []byte(content), 0o644)
1380		require.NoError(t, err)
1381
1382		config, err := m.readRawConfig(configFile)
1383		require.NoError(t, err)
1384
1385		section := config["section"].(map[string]any)
1386		assert.Equal(t, "value", section["key"])
1387		assert.Equal(t, int64(42), section["number"])
1388	})
1389
1390	t.Run("read YAML config", func(t *testing.T) {
1391		tmpDir := t.TempDir()
1392		configFile := filepath.Join(tmpDir, "config.yaml")
1393
1394		content := `
1395section:
1396  key: value
1397  number: 42
1398`
1399		err := os.WriteFile(configFile, []byte(content), 0o644)
1400		require.NoError(t, err)
1401
1402		config, err := m.readRawConfig(configFile)
1403		require.NoError(t, err)
1404
1405		section := config["section"].(map[string]any)
1406		assert.Equal(t, "value", section["key"])
1407		assert.Equal(t, 42, section["number"])
1408	})
1409
1410	t.Run("read JSON config", func(t *testing.T) {
1411		tmpDir := t.TempDir()
1412		configFile := filepath.Join(tmpDir, "config.json")
1413
1414		content := `{
1415  "section": {
1416    "key": "value",
1417    "number": 42
1418  }
1419}`
1420		err := os.WriteFile(configFile, []byte(content), 0o644)
1421		require.NoError(t, err)
1422
1423		config, err := m.readRawConfig(configFile)
1424		require.NoError(t, err)
1425
1426		section := config["section"].(map[string]any)
1427		assert.Equal(t, "value", section["key"])
1428		// JSON numbers are float64
1429		assert.InDelta(t, 42.0, section["number"].(float64), 0.001)
1430	})
1431}
1432
1433func TestMigrator_Migrate_RenamesCustomFavoritesSidebarShortcut(t *testing.T) {
1434	home := t.TempDir()
1435	t.Setenv("HOME", home)
1436	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1437	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1438	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1439	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1440
1441	configFile, err := GetConfigFile()
1442	require.NoError(t, err)
1443	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1444	require.NoError(t, os.WriteFile(configFile, []byte(`
1445[workspace.shortcuts.actions.toggle-favorites-systemview]
1446keys = []
1447desc = "Custom favorites shortcut"
1448`), 0o644))
1449
1450	migrator := NewMigrator()
1451	result, err := migrator.CheckMigration()
1452	require.NoError(t, err)
1453	require.NotNil(t, result)
1454	assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-sidebar")
1455
1456	changes, err := migrator.DetectChanges()
1457	require.NoError(t, err)
1458	assertContainsChange(t, changes, port.KeyChangeRenamed,
1459		"workspace.shortcuts.actions.toggle-favorites-systemview",
1460		"workspace.shortcuts.actions.toggle-favorites-sidebar")
1461
1462	applied, err := migrator.Migrate()
1463	require.NoError(t, err)
1464	assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview -> workspace.shortcuts.actions.toggle-favorites-sidebar")
1465
1466	mgr, err := NewManager()
1467	require.NoError(t, err)
1468	require.NoError(t, mgr.Load())
1469	_, ok := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
1470	assert.False(t, ok)
1471	favorites := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
1472	assert.Empty(t, favorites.Keys)
1473	assert.Equal(t, "Custom favorites shortcut", favorites.Desc)
1474}
1475
1476func TestMigrator_Migrate_UpgradesLegacyFavoritesSidebarDefault(t *testing.T) {
1477	home := t.TempDir()
1478	t.Setenv("HOME", home)
1479	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1480	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1481	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1482	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1483
1484	configFile, err := GetConfigFile()
1485	require.NoError(t, err)
1486	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1487	require.NoError(t, os.WriteFile(configFile, []byte(`
1488[workspace.shortcuts.actions.toggle-favorites-systemview]
1489keys = []
1490desc = "Toggle Favorites in right split"
1491`), 0o644))
1492
1493	migrator := NewMigrator()
1494	result, err := migrator.CheckMigration()
1495	require.NoError(t, err)
1496	require.NotNil(t, result)
1497	assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-sidebar")
1498	assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-current-page-favorite")
1499
1500	changes, err := migrator.DetectChanges()
1501	require.NoError(t, err)
1502	assertContainsChange(t, changes, port.KeyChangeRenamed,
1503		"workspace.shortcuts.actions.toggle-favorites-systemview",
1504		"workspace.shortcuts.actions.toggle-favorites-sidebar")
1505	assertContainsChange(t, changes, port.KeyChangeAdded, "", "workspace.shortcuts.actions.toggle-current-page-favorite")
1506
1507	applied, err := migrator.Migrate()
1508	require.NoError(t, err)
1509	assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview -> workspace.shortcuts.actions.toggle-favorites-sidebar")
1510	assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-current-page-favorite")
1511
1512	raw, err := migrator.readRawConfig(configFile)
1513	require.NoError(t, err)
1514	workspace, ok := raw["workspace"].(map[string]any)
1515	require.True(t, ok)
1516	shortcuts, ok := workspace["shortcuts"].(map[string]any)
1517	require.True(t, ok)
1518	actions, ok := shortcuts["actions"].(map[string]any)
1519	require.True(t, ok)
1520
1521	expectedActions := DefaultConfig().Workspace.Shortcuts.Actions
1522	_, ok = actions["toggle-favorites-systemview"]
1523	assert.False(t, ok)
1524	favorites, ok := actions["toggle-favorites-sidebar"].(map[string]any)
1525	require.True(t, ok)
1526	assert.Equal(t, []any{"ctrl+b"}, favorites["keys"])
1527	assert.Equal(t, expectedActions["toggle-favorites-sidebar"].Desc, favorites["desc"])
1528	currentPage, ok := actions["toggle-current-page-favorite"].(map[string]any)
1529	require.True(t, ok)
1530	assert.Equal(t, []any{"ctrl+d"}, currentPage["keys"])
1531	assert.Equal(t, expectedActions["toggle-current-page-favorite"].Desc, currentPage["desc"])
1532
1533	mgr, err := NewManager()
1534	require.NoError(t, err)
1535	require.NoError(t, mgr.Load())
1536	cfg := mgr.Get()
1537	require.NotNil(t, cfg)
1538	_, ok = cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
1539	assert.False(t, ok)
1540	assert.Equal(t, []string{"ctrl+b"}, cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"].Keys)
1541	assert.Equal(t, []string{"ctrl+d"}, cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"].Keys)
1542}

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