v0.33.2 57f21d6

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	assert.True(t, keySet["omnibox.style"], "should include omnibox.style for config migrate")
43}
44
45func TestMigrator_GetUserConfigKeys(t *testing.T) {
46	m := NewMigrator()
47
48	// Create a temp config file
49	tmpDir := t.TempDir()
50	configFile := filepath.Join(tmpDir, "config.toml")
51
52	content := `
53[history]
54max_entries = 5000
55retention_period_days = 180
56
57[logging]
58level = "debug"
59
60[appearance]
61sans_font = "Arial"
62`
63	err := os.WriteFile(configFile, []byte(content), 0o644)
64	require.NoError(t, err)
65
66	keys, err := m.getUserConfigKeys(configFile)
67	require.NoError(t, err)
68
69	// Should have the keys we defined
70	assert.True(t, keys["history.max_entries"])
71	assert.True(t, keys["history.retention_period_days"])
72	assert.True(t, keys["logging.level"])
73	assert.True(t, keys["appearance.sans_font"])
74
75	// Should not have keys we didn't define
76	assert.False(t, keys["history.cleanup_interval_days"])
77	assert.False(t, keys["logging.format"])
78}
79
80func TestMigrator_FlattenMap(t *testing.T) {
81	m := NewMigrator()
82
83	tests := []struct {
84		name     string
85		input    map[string]any
86		prefix   string
87		expected []string
88	}{
89		{
90			name: "simple flat map",
91			input: map[string]any{
92				"key1": "value1",
93				"key2": 123,
94			},
95			prefix:   "",
96			expected: []string{"key1", "key2"},
97		},
98		{
99			name: "nested map",
100			input: map[string]any{
101				"parent": map[string]any{
102					"child1": "value1",
103					"child2": "value2",
104				},
105			},
106			prefix:   "",
107			expected: []string{"parent.child1", "parent.child2"},
108		},
109		{
110			name: "with prefix",
111			input: map[string]any{
112				"key": "value",
113			},
114			prefix:   "section",
115			expected: []string{"section.key"},
116		},
117		{
118			name: "search_shortcuts treated as single key",
119			input: map[string]any{
120				"search_shortcuts": map[string]any{
121					"g":   "https://google.com/?q=%s",
122					"ddg": "https://duckduckgo.com/?q=%s",
123				},
124			},
125			prefix:   "",
126			expected: []string{"search_shortcuts"}, // Should be treated as user data
127		},
128		{
129			name: "actions map treated as single key",
130			input: map[string]any{
131				"pane_mode": map[string]any{
132					"actions": map[string]any{
133						"split-right": []string{"r"},
134					},
135				},
136			},
137			prefix:   "",
138			expected: []string{"pane_mode.actions"}, // Actions treated as user data
139		},
140	}
141
142	for _, tt := range tests {
143		t.Run(tt.name, func(t *testing.T) {
144			keys := make(map[string]bool)
145			m.flattenMap(tt.input, tt.prefix, keys)
146
147			for _, expectedKey := range tt.expected {
148				assert.True(t, keys[expectedKey], "should contain key: %s", expectedKey)
149			}
150		})
151	}
152}
153
154func TestMigrator_IsUserDataSection(t *testing.T) {
155	m := NewMigrator()
156
157	tests := []struct {
158		name     string
159		keyPath  string
160		expected bool
161	}{
162		{
163			name:     "search_shortcuts is user data",
164			keyPath:  "search_shortcuts",
165			expected: true,
166		},
167		{
168			name:     "pane_mode.actions is user data",
169			keyPath:  "pane_mode.actions",
170			expected: true,
171		},
172		{
173			name:     "tab_mode.actions is user data",
174			keyPath:  "tab_mode.actions",
175			expected: true,
176		},
177		{
178			name:     "history is not user data",
179			keyPath:  "history",
180			expected: false,
181		},
182		{
183			name:     "history.max_entries is not user data",
184			keyPath:  "history.max_entries",
185			expected: false,
186		},
187		{
188			name:     "appearance.sans_font is not user data",
189			keyPath:  "appearance.sans_font",
190			expected: false,
191		},
192	}
193
194	for _, tt := range tests {
195		t.Run(tt.name, func(t *testing.T) {
196			result := m.isUserDataSection(tt.keyPath)
197			assert.Equal(t, tt.expected, result)
198		})
199	}
200}
201
202func TestMigrator_FindMissingKeys(t *testing.T) {
203	m := NewMigrator()
204
205	tests := []struct {
206		name        string
207		defaultKeys []string
208		userKeys    map[string]bool
209		expected    []string
210	}{
211		{
212			name:        "all keys present",
213			defaultKeys: []string{"key1", "key2"},
214			userKeys:    map[string]bool{"key1": true, "key2": true},
215			expected:    []string{},
216		},
217		{
218			name:        "some keys missing",
219			defaultKeys: []string{"key1", "key2", "key3"},
220			userKeys:    map[string]bool{"key1": true},
221			expected:    []string{"key2", "key3"},
222		},
223		{
224			name:        "all keys missing",
225			defaultKeys: []string{"key1", "key2"},
226			userKeys:    map[string]bool{},
227			expected:    []string{"key1", "key2"},
228		},
229		{
230			name:        "parent key covers children",
231			defaultKeys: []string{"parent.child1", "parent.child2"},
232			userKeys:    map[string]bool{"parent": true},
233			expected:    []string{}, // Parent covers children
234		},
235		{
236			name:        "child keys cover parent (struct defaults like palettes)",
237			defaultKeys: []string{"appearance.dark_palette"},
238			userKeys:    map[string]bool{"appearance.dark_palette.background": true, "appearance.dark_palette.accent": true},
239			expected:    []string{}, // Child keys mean parent is defined
240		},
241	}
242
243	for _, tt := range tests {
244		t.Run(tt.name, func(t *testing.T) {
245			result := m.findMissingKeys(tt.defaultKeys, tt.userKeys)
246			assert.ElementsMatch(t, tt.expected, result)
247		})
248	}
249}
250
251func TestMigrator_GetKeyInfo(t *testing.T) {
252	m := NewMigrator()
253
254	tests := []struct {
255		name         string
256		key          string
257		expectedType string
258	}{
259		{
260			name:         "bool key",
261			key:          "debug.enable_devtools",
262			expectedType: "bool",
263		},
264		{
265			name:         "int key",
266			key:          "history.max_entries",
267			expectedType: "int",
268		},
269		{
270			name:         "string key",
271			key:          "logging.level",
272			expectedType: "string",
273		},
274		{
275			name:         "float key",
276			key:          "default_webpage_zoom",
277			expectedType: "float",
278		},
279	}
280
281	for _, tt := range tests {
282		t.Run(tt.name, func(t *testing.T) {
283			info := m.GetKeyInfo(tt.key)
284			assert.Equal(t, tt.key, info.Key)
285			assert.Equal(t, tt.expectedType, info.Type)
286			assert.NotEmpty(t, info.DefaultValue)
287		})
288	}
289}
290
291func TestMigrator_GetTypeName(t *testing.T) {
292	m := NewMigrator()
293
294	tests := []struct {
295		name     string
296		value    any
297		expected string
298	}{
299		{name: "bool", value: true, expected: "bool"},
300		{name: "int", value: 42, expected: "int"},
301		{name: "int64", value: int64(42), expected: "int"},
302		{name: "float64", value: 3.14, expected: "float"},
303		{name: "string", value: "hello", expected: "string"},
304		{name: "slice", value: []any{"a", "b"}, expected: "list"},
305		{name: "map", value: map[string]any{"a": 1}, expected: "map"},
306		{name: "nil", value: nil, expected: "unknown"},
307	}
308
309	for _, tt := range tests {
310		t.Run(tt.name, func(t *testing.T) {
311			result := m.getTypeName(tt.value)
312			assert.Equal(t, tt.expected, result)
313		})
314	}
315}
316
317func TestMigrator_FormatValue(t *testing.T) {
318	m := NewMigrator()
319
320	tests := []struct {
321		name     string
322		value    any
323		expected string
324	}{
325		{name: "true", value: true, expected: "true"},
326		{name: "false", value: false, expected: "false"},
327		{name: "int", value: 42, expected: "42"},
328		{name: "float", value: 3.14, expected: "3.14"},
329		{name: "string", value: "hello", expected: `"hello"`},
330		{name: "empty string", value: "", expected: `""`},
331		{name: "empty slice", value: []any{}, expected: "[]"},
332		{name: "slice with items", value: []any{"a", "b", "c"}, expected: "[3 items]"},
333		{name: "empty map", value: map[string]any{}, expected: "{}"},
334		{name: "map with entries", value: map[string]any{"a": 1, "b": 2}, expected: "{2 entries}"},
335		{name: "nil", value: nil, expected: "null"},
336	}
337
338	for _, tt := range tests {
339		t.Run(tt.name, func(t *testing.T) {
340			result := m.formatValue(tt.value)
341			assert.Equal(t, tt.expected, result)
342		})
343	}
344}
345
346func TestKeysAreSimilar(t *testing.T) {
347	tests := []struct {
348		name     string
349		oldKey   string
350		newKey   string
351		expected bool
352	}{
353		{
354			name:     "identical keys",
355			oldKey:   "section.pane_mode_color",
356			newKey:   "section.pane_mode_color",
357			expected: true,
358		},
359		{
360			name:     "substring match - old contains new",
361			oldKey:   "section.pane_mode_border_color",
362			newKey:   "section.pane_mode_color",
363			expected: true,
364		},
365		{
366			name:     "substring match - new contains old",
367			oldKey:   "section.mode_color",
368			newKey:   "section.pane_mode_color",
369			expected: true,
370		},
371		{
372			name:     "token matching - border removed",
373			oldKey:   "section.tab_mode_border_color",
374			newKey:   "section.tab_mode_color",
375			expected: true,
376		},
377		{
378			name:     "different parent paths",
379			oldKey:   "section_a.pane_mode_color",
380			newKey:   "section_b.pane_mode_color",
381			expected: false,
382		},
383		{
384			name:     "different depth",
385			oldKey:   "pane_mode_color",
386			newKey:   "section.pane_mode_color",
387			expected: false,
388		},
389		{
390			name:     "completely different keys",
391			oldKey:   "section.foo_bar",
392			newKey:   "section.baz_qux",
393			expected: false,
394		},
395		{
396			name:     "top level keys rejected",
397			oldKey:   "foo",
398			newKey:   "bar",
399			expected: false,
400		},
401		{
402			name:     "no common tokens",
403			oldKey:   "section.aaa_bbb",
404			newKey:   "section.ccc_ddd",
405			expected: false,
406		},
407		{
408			name:     "single token difference allowed",
409			oldKey:   "section.pane_mode_border_width",
410			newKey:   "section.mode_border_width",
411			expected: true,
412		},
413		{
414			name:     "duplicate tokens in old key - no double counting",
415			oldKey:   "section.mode_mode_mode",
416			newKey:   "section.mode_other_another",
417			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
418		},
419		{
420			name:     "enough common tokens with duplicates",
421			oldKey:   "section.pane_mode_border",
422			newKey:   "section.pane_mode_color",
423			expected: true, // oldTokens=[pane,mode,border], newTokens=[pane,mode,color], matches=2, minTokens=3, 2>=2 && 2>0 = true
424		},
425	}
426
427	for _, tt := range tests {
428		t.Run(tt.name, func(t *testing.T) {
429			result := keysAreSimilar(tt.oldKey, tt.newKey)
430			assert.Equal(t, tt.expected, result, "keysAreSimilar(%q, %q)", tt.oldKey, tt.newKey)
431		})
432	}
433}
434
435func TestMigrator_TypesAreCompatible(t *testing.T) {
436	m := NewMigrator()
437
438	tests := []struct {
439		name     string
440		oldKey   string
441		newKey   string
442		expected bool
443	}{
444		{
445			name:     "color to color - compatible (using real key)",
446			oldKey:   "workspace.styling.pane_mode_border_color",
447			newKey:   "workspace.styling.pane_mode_color",
448			expected: true,
449		},
450		{
451			name:     "width to width - compatible (using real key)",
452			oldKey:   "workspace.styling.pane_mode_border_width",
453			newKey:   "workspace.styling.mode_border_width",
454			expected: true,
455		},
456		{
457			name:     "width suffix to color type - incompatible",
458			oldKey:   "workspace.styling.some_width",
459			newKey:   "workspace.styling.pane_mode_color", // real key with string type
460			expected: false,
461		},
462		{
463			name:     "color suffix to int type - incompatible",
464			oldKey:   "workspace.styling.some_color",
465			newKey:   "workspace.styling.mode_border_width", // real key with int type
466			expected: false,
467		},
468		{
469			name:     "generic key to string type - compatible",
470			oldKey:   "workspace.styling.some_setting",
471			newKey:   "workspace.styling.pane_mode_color", // real key with string type
472			expected: true,
473		},
474		{
475			name:     "non-existent new key - incompatible",
476			oldKey:   "section.old_key",
477			newKey:   "section.nonexistent_key_xyz",
478			expected: false,
479		},
480	}
481
482	for _, tt := range tests {
483		t.Run(tt.name, func(t *testing.T) {
484			result := m.typesAreCompatible(tt.oldKey, tt.newKey)
485			assert.Equal(t, tt.expected, result, "typesAreCompatible(%q, %q)", tt.oldKey, tt.newKey)
486		})
487	}
488}
489
490func TestMigrator_FindDeprecatedKeys(t *testing.T) {
491	m := NewMigrator()
492
493	tests := []struct {
494		name        string
495		userKeys    map[string]any
496		defaultKeys map[string]bool
497		expected    []string
498	}{
499		{
500			name: "no deprecated keys",
501			userKeys: map[string]any{
502				"history.max_entries": 1000,
503				"logging.level":       "info",
504			},
505			defaultKeys: map[string]bool{
506				"history.max_entries": true,
507				"logging.level":       true,
508			},
509			expected: []string{},
510		},
511		{
512			name: "one deprecated key",
513			userKeys: map[string]any{
514				"history.max_entries": 1000,
515				"old.deprecated_key":  "value",
516			},
517			defaultKeys: map[string]bool{
518				"history.max_entries": true,
519			},
520			expected: []string{"old.deprecated_key"},
521		},
522		{
523			name: "multiple deprecated keys",
524			userKeys: map[string]any{
525				"old.key1": "a",
526				"old.key2": "b",
527				"old.key3": "c",
528			},
529			defaultKeys: map[string]bool{
530				"new.key1": true,
531			},
532			expected: []string{"old.key1", "old.key2", "old.key3"},
533		},
534		{
535			name: "parent key exists in defaults",
536			userKeys: map[string]any{
537				"parent.child": "value",
538			},
539			defaultKeys: map[string]bool{
540				"parent.child":       true,
541				"parent.other_child": true,
542			},
543			expected: []string{},
544		},
545		{
546			name: "database.path ignored as deprecated",
547			userKeys: map[string]any{
548				"database.path": "",
549			},
550			defaultKeys: map[string]bool{
551				"history.max_entries": true,
552			},
553			expected: []string{},
554		},
555	}
556
557	for _, tt := range tests {
558		t.Run(tt.name, func(t *testing.T) {
559			result := m.findDeprecatedKeys(tt.userKeys, tt.defaultKeys)
560			assert.ElementsMatch(t, tt.expected, result)
561		})
562	}
563}
564
565func TestMigrator_MatchRenamedKeys(t *testing.T) {
566	m := NewMigrator()
567
568	tests := []struct {
569		name                 string
570		deprecated           []string
571		missing              []string
572		expectedRenames      map[string]string
573		expectedUnmatchedOld []string
574		expectedUnmatchedNew []string
575	}{
576		{
577			name:                 "no keys to match",
578			deprecated:           []string{},
579			missing:              []string{},
580			expectedRenames:      map[string]string{},
581			expectedUnmatchedOld: nil,
582			expectedUnmatchedNew: nil,
583		},
584		{
585			name:       "simple rename match (using real keys)",
586			deprecated: []string{"workspace.styling.pane_mode_border_color"},
587			missing:    []string{"workspace.styling.pane_mode_color"},
588			expectedRenames: map[string]string{
589				"workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
590			},
591			expectedUnmatchedOld: nil,
592			expectedUnmatchedNew: nil,
593		},
594		{
595			name:                 "no match possible - different types (width vs color)",
596			deprecated:           []string{"workspace.styling.pane_mode_border_width"},
597			missing:              []string{"workspace.styling.pane_mode_color"},
598			expectedRenames:      map[string]string{},
599			expectedUnmatchedOld: []string{"workspace.styling.pane_mode_border_width"},
600			expectedUnmatchedNew: []string{"workspace.styling.pane_mode_color"},
601		},
602		{
603			name:                 "no match possible - different parents",
604			deprecated:           []string{"old_section.some_key"},
605			missing:              []string{"new_section.some_key"},
606			expectedRenames:      map[string]string{},
607			expectedUnmatchedOld: []string{"old_section.some_key"},
608			expectedUnmatchedNew: []string{"new_section.some_key"},
609		},
610		{
611			name:       "multiple renames (using real keys)",
612			deprecated: []string{"workspace.styling.pane_mode_border_color", "workspace.styling.tab_mode_border_color"},
613			missing:    []string{"workspace.styling.pane_mode_color", "workspace.styling.tab_mode_color"},
614			expectedRenames: map[string]string{
615				"workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
616				"workspace.styling.tab_mode_border_color":  "workspace.styling.tab_mode_color",
617			},
618			expectedUnmatchedOld: nil,
619			expectedUnmatchedNew: nil,
620		},
621	}
622
623	for _, tt := range tests {
624		t.Run(tt.name, func(t *testing.T) {
625			renames, unmatchedOld, unmatchedNew := m.matchRenamedKeys(tt.deprecated, tt.missing)
626			assert.Equal(t, tt.expectedRenames, renames, "renames mismatch")
627			assert.ElementsMatch(t, tt.expectedUnmatchedOld, unmatchedOld, "unmatched old mismatch")
628			assert.ElementsMatch(t, tt.expectedUnmatchedNew, unmatchedNew, "unmatched new mismatch")
629		})
630	}
631}
632
633func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) {
634	// This test would need to temporarily modify the config path
635	// which is difficult without refactoring. Skip for now.
636	t.Skip("requires config path mocking")
637}
638
639func TestMigrator_Migrate_NoConfigFile(t *testing.T) {
640	// This test would need to temporarily modify the config path
641	// which is difficult without refactoring. Skip for now.
642	t.Skip("requires config path mocking")
643}
644
645func TestMigrator_GetConfigType(t *testing.T) {
646	m := NewMigrator()
647
648	tests := []struct {
649		name       string
650		configFile string
651		expected   string
652	}{
653		{
654			name:       "toml file",
655			configFile: "/path/to/config.toml",
656			expected:   "toml",
657		},
658		{
659			name:       "yaml file",
660			configFile: "/path/to/config.yaml",
661			expected:   "yaml",
662		},
663		{
664			name:       "yml file",
665			configFile: "/path/to/config.yml",
666			expected:   "yaml",
667		},
668		{
669			name:       "json file",
670			configFile: "/path/to/config.json",
671			expected:   "json",
672		},
673		{
674			name:       "unknown extension defaults to toml",
675			configFile: "/path/to/config.unknown",
676			expected:   "toml",
677		},
678		{
679			name:       "no extension defaults to toml",
680			configFile: "/path/to/config",
681			expected:   "toml",
682		},
683	}
684
685	for _, tt := range tests {
686		t.Run(tt.name, func(t *testing.T) {
687			result := m.getConfigType(tt.configFile)
688			assert.Equal(t, tt.expected, result)
689		})
690	}
691}
692
693func TestMigrator_DetectMissingDefaultActions(t *testing.T) {
694	m := NewMigrator()
695
696	shortcutActions := make(map[string]any, len(m.defaultConfig.Workspace.Shortcuts.Actions))
697	for actionName, value := range m.defaultConfig.Workspace.Shortcuts.Actions {
698		shortcutActions[actionName] = value
699	}
700	delete(shortcutActions, "toggle-floating-pane")
701
702	paneActions := make(map[string]any, len(m.defaultConfig.Workspace.PaneMode.Actions))
703	for actionName, value := range m.defaultConfig.Workspace.PaneMode.Actions {
704		paneActions[actionName] = value
705	}
706	delete(paneActions, "eject-pane-to-window")
707
708	missing := m.detectMissingDefaultActions(map[string]any{
709		"workspace.shortcuts.actions": shortcutActions,
710		"workspace.pane_mode.actions": paneActions,
711	})
712
713	assert.Equal(t, []string{
714		"workspace.pane_mode.actions.eject-pane-to-window",
715		"workspace.shortcuts.actions.toggle-floating-pane",
716	}, missing)
717}
718
719func TestMigrator_MergeMissingDefaultActions(t *testing.T) {
720	m := NewMigrator()
721
722	rawConfig := map[string]any{
723		"workspace": map[string]any{
724			"shortcuts": map[string]any{
725				"actions": map[string]any{
726					"close_pane": map[string]any{
727						"keys": []string{"ctrl+w", "ctrl+i"},
728						"desc": "Custom close pane",
729					},
730					"next_tab": map[string]any{
731						"keys": []string{"ctrl+tab"},
732						"desc": "Switch to next tab",
733					},
734					"previous_tab": map[string]any{
735						"keys": []string{"ctrl+shift+tab"},
736						"desc": "Switch to previous tab",
737					},
738					"consume_or_expel_left": map[string]any{
739						"keys": []string{"alt+["},
740						"desc": "Consume/expel pane left",
741					},
742					"consume_or_expel_right": map[string]any{
743						"keys": []string{"alt+]"},
744						"desc": "Consume/expel pane right",
745					},
746					"consume_or_expel_up": map[string]any{
747						"keys": []string{"alt+{"},
748						"desc": "Consume/expel pane up",
749					},
750					"consume_or_expel_down": map[string]any{
751						"keys": []string{"alt+}"},
752						"desc": "Consume/expel pane down",
753					},
754				},
755			},
756		},
757	}
758
759	m.mergeMissingDefaultActions(rawConfig)
760
761	actionsAny := m.getNestedValue(rawConfig, "workspace.shortcuts.actions")
762	actions, ok := actionsAny.(map[string]any)
763	require.True(t, ok)
764
765	toggleAny, hasToggle := actions["toggle-floating-pane"]
766	assert.True(t, hasToggle)
767	assert.NotNil(t, toggleAny)
768
769	closePaneAny, hasClosePane := actions["close_pane"]
770	require.True(t, hasClosePane)
771	closePane, ok := closePaneAny.(map[string]any)
772	require.True(t, ok)
773	assert.Equal(t, "Custom close pane", closePane["desc"])
774	assert.NotContains(t, actions, "close-pane")
775	assert.NotContains(t, actions, "next-tab")
776	assert.NotContains(t, actions, "previous-tab")
777	assert.NotContains(t, actions, "consume-or-expel-left")
778	assert.NotContains(t, actions, "consume-or-expel-right")
779	assert.NotContains(t, actions, "consume-or-expel-up")
780	assert.NotContains(t, actions, "consume-or-expel-down")
781}
782
783func TestMigrator_MergeMissingDefaultActions_PaneMode(t *testing.T) {
784	m := NewMigrator()
785
786	rawConfig := map[string]any{
787		"workspace": map[string]any{
788			"pane_mode": map[string]any{
789				"actions": map[string]any{
790					"stack-pane": map[string]any{
791						"keys": []string{"s"},
792						"desc": "Stack pane with sibling",
793					},
794					"move-pane-to-tab": map[string]any{
795						"keys": []string{"m"},
796						"desc": "Custom move pane",
797					},
798				},
799			},
800		},
801	}
802
803	m.mergeMissingDefaultActions(rawConfig)
804
805	actionsAny := m.getNestedValue(rawConfig, "workspace.pane_mode.actions")
806	actions, ok := actionsAny.(map[string]any)
807	require.True(t, ok)
808
809	ejectAny, hasEject := actions["eject-pane-to-window"]
810	assert.True(t, hasEject)
811	assert.NotNil(t, ejectAny)
812
813	moveAny, hasMove := actions["move-pane-to-tab"]
814	require.True(t, hasMove)
815	move, ok := moveAny.(map[string]any)
816	require.True(t, ok)
817	assert.Equal(t, "Custom move pane", move["desc"])
818}
819
820func TestMigrator_MergeMissingDefaultActions_VimMode(t *testing.T) {
821	m := NewMigrator()
822
823	rawConfig := map[string]any{
824		"workspace": map[string]any{
825			"vim_mode": map[string]any{
826				"actions": map[string]any{
827					"heading-next": map[string]any{
828						"keys": []string{"]]"},
829						"desc": "Custom next heading",
830					},
831				},
832			},
833		},
834	}
835
836	m.mergeMissingDefaultActions(rawConfig)
837
838	actionsAny := m.getNestedValue(rawConfig, "workspace.vim_mode.actions")
839	actions, ok := actionsAny.(map[string]any)
840	require.True(t, ok)
841
842	focusAny, hasFocus := actions["focus-input"]
843	require.True(t, hasFocus)
844	focus, ok := focusAny.(ActionBinding)
845	require.True(t, ok)
846	assert.Equal(t, []string{"gi"}, focus.Keys)
847
848	headingAny, hasHeading := actions["heading-next"]
849	require.True(t, hasHeading)
850	heading, ok := headingAny.(map[string]any)
851	require.True(t, ok)
852	assert.Equal(t, "Custom next heading", heading["desc"])
853}
854
855func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
856	m := NewMigrator()
857
858	value := m.defaultValueForKey("workspace.shortcuts.actions.toggle-floating-pane")
859
860	action, ok := value.(ActionBinding)
861	require.True(t, ok)
862	assert.Equal(t, []string{"alt+f"}, action.Keys)
863}
864
865func TestMigrator_DefaultValueForKey_PaneModeAction(t *testing.T) {
866	m := NewMigrator()
867
868	value := m.defaultValueForKey("workspace.pane_mode.actions.eject-pane-to-window")
869
870	action, ok := value.(ActionBinding)
871	require.True(t, ok)
872	assert.Equal(t, []string{"w"}, action.Keys)
873}
874
875func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) {
876	m := NewMigrator()
877
878	value := m.defaultValueForKey("history.max_entries")
879	assert.Equal(t, 10000, value)
880}
881
882func TestMigrator_GetNestedValue(t *testing.T) {
883	m := NewMigrator()
884
885	config := map[string]any{
886		"level1": map[string]any{
887			"level2": map[string]any{
888				"level3": "deep_value",
889			},
890			"simple": "simple_value",
891		},
892		"top": "top_value",
893	}
894
895	tests := []struct {
896		name     string
897		key      string
898		expected any
899	}{
900		{
901			name:     "top level key",
902			key:      "top",
903			expected: "top_value",
904		},
905		{
906			name:     "nested key",
907			key:      "level1.simple",
908			expected: "simple_value",
909		},
910		{
911			name:     "deeply nested key",
912			key:      "level1.level2.level3",
913			expected: "deep_value",
914		},
915		{
916			name:     "non-existent key",
917			key:      "nonexistent",
918			expected: nil,
919		},
920		{
921			name:     "non-existent nested key",
922			key:      "level1.nonexistent",
923			expected: nil,
924		},
925	}
926
927	for _, tt := range tests {
928		t.Run(tt.name, func(t *testing.T) {
929			result := m.getNestedValue(config, tt.key)
930			assert.Equal(t, tt.expected, result)
931		})
932	}
933}
934
935func TestMigrator_SetNestedValue(t *testing.T) {
936	m := NewMigrator()
937
938	t.Run("set top level value", func(t *testing.T) {
939		config := make(map[string]any)
940		m.setNestedValue(config, "top", "value")
941		assert.Equal(t, "value", config["top"])
942	})
943
944	t.Run("set nested value with existing parent", func(t *testing.T) {
945		config := map[string]any{
946			"parent": map[string]any{},
947		}
948		m.setNestedValue(config, "parent.child", "value")
949		parent := config["parent"].(map[string]any)
950		assert.Equal(t, "value", parent["child"])
951	})
952
953	t.Run("set nested value creates intermediate maps", func(t *testing.T) {
954		config := make(map[string]any)
955		m.setNestedValue(config, "a.b.c", "deep_value")
956
957		a := config["a"].(map[string]any)
958		b := a["b"].(map[string]any)
959		assert.Equal(t, "deep_value", b["c"])
960	})
961}
962
963func TestMigrator_DeleteNestedKey(t *testing.T) {
964	m := NewMigrator()
965
966	t.Run("delete top level key", func(t *testing.T) {
967		config := map[string]any{
968			"keep":   "value",
969			"delete": "value",
970		}
971		m.deleteNestedKey(config, "delete")
972		assert.Contains(t, config, "keep")
973		assert.NotContains(t, config, "delete")
974	})
975
976	t.Run("delete nested key", func(t *testing.T) {
977		config := map[string]any{
978			"parent": map[string]any{
979				"keep":   "value",
980				"delete": "value",
981			},
982		}
983		m.deleteNestedKey(config, "parent.delete")
984		parent := config["parent"].(map[string]any)
985		assert.Contains(t, parent, "keep")
986		assert.NotContains(t, parent, "delete")
987	})
988
989	t.Run("delete nested key cleans up empty parents", func(t *testing.T) {
990		config := map[string]any{
991			"keep": "value",
992			"parent": map[string]any{
993				"child": map[string]any{
994					"leaf": "value",
995				},
996			},
997		}
998		m.deleteNestedKey(config, "parent.child.leaf")
999		// Both parent and child should be removed since they're empty
1000		assert.NotContains(t, config, "parent")
1001		assert.Contains(t, config, "keep")
1002	})
1003
1004	t.Run("delete non-existent key is no-op", func(t *testing.T) {
1005		config := map[string]any{
1006			"keep": "value",
1007		}
1008		m.deleteNestedKey(config, "nonexistent")
1009		assert.Contains(t, config, "keep")
1010	})
1011}
1012
1013func TestMigrator_Migrate_IsIdempotentForExternalThemePathAndEmptyProfiles(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	require.Contains(t, content, "path = '")
1028	require.Contains(t, content, "[workspace.floating_pane.profiles]")
1029	content = strings.Replace(content, "\n    [workspace.floating_pane.profiles]\n", "", 1)
1030	require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1031
1032	m := NewMigrator()
1033	firstChanges, err := m.DetectChanges()
1034	require.NoError(t, err)
1035	assertContainsChange(t, firstChanges, port.KeyChangeAdded, "", "workspace.floating_pane.profiles")
1036
1037	appliedKeys, err := m.Migrate()
1038	require.NoError(t, err)
1039	require.NotEmpty(t, appliedKeys, "first migration should apply the missing profiles default")
1040
1041	secondChanges, err := m.DetectChanges()
1042	require.NoError(t, err)
1043	assert.Empty(t, secondChanges, "second migration detection should report no changes after a successful migration")
1044
1045	secondAppliedKeys, err := m.Migrate()
1046	require.NoError(t, err)
1047	assert.Empty(t, secondAppliedKeys, "second migration should be a no-op")
1048}
1049
1050func assertContainsChange(t *testing.T, changes []port.KeyChange, changeType port.KeyChangeType, oldKey, newKey string) {
1051	t.Helper()
1052
1053	for _, change := range changes {
1054		if change.Type == changeType && change.OldKey == oldKey && change.NewKey == newKey {
1055			return
1056		}
1057	}
1058	assert.Failf(t, "missing expected change", "type=%v old=%q new=%q changes=%v", changeType, oldKey, newKey, changes)
1059}
1060
1061// --- Migrate-path popup → browsing_contexts transform tests ---
1062
1063func TestMigrator_GetUserConfigKeysWithValues_TransformsLegacyPopups(t *testing.T) {
1064	m := NewMigrator()
1065
1066	tmpDir := t.TempDir()
1067	configFile := filepath.Join(tmpDir, "config.toml")
1068
1069	content := `
1070[workspace]
1071new_pane_url = "about:blank"
1072
1073[workspace.popups]
1074behavior = "tabbed"
1075placement = "left"
1076oauth_auto_close = false
1077`
1078	err := os.WriteFile(configFile, []byte(content), 0o644)
1079	require.NoError(t, err)
1080
1081	keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1082	require.NoError(t, err)
1083
1084	// Legacy popups keys should NOT appear after transform.
1085	_, hasPopupsBehavior := keysWithValues["workspace.popups.behavior"]
1086	assert.False(t, hasPopupsBehavior, "workspace.popups.behavior should not exist after transform")
1087
1088	// Browsing_contexts flattened keys should carry the legacy values.
1089	assert.Equal(t, "tabbed", keysWithValues["workspace.browsing_contexts.behavior"])
1090	assert.Equal(t, "left", keysWithValues["workspace.browsing_contexts.placement"])
1091	assert.Equal(t, false, keysWithValues["workspace.browsing_contexts.oauth_auto_close"])
1092}
1093
1094func TestMigrator_GetUserConfigKeysWithValues_BrowsingContextsAlreadyPresent(t *testing.T) {
1095	m := NewMigrator()
1096
1097	tmpDir := t.TempDir()
1098	configFile := filepath.Join(tmpDir, "config.toml")
1099
1100	// Both popups and browsing_contexts present — transform should be a no-op for popups.
1101	content := `
1102[workspace]
1103new_pane_url = "about:blank"
1104
1105[workspace.popups]
1106behavior = "tabbed"
1107
1108[workspace.browsing_contexts]
1109behavior = "split"
1110placement = "right"
1111`
1112	err := os.WriteFile(configFile, []byte(content), 0o644)
1113	require.NoError(t, err)
1114
1115	keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1116	require.NoError(t, err)
1117
1118	// Browsing_contexts flattened keys should retain their original values.
1119	assert.Equal(t, "split", keysWithValues["workspace.browsing_contexts.behavior"])
1120	assert.Equal(t, "right", keysWithValues["workspace.browsing_contexts.placement"])
1121
1122	// Popups should still exist (transform skipped when browsing_contexts already present).
1123	_, hasPopups := keysWithValues["workspace.popups.behavior"]
1124	assert.True(t, hasPopups, "popups should still exist when browsing_contexts already present")
1125}
1126
1127func TestMigrator_GetUserConfigKeysWithValues_NoPopupsKey(t *testing.T) {
1128	m := NewMigrator()
1129
1130	tmpDir := t.TempDir()
1131	configFile := filepath.Join(tmpDir, "config.toml")
1132
1133	content := `
1134[workspace]
1135new_pane_url = "about:blank"
1136
1137[workspace.browsing_contexts]
1138behavior = "stacked"
1139`
1140	err := os.WriteFile(configFile, []byte(content), 0o644)
1141	require.NoError(t, err)
1142
1143	keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1144	require.NoError(t, err)
1145
1146	// Should only have browsing_contexts, no popups.
1147	assert.Equal(t, "stacked", keysWithValues["workspace.browsing_contexts.behavior"])
1148
1149	_, hasPopups := keysWithValues["workspace.popups.behavior"]
1150	assert.False(t, hasPopups, "popups should not exist when absent from config")
1151}
1152
1153func TestMigrator_DetectChanges_ReportsLegacyBrowsingContextsRename(t *testing.T) {
1154	home := t.TempDir()
1155	t.Setenv("HOME", home)
1156	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1157	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1158	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1159	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1160
1161	configFile, err := GetConfigFile()
1162	require.NoError(t, err)
1163	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1164
1165	content, err := encodeConfigToTOML(DefaultConfig())
1166	require.NoError(t, err)
1167	content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1168	require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1169
1170	m := NewMigrator()
1171	changes, err := m.DetectChanges()
1172	require.NoError(t, err)
1173
1174	found := false
1175	for _, change := range changes {
1176		if change.Type == port.KeyChangeRenamed &&
1177			change.OldKey == "workspace.popups.behavior" &&
1178			change.NewKey == "workspace.browsing_contexts.behavior" {
1179			found = true
1180			break
1181		}
1182	}
1183	assert.True(t, found, "expected legacy workspace.popups.behavior rename to be reported")
1184}
1185
1186func TestMigrator_Migrate_AppliesLegacyBrowsingContextsRename(t *testing.T) {
1187	home := t.TempDir()
1188	t.Setenv("HOME", home)
1189	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1190	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1191	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1192	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1193
1194	configFile, err := GetConfigFile()
1195	require.NoError(t, err)
1196	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1197
1198	content, err := encodeConfigToTOML(DefaultConfig())
1199	require.NoError(t, err)
1200	content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1201	require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1202
1203	m := NewMigrator()
1204	appliedKeys, err := m.Migrate()
1205	require.NoError(t, err)
1206	assert.Contains(t, appliedKeys, "workspace.popups.behavior -> workspace.browsing_contexts.behavior")
1207
1208	migrated, err := os.ReadFile(configFile)
1209	require.NoError(t, err)
1210	assert.Contains(t, string(migrated), "[workspace.browsing_contexts]")
1211	assert.NotContains(t, string(migrated), "[workspace.popups]")
1212}
1213
1214func TestMigrator_Migrate_RenamesLegacyPageModeToVimMode(t *testing.T) {
1215	home := t.TempDir()
1216	t.Setenv("HOME", home)
1217	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1218	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1219	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1220	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1221
1222	configFile, err := GetConfigFile()
1223	require.NoError(t, err)
1224	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1225	require.NoError(t, os.WriteFile(configFile, []byte(`
1226[workspace.page_mode]
1227activation_shortcut = "ctrl+alt+y"
1228timeout_ms = 250
1229
1230[workspace.page_mode.actions.page-scroll-down]
1231keys = ["n"]
1232desc = "Scroll down"
1233
1234[workspace.page_mode.actions.page_scroll_up]
1235keys = ["u"]
1236desc = "Scroll up"
1237
1238[workspace.page_mode.actions.page_scroll_down_fast]
1239keys = ["d"]
1240desc = "Scroll down fast"
1241`), 0o644))
1242
1243	migrator := NewMigrator()
1244	applied, err := migrator.Migrate()
1245	require.NoError(t, err)
1246	assert.Contains(t, applied, "workspace.page_mode.activation_shortcut -> workspace.vim_mode.activation_shortcut")
1247	assert.Contains(t, applied, "workspace.page_mode.timeout_ms -> workspace.vim_mode.timeout_ms")
1248	assert.Contains(t, applied, "workspace.page_mode.actions -> workspace.vim_mode.actions")
1249
1250	migrated, err := os.ReadFile(configFile)
1251	require.NoError(t, err)
1252	assert.Contains(t, string(migrated), "[workspace.vim_mode]")
1253	assert.Contains(t, string(migrated), "activation_shortcut = 'ctrl+alt+y'")
1254	assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down]")
1255	assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-up]")
1256	assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down-fast]")
1257	assert.Contains(t, string(migrated), "keys = ['u']")
1258	assert.NotContains(t, string(migrated), "page_mode")
1259	assert.NotContains(t, string(migrated), "page-scroll-down")
1260}
1261
1262func TestMigrator_DetectChanges_CanonicalizesLegacyVimActionNames(t *testing.T) {
1263	home := t.TempDir()
1264	t.Setenv("HOME", home)
1265	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1266	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1267	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1268	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1269
1270	configFile, err := GetConfigFile()
1271	require.NoError(t, err)
1272	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1273	require.NoError(t, os.WriteFile(configFile, []byte(`
1274[workspace.page_mode.actions.page_scroll_down_fast]
1275keys = ["d"]
1276desc = "Scroll down fast"
1277`), 0o644))
1278
1279	changes, err := NewMigrator().DetectChanges()
1280	require.NoError(t, err)
1281
1282	foundRename := false
1283	for _, change := range changes {
1284		if change.Type == port.KeyChangeRenamed &&
1285			change.OldKey == "workspace.page_mode.actions" &&
1286			change.NewKey == "workspace.vim_mode.actions" {
1287			foundRename = true
1288		}
1289	}
1290	assert.True(t, foundRename, "DetectChanges must report the canonical Vim Mode actions key")
1291	assert.Equal(t, "vim-scroll-down-fast", legacyVimActionName("page_scroll_down_fast"))
1292}
1293
1294func TestMigrator_Migrate_MergesLegacyPageModeActionsIntoExistingVimMode(t *testing.T) {
1295	home := t.TempDir()
1296	t.Setenv("HOME", home)
1297	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1298	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1299	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1300	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1301
1302	configFile, err := GetConfigFile()
1303	require.NoError(t, err)
1304	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1305	require.NoError(t, os.WriteFile(configFile, []byte(`
1306[workspace.page_mode.actions.page-scroll-down]
1307keys = ["n"]
1308
1309[workspace.page_mode.actions.page-scroll-up]
1310keys = ["u"]
1311
1312[workspace.vim_mode.actions.vim-scroll-down]
1313keys = ["j"]
1314`), 0o644))
1315
1316	_, err = NewMigrator().Migrate()
1317	require.NoError(t, err)
1318
1319	migrated, err := os.ReadFile(configFile)
1320	require.NoError(t, err)
1321	assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down]\n        keys = ['j']")
1322	assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-up]\n        keys = ['u']")
1323	assert.NotContains(t, string(migrated), "page_mode")
1324}
1325
1326func TestMigrator_DetectChanges_MixedBrowsingContextsDoesNotDoubleReportLegacyRename(t *testing.T) {
1327	home := t.TempDir()
1328	t.Setenv("HOME", home)
1329	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1330	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1331	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1332	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1333
1334	configFile, err := GetConfigFile()
1335	require.NoError(t, err)
1336	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1337	require.NoError(t, os.WriteFile(configFile, []byte(`
1338[workspace.popups]
1339placement = "left"
1340
1341[workspace.browsing_contexts]
1342behavior = "tabbed"
1343`), 0o644))
1344
1345	m := NewMigrator()
1346	changes, err := m.DetectChanges()
1347	require.NoError(t, err)
1348
1349	renameCount := 0
1350	for _, change := range changes {
1351		if change.Type == port.KeyChangeRenamed &&
1352			change.OldKey == "workspace.popups.placement" &&
1353			change.NewKey == "workspace.browsing_contexts.placement" {
1354			renameCount++
1355		}
1356		assert.False(t,
1357			change.Type == port.KeyChangeRemoved && change.OldKey == "workspace.popups.placement",
1358			"legacy popup placement should be reported as a rename, not also a removal",
1359		)
1360		assert.False(t,
1361			change.Type == port.KeyChangeAdded && change.NewKey == "workspace.browsing_contexts.placement",
1362			"canonical browsing_contexts placement should be reported as a rename, not also an addition",
1363		)
1364	}
1365	assert.Equal(t, 1, renameCount)
1366}
1367
1368func TestMigrator_ReadRawConfig(t *testing.T) {
1369	m := NewMigrator()
1370
1371	t.Run("read TOML config", func(t *testing.T) {
1372		tmpDir := t.TempDir()
1373		configFile := filepath.Join(tmpDir, "config.toml")
1374
1375		content := `
1376[section]
1377key = "value"
1378number = 42
1379`
1380		err := os.WriteFile(configFile, []byte(content), 0o644)
1381		require.NoError(t, err)
1382
1383		config, err := m.readRawConfig(configFile)
1384		require.NoError(t, err)
1385
1386		section := config["section"].(map[string]any)
1387		assert.Equal(t, "value", section["key"])
1388		assert.Equal(t, int64(42), section["number"])
1389	})
1390
1391	t.Run("read YAML config", func(t *testing.T) {
1392		tmpDir := t.TempDir()
1393		configFile := filepath.Join(tmpDir, "config.yaml")
1394
1395		content := `
1396section:
1397  key: value
1398  number: 42
1399`
1400		err := os.WriteFile(configFile, []byte(content), 0o644)
1401		require.NoError(t, err)
1402
1403		config, err := m.readRawConfig(configFile)
1404		require.NoError(t, err)
1405
1406		section := config["section"].(map[string]any)
1407		assert.Equal(t, "value", section["key"])
1408		assert.Equal(t, 42, section["number"])
1409	})
1410
1411	t.Run("read JSON config", func(t *testing.T) {
1412		tmpDir := t.TempDir()
1413		configFile := filepath.Join(tmpDir, "config.json")
1414
1415		content := `{
1416  "section": {
1417    "key": "value",
1418    "number": 42
1419  }
1420}`
1421		err := os.WriteFile(configFile, []byte(content), 0o644)
1422		require.NoError(t, err)
1423
1424		config, err := m.readRawConfig(configFile)
1425		require.NoError(t, err)
1426
1427		section := config["section"].(map[string]any)
1428		assert.Equal(t, "value", section["key"])
1429		// JSON numbers are float64
1430		assert.InDelta(t, 42.0, section["number"].(float64), 0.001)
1431	})
1432}
1433
1434func TestMigrator_Migrate_RenamesCustomFavoritesSidebarShortcut(t *testing.T) {
1435	home := t.TempDir()
1436	t.Setenv("HOME", home)
1437	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1438	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1439	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1440	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1441
1442	configFile, err := GetConfigFile()
1443	require.NoError(t, err)
1444	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1445	require.NoError(t, os.WriteFile(configFile, []byte(`
1446[workspace.shortcuts.actions.toggle-favorites-systemview]
1447keys = []
1448desc = "Custom favorites shortcut"
1449`), 0o644))
1450
1451	migrator := NewMigrator()
1452	result, err := migrator.CheckMigration()
1453	require.NoError(t, err)
1454	require.NotNil(t, result)
1455	assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-sidebar")
1456
1457	changes, err := migrator.DetectChanges()
1458	require.NoError(t, err)
1459	assertContainsChange(t, changes, port.KeyChangeRenamed,
1460		"workspace.shortcuts.actions.toggle-favorites-systemview",
1461		"workspace.shortcuts.actions.toggle-favorites-sidebar")
1462
1463	applied, err := migrator.Migrate()
1464	require.NoError(t, err)
1465	assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview -> workspace.shortcuts.actions.toggle-favorites-sidebar")
1466
1467	mgr, err := NewManager()
1468	require.NoError(t, err)
1469	require.NoError(t, mgr.Load())
1470	_, ok := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
1471	assert.False(t, ok)
1472	favorites := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
1473	assert.Empty(t, favorites.Keys)
1474	assert.Equal(t, "Custom favorites shortcut", favorites.Desc)
1475}
1476
1477func TestMigrator_Migrate_UpgradesLegacyFavoritesSidebarDefault(t *testing.T) {
1478	home := t.TempDir()
1479	t.Setenv("HOME", home)
1480	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1481	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1482	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1483	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1484
1485	configFile, err := GetConfigFile()
1486	require.NoError(t, err)
1487	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1488	require.NoError(t, os.WriteFile(configFile, []byte(`
1489[workspace.shortcuts.actions.toggle-favorites-systemview]
1490keys = []
1491desc = "Toggle Favorites in right split"
1492`), 0o644))
1493
1494	migrator := NewMigrator()
1495	result, err := migrator.CheckMigration()
1496	require.NoError(t, err)
1497	require.NotNil(t, result)
1498	assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-sidebar")
1499	assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-current-page-favorite")
1500
1501	changes, err := migrator.DetectChanges()
1502	require.NoError(t, err)
1503	assertContainsChange(t, changes, port.KeyChangeRenamed,
1504		"workspace.shortcuts.actions.toggle-favorites-systemview",
1505		"workspace.shortcuts.actions.toggle-favorites-sidebar")
1506	assertContainsChange(t, changes, port.KeyChangeAdded, "", "workspace.shortcuts.actions.toggle-current-page-favorite")
1507
1508	applied, err := migrator.Migrate()
1509	require.NoError(t, err)
1510	assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview -> workspace.shortcuts.actions.toggle-favorites-sidebar")
1511	assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-current-page-favorite")
1512
1513	raw, err := migrator.readRawConfig(configFile)
1514	require.NoError(t, err)
1515	workspace, ok := raw["workspace"].(map[string]any)
1516	require.True(t, ok)
1517	shortcuts, ok := workspace["shortcuts"].(map[string]any)
1518	require.True(t, ok)
1519	actions, ok := shortcuts["actions"].(map[string]any)
1520	require.True(t, ok)
1521
1522	expectedActions := DefaultConfig().Workspace.Shortcuts.Actions
1523	_, ok = actions["toggle-favorites-systemview"]
1524	assert.False(t, ok)
1525	favorites, ok := actions["toggle-favorites-sidebar"].(map[string]any)
1526	require.True(t, ok)
1527	assert.Equal(t, []any{"ctrl+b"}, favorites["keys"])
1528	assert.Equal(t, expectedActions["toggle-favorites-sidebar"].Desc, favorites["desc"])
1529	currentPage, ok := actions["toggle-current-page-favorite"].(map[string]any)
1530	require.True(t, ok)
1531	assert.Equal(t, []any{"ctrl+d"}, currentPage["keys"])
1532	assert.Equal(t, expectedActions["toggle-current-page-favorite"].Desc, currentPage["desc"])
1533
1534	mgr, err := NewManager()
1535	require.NoError(t, err)
1536	require.NoError(t, mgr.Load())
1537	cfg := mgr.Get()
1538	require.NotNil(t, cfg)
1539	_, ok = cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
1540	assert.False(t, ok)
1541	assert.Equal(t, []string{"ctrl+b"}, cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"].Keys)
1542	assert.Equal(t, []string{"ctrl+d"}, cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"].Keys)
1543}
1544
1545func writeMigratorUserConfig(t *testing.T, content string) string {
1546	t.Helper()
1547	home := t.TempDir()
1548	t.Setenv("HOME", home)
1549	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1550	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1551	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1552	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1553
1554	configFile, err := GetConfigFile()
1555	require.NoError(t, err)
1556	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1557	require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1558	return configFile
1559}
1560
1561func TestMigrator_DetectChanges_ReportsLegacyOmniboxMaxHistoryDaysRename(t *testing.T) {
1562	writeMigratorUserConfig(t, "[omnibox]\nmost_visited_days = 0\n")
1563
1564	changes, err := NewMigrator().DetectChanges()
1565	require.NoError(t, err)
1566
1567	found := false
1568	for _, change := range changes {
1569		if change.Type == port.KeyChangeRenamed &&
1570			change.OldKey == "omnibox.most_visited_days" &&
1571			change.NewKey == "omnibox.max_history_days" {
1572			found = true
1573		}
1574		if change.OldKey == "omnibox.most_visited_days" && change.Type == port.KeyChangeRemoved {
1575			t.Fatal("legacy key must be reported as a rename, not a removal")
1576		}
1577		if change.NewKey == "omnibox.max_history_days" && change.Type == port.KeyChangeAdded {
1578			t.Fatal("migrated key must be reported as a rename, not an addition")
1579		}
1580	}
1581	assert.True(t, found, "expected legacy omnibox.most_visited_days rename to be reported")
1582}
1583
1584func TestMigrator_Migrate_RenamesLegacyOmniboxMaxHistoryDaysPreservingValue(t *testing.T) {
1585	tests := []struct {
1586		name  string
1587		value string
1588	}{
1589		{name: "zero is preserved", value: "0"},
1590		{name: "custom value is preserved", value: "7"},
1591	}
1592
1593	for _, tt := range tests {
1594		t.Run(tt.name, func(t *testing.T) {
1595			configFile := writeMigratorUserConfig(t, "[omnibox]\nmost_visited_days = "+tt.value+"\n")
1596
1597			applied, err := NewMigrator().Migrate()
1598			require.NoError(t, err)
1599			assert.Contains(t, applied, "omnibox.most_visited_days -> omnibox.max_history_days")
1600
1601			migrated, err := os.ReadFile(configFile)
1602			require.NoError(t, err)
1603			assert.Contains(t, string(migrated), "max_history_days = "+tt.value)
1604			assert.NotContains(t, string(migrated), "most_visited_days")
1605		})
1606	}
1607}
1608
1609func TestMigrator_Migrate_KeepsNewOmniboxMaxHistoryDaysOverLegacyKey(t *testing.T) {
1610	configFile := writeMigratorUserConfig(t, "[omnibox]\nmax_history_days = 7\nmost_visited_days = 99\n")
1611
1612	_, err := NewMigrator().Migrate()
1613	require.NoError(t, err)
1614
1615	migrated, err := os.ReadFile(configFile)
1616	require.NoError(t, err)
1617	assert.Contains(t, string(migrated), "max_history_days = 7")
1618	assert.NotContains(t, string(migrated), "most_visited_days")
1619}
1620
1621func TestMigrator_Migrate_AddsKeymapPresetAndBuiltinShortcuts(t *testing.T) {
1622	home := t.TempDir()
1623	t.Setenv("HOME", home)
1624	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1625	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1626	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1627	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1628
1629	configFile, err := GetConfigFile()
1630	require.NoError(t, err)
1631	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1632	// A config written before keymap presets: no keymap, no prefix_mode, and a
1633	// shortcuts table without the configurable browser actions.
1634	require.NoError(t, os.WriteFile(configFile, []byte(`
1635[workspace.shortcuts.actions.quit]
1636keys = ["ctrl+shift+q"]
1637desc = "Custom quit"
1638
1639[workspace.shortcuts.actions.toggle-floating-pane]
1640keys = ["alt+f"]
1641desc = "Toggle floating pane"
1642`), 0o644))
1643
1644	m := NewMigrator()
1645	result, err := m.CheckMigration()
1646	require.NoError(t, err)
1647	require.NotNil(t, result)
1648	assert.Contains(t, result.MissingKeys, "workspace.keymap")
1649	assert.Contains(t, result.MissingKeys, "workspace.prefix_mode.activation_shortcut")
1650	assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.open-omnibox")
1651
1652	_, err = m.Migrate()
1653	require.NoError(t, err)
1654
1655	mgr, err := NewManager()
1656	require.NoError(t, err)
1657	require.NoError(t, mgr.Load())
1658	cfg := mgr.Get()
1659
1660	assert.Equal(t, KeymapZellij, cfg.Workspace.Keymap)
1661	assert.Equal(t, defaultPrefixActivationShortcut, cfg.Workspace.PrefixMode.ActivationShortcut)
1662	assert.Equal(t, DefaultConfig().Workspace.PrefixMode.Actions, cfg.Workspace.PrefixMode.Actions)
1663	assert.Equal(t, []string{"ctrl+l"}, cfg.Workspace.Shortcuts.Actions["open-omnibox"].Keys)
1664	assert.Equal(t, []string{"ctrl+shift+q"}, cfg.Workspace.Shortcuts.Actions["quit"].Keys, "user override kept")
1665
1666	again, err := m.CheckMigration()
1667	require.NoError(t, err)
1668	assert.Nil(t, again, "migration is idempotent")
1669}

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