v0.30.0 eb3068f

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_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
820	m := NewMigrator()
821
822	value := m.defaultValueForKey("workspace.shortcuts.actions.toggle-floating-pane")
823
824	action, ok := value.(ActionBinding)
825	require.True(t, ok)
826	assert.Equal(t, []string{"alt+f"}, action.Keys)
827}
828
829func TestMigrator_DefaultValueForKey_PaneModeAction(t *testing.T) {
830	m := NewMigrator()
831
832	value := m.defaultValueForKey("workspace.pane_mode.actions.eject-pane-to-window")
833
834	action, ok := value.(ActionBinding)
835	require.True(t, ok)
836	assert.Equal(t, []string{"w"}, action.Keys)
837}
838
839func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) {
840	m := NewMigrator()
841
842	value := m.defaultValueForKey("history.max_entries")
843	assert.Equal(t, 10000, value)
844}
845
846func TestMigrator_GetNestedValue(t *testing.T) {
847	m := NewMigrator()
848
849	config := map[string]any{
850		"level1": map[string]any{
851			"level2": map[string]any{
852				"level3": "deep_value",
853			},
854			"simple": "simple_value",
855		},
856		"top": "top_value",
857	}
858
859	tests := []struct {
860		name     string
861		key      string
862		expected any
863	}{
864		{
865			name:     "top level key",
866			key:      "top",
867			expected: "top_value",
868		},
869		{
870			name:     "nested key",
871			key:      "level1.simple",
872			expected: "simple_value",
873		},
874		{
875			name:     "deeply nested key",
876			key:      "level1.level2.level3",
877			expected: "deep_value",
878		},
879		{
880			name:     "non-existent key",
881			key:      "nonexistent",
882			expected: nil,
883		},
884		{
885			name:     "non-existent nested key",
886			key:      "level1.nonexistent",
887			expected: nil,
888		},
889	}
890
891	for _, tt := range tests {
892		t.Run(tt.name, func(t *testing.T) {
893			result := m.getNestedValue(config, tt.key)
894			assert.Equal(t, tt.expected, result)
895		})
896	}
897}
898
899func TestMigrator_SetNestedValue(t *testing.T) {
900	m := NewMigrator()
901
902	t.Run("set top level value", func(t *testing.T) {
903		config := make(map[string]any)
904		m.setNestedValue(config, "top", "value")
905		assert.Equal(t, "value", config["top"])
906	})
907
908	t.Run("set nested value with existing parent", func(t *testing.T) {
909		config := map[string]any{
910			"parent": map[string]any{},
911		}
912		m.setNestedValue(config, "parent.child", "value")
913		parent := config["parent"].(map[string]any)
914		assert.Equal(t, "value", parent["child"])
915	})
916
917	t.Run("set nested value creates intermediate maps", func(t *testing.T) {
918		config := make(map[string]any)
919		m.setNestedValue(config, "a.b.c", "deep_value")
920
921		a := config["a"].(map[string]any)
922		b := a["b"].(map[string]any)
923		assert.Equal(t, "deep_value", b["c"])
924	})
925}
926
927func TestMigrator_DeleteNestedKey(t *testing.T) {
928	m := NewMigrator()
929
930	t.Run("delete top level key", func(t *testing.T) {
931		config := map[string]any{
932			"keep":   "value",
933			"delete": "value",
934		}
935		m.deleteNestedKey(config, "delete")
936		assert.Contains(t, config, "keep")
937		assert.NotContains(t, config, "delete")
938	})
939
940	t.Run("delete nested key", func(t *testing.T) {
941		config := map[string]any{
942			"parent": map[string]any{
943				"keep":   "value",
944				"delete": "value",
945			},
946		}
947		m.deleteNestedKey(config, "parent.delete")
948		parent := config["parent"].(map[string]any)
949		assert.Contains(t, parent, "keep")
950		assert.NotContains(t, parent, "delete")
951	})
952
953	t.Run("delete nested key cleans up empty parents", func(t *testing.T) {
954		config := map[string]any{
955			"keep": "value",
956			"parent": map[string]any{
957				"child": map[string]any{
958					"leaf": "value",
959				},
960			},
961		}
962		m.deleteNestedKey(config, "parent.child.leaf")
963		// Both parent and child should be removed since they're empty
964		assert.NotContains(t, config, "parent")
965		assert.Contains(t, config, "keep")
966	})
967
968	t.Run("delete non-existent key is no-op", func(t *testing.T) {
969		config := map[string]any{
970			"keep": "value",
971		}
972		m.deleteNestedKey(config, "nonexistent")
973		assert.Contains(t, config, "keep")
974	})
975}
976
977func TestMigrator_Migrate_IsIdempotentForExternalThemePathAndEmptyProfiles(t *testing.T) {
978	home := t.TempDir()
979	t.Setenv("HOME", home)
980	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
981	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
982	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
983	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
984
985	configFile, err := GetConfigFile()
986	require.NoError(t, err)
987	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
988
989	content, err := encodeConfigToTOML(DefaultConfig())
990	require.NoError(t, err)
991	require.Contains(t, content, "path = '")
992	require.Contains(t, content, "[workspace.floating_pane.profiles]")
993	content = strings.Replace(content, "\n    [workspace.floating_pane.profiles]\n", "", 1)
994	require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
995
996	m := NewMigrator()
997	firstChanges, err := m.DetectChanges()
998	require.NoError(t, err)
999	assertContainsChange(t, firstChanges, port.KeyChangeAdded, "", "workspace.floating_pane.profiles")
1000
1001	appliedKeys, err := m.Migrate()
1002	require.NoError(t, err)
1003	require.NotEmpty(t, appliedKeys, "first migration should apply the missing profiles default")
1004
1005	secondChanges, err := m.DetectChanges()
1006	require.NoError(t, err)
1007	assert.Empty(t, secondChanges, "second migration detection should report no changes after a successful migration")
1008
1009	secondAppliedKeys, err := m.Migrate()
1010	require.NoError(t, err)
1011	assert.Empty(t, secondAppliedKeys, "second migration should be a no-op")
1012}
1013
1014func assertContainsChange(t *testing.T, changes []port.KeyChange, changeType port.KeyChangeType, oldKey, newKey string) {
1015	t.Helper()
1016
1017	for _, change := range changes {
1018		if change.Type == changeType && change.OldKey == oldKey && change.NewKey == newKey {
1019			return
1020		}
1021	}
1022	assert.Failf(t, "missing expected change", "type=%v old=%q new=%q changes=%v", changeType, oldKey, newKey, changes)
1023}
1024
1025// --- Migrate-path popup → browsing_contexts transform tests ---
1026
1027func TestMigrator_GetUserConfigKeysWithValues_TransformsLegacyPopups(t *testing.T) {
1028	m := NewMigrator()
1029
1030	tmpDir := t.TempDir()
1031	configFile := filepath.Join(tmpDir, "config.toml")
1032
1033	content := `
1034[workspace]
1035new_pane_url = "about:blank"
1036
1037[workspace.popups]
1038behavior = "tabbed"
1039placement = "left"
1040oauth_auto_close = false
1041`
1042	err := os.WriteFile(configFile, []byte(content), 0o644)
1043	require.NoError(t, err)
1044
1045	keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1046	require.NoError(t, err)
1047
1048	// Legacy popups keys should NOT appear after transform.
1049	_, hasPopupsBehavior := keysWithValues["workspace.popups.behavior"]
1050	assert.False(t, hasPopupsBehavior, "workspace.popups.behavior should not exist after transform")
1051
1052	// Browsing_contexts flattened keys should carry the legacy values.
1053	assert.Equal(t, "tabbed", keysWithValues["workspace.browsing_contexts.behavior"])
1054	assert.Equal(t, "left", keysWithValues["workspace.browsing_contexts.placement"])
1055	assert.Equal(t, false, keysWithValues["workspace.browsing_contexts.oauth_auto_close"])
1056}
1057
1058func TestMigrator_GetUserConfigKeysWithValues_BrowsingContextsAlreadyPresent(t *testing.T) {
1059	m := NewMigrator()
1060
1061	tmpDir := t.TempDir()
1062	configFile := filepath.Join(tmpDir, "config.toml")
1063
1064	// Both popups and browsing_contexts present — transform should be a no-op for popups.
1065	content := `
1066[workspace]
1067new_pane_url = "about:blank"
1068
1069[workspace.popups]
1070behavior = "tabbed"
1071
1072[workspace.browsing_contexts]
1073behavior = "split"
1074placement = "right"
1075`
1076	err := os.WriteFile(configFile, []byte(content), 0o644)
1077	require.NoError(t, err)
1078
1079	keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1080	require.NoError(t, err)
1081
1082	// Browsing_contexts flattened keys should retain their original values.
1083	assert.Equal(t, "split", keysWithValues["workspace.browsing_contexts.behavior"])
1084	assert.Equal(t, "right", keysWithValues["workspace.browsing_contexts.placement"])
1085
1086	// Popups should still exist (transform skipped when browsing_contexts already present).
1087	_, hasPopups := keysWithValues["workspace.popups.behavior"]
1088	assert.True(t, hasPopups, "popups should still exist when browsing_contexts already present")
1089}
1090
1091func TestMigrator_GetUserConfigKeysWithValues_NoPopupsKey(t *testing.T) {
1092	m := NewMigrator()
1093
1094	tmpDir := t.TempDir()
1095	configFile := filepath.Join(tmpDir, "config.toml")
1096
1097	content := `
1098[workspace]
1099new_pane_url = "about:blank"
1100
1101[workspace.browsing_contexts]
1102behavior = "stacked"
1103`
1104	err := os.WriteFile(configFile, []byte(content), 0o644)
1105	require.NoError(t, err)
1106
1107	keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1108	require.NoError(t, err)
1109
1110	// Should only have browsing_contexts, no popups.
1111	assert.Equal(t, "stacked", keysWithValues["workspace.browsing_contexts.behavior"])
1112
1113	_, hasPopups := keysWithValues["workspace.popups.behavior"]
1114	assert.False(t, hasPopups, "popups should not exist when absent from config")
1115}
1116
1117func TestMigrator_DetectChanges_ReportsLegacyBrowsingContextsRename(t *testing.T) {
1118	home := t.TempDir()
1119	t.Setenv("HOME", home)
1120	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1121	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1122	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1123	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1124
1125	configFile, err := GetConfigFile()
1126	require.NoError(t, err)
1127	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1128
1129	content, err := encodeConfigToTOML(DefaultConfig())
1130	require.NoError(t, err)
1131	content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1132	require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1133
1134	m := NewMigrator()
1135	changes, err := m.DetectChanges()
1136	require.NoError(t, err)
1137
1138	found := false
1139	for _, change := range changes {
1140		if change.Type == port.KeyChangeRenamed &&
1141			change.OldKey == "workspace.popups.behavior" &&
1142			change.NewKey == "workspace.browsing_contexts.behavior" {
1143			found = true
1144			break
1145		}
1146	}
1147	assert.True(t, found, "expected legacy workspace.popups.behavior rename to be reported")
1148}
1149
1150func TestMigrator_Migrate_AppliesLegacyBrowsingContextsRename(t *testing.T) {
1151	home := t.TempDir()
1152	t.Setenv("HOME", home)
1153	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1154	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1155	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1156	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1157
1158	configFile, err := GetConfigFile()
1159	require.NoError(t, err)
1160	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1161
1162	content, err := encodeConfigToTOML(DefaultConfig())
1163	require.NoError(t, err)
1164	content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1165	require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1166
1167	m := NewMigrator()
1168	appliedKeys, err := m.Migrate()
1169	require.NoError(t, err)
1170	assert.Contains(t, appliedKeys, "workspace.popups.behavior -> workspace.browsing_contexts.behavior")
1171
1172	migrated, err := os.ReadFile(configFile)
1173	require.NoError(t, err)
1174	assert.Contains(t, string(migrated), "[workspace.browsing_contexts]")
1175	assert.NotContains(t, string(migrated), "[workspace.popups]")
1176}
1177
1178func TestMigrator_DetectChanges_MixedBrowsingContextsDoesNotDoubleReportLegacyRename(t *testing.T) {
1179	home := t.TempDir()
1180	t.Setenv("HOME", home)
1181	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1182	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1183	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1184	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1185
1186	configFile, err := GetConfigFile()
1187	require.NoError(t, err)
1188	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1189	require.NoError(t, os.WriteFile(configFile, []byte(`
1190[workspace.popups]
1191placement = "left"
1192
1193[workspace.browsing_contexts]
1194behavior = "tabbed"
1195`), 0o644))
1196
1197	m := NewMigrator()
1198	changes, err := m.DetectChanges()
1199	require.NoError(t, err)
1200
1201	renameCount := 0
1202	for _, change := range changes {
1203		if change.Type == port.KeyChangeRenamed &&
1204			change.OldKey == "workspace.popups.placement" &&
1205			change.NewKey == "workspace.browsing_contexts.placement" {
1206			renameCount++
1207		}
1208		assert.False(t,
1209			change.Type == port.KeyChangeRemoved && change.OldKey == "workspace.popups.placement",
1210			"legacy popup placement should be reported as a rename, not also a removal",
1211		)
1212		assert.False(t,
1213			change.Type == port.KeyChangeAdded && change.NewKey == "workspace.browsing_contexts.placement",
1214			"canonical browsing_contexts placement should be reported as a rename, not also an addition",
1215		)
1216	}
1217	assert.Equal(t, 1, renameCount)
1218}
1219
1220func TestMigrator_ReadRawConfig(t *testing.T) {
1221	m := NewMigrator()
1222
1223	t.Run("read TOML config", func(t *testing.T) {
1224		tmpDir := t.TempDir()
1225		configFile := filepath.Join(tmpDir, "config.toml")
1226
1227		content := `
1228[section]
1229key = "value"
1230number = 42
1231`
1232		err := os.WriteFile(configFile, []byte(content), 0o644)
1233		require.NoError(t, err)
1234
1235		config, err := m.readRawConfig(configFile)
1236		require.NoError(t, err)
1237
1238		section := config["section"].(map[string]any)
1239		assert.Equal(t, "value", section["key"])
1240		assert.Equal(t, int64(42), section["number"])
1241	})
1242
1243	t.Run("read YAML config", func(t *testing.T) {
1244		tmpDir := t.TempDir()
1245		configFile := filepath.Join(tmpDir, "config.yaml")
1246
1247		content := `
1248section:
1249  key: value
1250  number: 42
1251`
1252		err := os.WriteFile(configFile, []byte(content), 0o644)
1253		require.NoError(t, err)
1254
1255		config, err := m.readRawConfig(configFile)
1256		require.NoError(t, err)
1257
1258		section := config["section"].(map[string]any)
1259		assert.Equal(t, "value", section["key"])
1260		assert.Equal(t, 42, section["number"])
1261	})
1262
1263	t.Run("read JSON config", func(t *testing.T) {
1264		tmpDir := t.TempDir()
1265		configFile := filepath.Join(tmpDir, "config.json")
1266
1267		content := `{
1268  "section": {
1269    "key": "value",
1270    "number": 42
1271  }
1272}`
1273		err := os.WriteFile(configFile, []byte(content), 0o644)
1274		require.NoError(t, err)
1275
1276		config, err := m.readRawConfig(configFile)
1277		require.NoError(t, err)
1278
1279		section := config["section"].(map[string]any)
1280		assert.Equal(t, "value", section["key"])
1281		// JSON numbers are float64
1282		assert.InDelta(t, 42.0, section["number"].(float64), 0.001)
1283	})
1284}

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