v0.32.0 0617278

migrate.go RawHistoryFolder

1package config
2
3import (
4	"encoding/json"
5	"fmt"
6	"os"
7	"path/filepath"
8	"reflect"
9	"sort"
10	"strings"
11
12	"github.com/pelletier/go-toml/v2"
13	"github.com/spf13/viper"
14	"gopkg.in/yaml.v3"
15
16	"github.com/bnema/dumber/internal/application/port"
17	"github.com/bnema/dumber/internal/domain/entity"
18)
19
20// Migrator implements port.ConfigMigrator for comparing and merging config files.
21type Migrator struct {
22	// defaultViper holds a Viper instance with all defaults set.
23	defaultViper *viper.Viper
24	// defaultConfig holds the default configuration.
25	defaultConfig *Config
26}
27
28// NewMigrator creates a new Migrator instance.
29func NewMigrator() *Migrator {
30	v := viper.New()
31	v.SetConfigType("toml")
32
33	// Create a temporary manager to set defaults
34	m := &Manager{viper: v}
35	m.setDefaults()
36
37	return &Migrator{
38		defaultViper:  v,
39		defaultConfig: DefaultConfig(),
40	}
41}
42
43// CheckMigration checks if user config is missing any default keys.
44// Returns nil if no migration is needed.
45func (m *Migrator) CheckMigration() (*port.MigrationResult, error) {
46	configFile, err := GetConfigFile()
47	if err != nil {
48		return nil, fmt.Errorf("failed to get config file path: %w", err)
49	}
50
51	// If config file doesn't exist, no migration needed (will be created with all defaults)
52	if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
53		return nil, nil
54	}
55
56	userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile)
57	if err != nil {
58		return nil, fmt.Errorf("failed to parse user config: %w", err)
59	}
60
61	userKeys := make(map[string]bool, len(userKeysWithValues))
62	for key := range userKeysWithValues {
63		userKeys[key] = true
64	}
65
66	// Get all default keys
67	defaultKeys := m.getAllDefaultKeys()
68
69	// Find missing keys (in defaults but not in user config)
70	missingKeys := m.findMissingKeys(defaultKeys, userKeys)
71	missingKeys = appendUniqueKeys(missingKeys, m.detectMissingDefaultActions(userKeysWithValues))
72
73	if len(missingKeys) == 0 {
74		return nil, nil
75	}
76
77	return &port.MigrationResult{
78		MissingKeys: missingKeys,
79		ConfigFile:  configFile,
80	}, nil
81}
82
83// DetectChanges analyzes user config and returns all detected changes.
84// This provides a detailed diff-like view of what migration would do.
85func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
86	configFile, err := GetConfigFile()
87	if err != nil {
88		return nil, fmt.Errorf("failed to get config file path: %w", err)
89	}
90
91	// If config file doesn't exist, no changes to detect
92	if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
93		return nil, nil
94	}
95
96	// Get user-defined keys with values after compatibility transforms.
97	userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile)
98	if err != nil {
99		return nil, fmt.Errorf("failed to parse user config: %w", err)
100	}
101
102	// Get raw user-defined keys before popup compatibility transforms so legacy
103	// workspace.popups -> workspace.browsing_contexts renames are still reported.
104	rawUserKeysWithValues, err := m.getRawUserConfigKeysWithValues(configFile)
105	if err != nil {
106		return nil, fmt.Errorf("failed to parse raw user config: %w", err)
107	}
108
109	// Get all default keys
110	defaultKeys := m.getAllDefaultKeys()
111	defaultKeySet := make(map[string]bool)
112	for _, k := range defaultKeys {
113		defaultKeySet[k] = true
114	}
115
116	// Build user key set for lookup
117	userKeySet := make(map[string]bool)
118	for k := range userKeysWithValues {
119		userKeySet[k] = true
120	}
121
122	legacyRenames := append(
123		m.detectLegacyBrowsingContextRenames(rawUserKeysWithValues),
124		m.detectLegacyFavoritesSidebarRename(userKeysWithValues)...,
125	)
126	legacyRenames = append(legacyRenames, m.detectLegacyVimModeRenames(rawUserKeysWithValues)...)
127	var changes []port.KeyChange
128	changes = append(changes, legacyRenames...)
129
130	handledLegacyDeprecatedKeys := make(map[string]bool, len(legacyRenames))
131	handledLegacyMissingKeys := make(map[string]bool, len(legacyRenames))
132	for _, change := range legacyRenames {
133		handledLegacyDeprecatedKeys[change.OldKey] = true
134		handledLegacyMissingKeys[change.NewKey] = true
135	}
136
137	// Find deprecated keys (in user config but not in defaults)
138	deprecatedKeys := excludeKeys(m.findDeprecatedKeys(userKeysWithValues, defaultKeySet), handledLegacyDeprecatedKeys)
139
140	// Find missing keys (in defaults but not in user config)
141	missingKeys := excludeKeys(m.findMissingKeys(defaultKeys, userKeySet), handledLegacyMissingKeys)
142	missingKeys = appendUniqueKeys(missingKeys, m.detectMissingDefaultActions(userKeysWithValues))
143
144	// Try to match deprecated keys with missing keys (detect renames)
145	renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys)
146
147	// Add rename changes
148	for oldKey, newKey := range renames {
149		oldValue := m.formatValue(userKeysWithValues[oldKey])
150		newValue := m.formatValue(m.defaultValueForKey(newKey))
151		changes = append(changes, port.KeyChange{
152			Type:     port.KeyChangeRenamed,
153			OldKey:   oldKey,
154			NewKey:   newKey,
155			OldValue: oldValue,
156			NewValue: newValue,
157		})
158	}
159
160	// Add removed changes (deprecated keys that couldn't be matched)
161	for _, oldKey := range unmatchedDeprecated {
162		changes = append(changes, port.KeyChange{
163			Type:     port.KeyChangeRemoved,
164			OldKey:   oldKey,
165			OldValue: m.formatValue(userKeysWithValues[oldKey]),
166		})
167	}
168
169	// Add new key changes (missing keys that couldn't be matched)
170	for _, newKey := range unmatchedMissing {
171		changes = append(changes, port.KeyChange{
172			Type:     port.KeyChangeAdded,
173			NewKey:   newKey,
174			NewValue: m.formatValue(m.defaultValueForKey(newKey)),
175		})
176	}
177
178	// Sort changes for consistent output
179	sort.Slice(changes, func(i, j int) bool {
180		// Sort by type first, then by key
181		if changes[i].Type != changes[j].Type {
182			return changes[i].Type < changes[j].Type
183		}
184		keyI := changes[i].NewKey
185		if keyI == "" {
186			keyI = changes[i].OldKey
187		}
188		keyJ := changes[j].NewKey
189		if keyJ == "" {
190			keyJ = changes[j].OldKey
191		}
192		return keyI < keyJ
193	})
194
195	return changes, nil
196}
197
198// findDeprecatedKeys returns user keys that don't exist in defaults.
199func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string {
200	var deprecated []string
201	for userKey := range userKeys {
202		if m.isMigrationIgnoredDeprecatedKey(userKey) {
203			continue
204		}
205		if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) {
206			deprecated = append(deprecated, userKey)
207		}
208	}
209	sort.Strings(deprecated)
210	return deprecated
211}
212
213// keyOrRelatedExistsInDefaults checks if a user key exists in defaults.
214func (*Migrator) keyOrRelatedExistsInDefaults(userKey string, defaultKeys map[string]bool) bool {
215	if defaultKeys[userKey] {
216		return true
217	}
218
219	// Check if this is a parent of any default key
220	userKeyPrefix := userKey + "."
221	for defaultKey := range defaultKeys {
222		if strings.HasPrefix(defaultKey, userKeyPrefix) {
223			return true
224		}
225	}
226
227	// Check if any parent of this user key exists
228	parts := strings.Split(userKey, ".")
229	for i := len(parts) - 1; i > 0; i-- {
230		parentKey := strings.Join(parts[:i], ".")
231		if defaultKeys[parentKey] {
232			return true
233		}
234	}
235
236	return false
237}
238
239// matchRenamedKeys attempts to match deprecated keys with missing keys.
240// Returns: renames map, unmatched deprecated, unmatched missing.
241func (m *Migrator) matchRenamedKeys(deprecated, missing []string) (map[string]string, []string, []string) {
242	renames := make(map[string]string)
243	usedDeprecated := make(map[string]bool)
244	usedMissing := make(map[string]bool)
245
246	// First pass: exact suffix matching (e.g., "foo_border_color" -> "foo_color")
247	for _, oldKey := range deprecated {
248		for _, newKey := range missing {
249			if usedMissing[newKey] {
250				continue
251			}
252
253			// Check if keys are similar (same parent path + related leaf names)
254			// AND have compatible types (don't match int keys with string keys)
255			if keysAreSimilar(oldKey, newKey) && m.typesAreCompatible(oldKey, newKey) {
256				renames[oldKey] = newKey
257				usedDeprecated[oldKey] = true
258				usedMissing[newKey] = true
259				break
260			}
261		}
262	}
263
264	// Collect unmatched
265	var unmatchedDeprecated []string
266	for _, k := range deprecated {
267		if !usedDeprecated[k] {
268			unmatchedDeprecated = append(unmatchedDeprecated, k)
269		}
270	}
271
272	var unmatchedMissing []string
273	for _, k := range missing {
274		if !usedMissing[k] {
275			unmatchedMissing = append(unmatchedMissing, k)
276		}
277	}
278
279	return renames, unmatchedDeprecated, unmatchedMissing
280}
281
282// Type name constants for migration compatibility checks.
283const (
284	typeNameInt    = "int"
285	typeNameString = "string"
286)
287
288// Config format constants.
289const (
290	configFormatTOML = "toml"
291	configFormatYAML = "yaml"
292	configFormatJSON = "json"
293	databasePathKey  = "database.path"
294)
295
296type defaultActionMap struct {
297	key     string
298	actions map[string]entity.ActionBinding
299}
300
301func (m *Migrator) defaultActionMaps() []defaultActionMap {
302	return []defaultActionMap{
303		{key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},
304		{key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},
305		{key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},
306		{key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions},
307		{key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions},
308		{key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions},
309	}
310}
311
312// typesAreCompatible checks if the default types for two keys are compatible.
313// This prevents matching int keys with string keys during rename detection.
314func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool {
315	// Get the type of the new key from defaults
316	newValue := m.defaultViper.Get(newKey)
317	if newValue == nil {
318		return false
319	}
320
321	newType := m.getTypeName(newValue)
322
323	// Infer expected old type from key name patterns
324	// Keys ending in "_width" are typically int
325	// Keys ending in "_color" are typically string
326	if strings.HasSuffix(oldKey, "_width") && newType == typeNameString {
327		return false
328	}
329	if strings.HasSuffix(oldKey, "_color") && newType == typeNameInt {
330		return false
331	}
332
333	// Additional checks based on new key patterns
334	if strings.HasSuffix(newKey, "_width") && newType != typeNameInt {
335		return false
336	}
337	if strings.HasSuffix(newKey, "_color") && newType != typeNameString {
338		return false
339	}
340
341	return true
342}
343
344// keysAreSimilar checks if two keys are likely renames of each other.
345func keysAreSimilar(oldKey, newKey string) bool {
346	oldParts := strings.Split(oldKey, ".")
347	newParts := strings.Split(newKey, ".")
348
349	// Must have same parent path (all but last part)
350	if len(oldParts) != len(newParts) {
351		return false
352	}
353	if len(oldParts) < 2 {
354		return false
355	}
356
357	// Check parent path matches
358	for i := 0; i < len(oldParts)-1; i++ {
359		if oldParts[i] != newParts[i] {
360			return false
361		}
362	}
363
364	// Check if leaf names are similar (one is substring of the other, or share a common base)
365	oldLeaf := oldParts[len(oldParts)-1]
366	newLeaf := newParts[len(newParts)-1]
367
368	// Direct substring check
369	if strings.Contains(oldLeaf, newLeaf) || strings.Contains(newLeaf, oldLeaf) {
370		return true
371	}
372
373	// Common prefix/suffix matching (e.g., "pane_mode_border_color" vs "pane_mode_color")
374	// Extract common parts by splitting on underscore
375	oldTokens := strings.Split(oldLeaf, "_")
376	newTokens := strings.Split(newLeaf, "_")
377
378	// Count matching tokens, ensuring each new token is matched at most once
379	matches := 0
380	matchedNew := make([]bool, len(newTokens))
381	for _, ot := range oldTokens {
382		for j, nt := range newTokens {
383			if ot == nt && !matchedNew[j] {
384				matches++
385				matchedNew[j] = true
386				break
387			}
388		}
389	}
390
391	// If most tokens match, consider similar
392	minTokens := min(len(newTokens), len(oldTokens))
393	return matches >= minTokens-1 && matches > 0
394}
395
396// Migrate adds missing default keys to the user's config file and removes deprecated keys.
397func (m *Migrator) Migrate() ([]string, error) {
398	changes, err := m.DetectChanges()
399	if err != nil {
400		return nil, err
401	}
402
403	if len(changes) == 0 {
404		return nil, nil
405	}
406
407	configFile, err := GetConfigFile()
408	if err != nil {
409		return nil, fmt.Errorf("failed to get config file path: %w", err)
410	}
411
412	// Read the raw config file into a nested map
413	rawConfig, err := m.readRawConfig(configFile)
414	if err != nil {
415		return nil, fmt.Errorf("failed to read config file: %w", err)
416	}
417
418	// Transform legacy action bindings (slice -> ActionBinding struct)
419	// and migrate popups -> browsing_contexts
420	transformer := NewLegacyConfigTransformer()
421	transformer.TransformLegacyActions(rawConfig)
422	transformer.TransformLegacyEngineConfig(rawConfig)
423	transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
424	m.migrateLegacyVimMode(rawConfig)
425
426	// Build sets of keys to remove and renames to apply
427	keysToRemove := make(map[string]bool)
428	renames := make(map[string]string) // oldKey -> newKey
429
430	var appliedKeys []string
431	for _, change := range changes {
432		switch change.Type {
433		case port.KeyChangeRenamed:
434			renames[change.OldKey] = change.NewKey
435			keysToRemove[change.OldKey] = true
436			appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey))
437		case port.KeyChangeAdded:
438			appliedKeys = append(appliedKeys, change.NewKey)
439		case port.KeyChangeRemoved:
440			keysToRemove[change.OldKey] = true
441			appliedKeys = append(appliedKeys, fmt.Sprintf("(removed: %s)", change.OldKey))
442		}
443	}
444
445	// Apply renames: copy value from old key to new key before removing old key
446	for oldKey, newKey := range renames {
447		if value := m.getNestedValue(rawConfig, oldKey); value != nil {
448			m.setNestedValue(rawConfig, newKey, value)
449		}
450	}
451
452	// Remove deprecated and renamed keys from the raw config.
453	for key := range keysToRemove {
454		m.deleteNestedKey(rawConfig, key)
455	}
456	m.upgradeLegacyFavoritesSidebarDefault(rawConfig)
457	m.mergeMissingDefaultActions(rawConfig)
458
459	// Create a new Viper instance with defaults for added keys
460	userViper := viper.New()
461	userViper.SetConfigFile(configFile)
462	userViper.SetConfigType(m.getConfigType(configFile))
463
464	// Set all defaults first
465	mgr := &Manager{viper: userViper}
466	mgr.setDefaults()
467
468	// Merge the cleaned raw config on top of defaults
469	if err := userViper.MergeConfigMap(rawConfig); err != nil {
470		return nil, fmt.Errorf("failed to merge config: %w", err)
471	}
472
473	// Unmarshal to Config struct for ordered writing
474	var cfg Config
475	if err := userViper.Unmarshal(&cfg); err != nil {
476		return nil, fmt.Errorf("failed to unmarshal merged config: %w", err)
477	}
478
479	// Write with ordered sections
480	if err := WriteConfigOrdered(&cfg, configFile); err != nil {
481		return nil, fmt.Errorf("failed to write config file: %w", err)
482	}
483
484	return appliedKeys, nil
485}
486
487// getConfigType returns the config type based on file extension.
488func (*Migrator) getConfigType(configFile string) string {
489	ext := strings.TrimPrefix(filepath.Ext(configFile), ".")
490	switch ext {
491	case configFormatYAML, "yml":
492		return configFormatYAML
493	case configFormatJSON:
494		return configFormatJSON
495	case configFormatTOML:
496		return configFormatTOML
497	default:
498		return configFormatTOML // default to TOML
499	}
500}
501
502// readRawConfig reads the config file into a nested map without applying defaults.
503func (m *Migrator) readRawConfig(configFile string) (map[string]any, error) {
504	data, err := os.ReadFile(configFile)
505	if err != nil {
506		return nil, err
507	}
508
509	var rawConfig map[string]any
510	configType := m.getConfigType(configFile)
511
512	switch configType {
513	case configFormatTOML:
514		if err := toml.Unmarshal(data, &rawConfig); err != nil {
515			return nil, fmt.Errorf("failed to parse TOML: %w", err)
516		}
517	case configFormatYAML:
518		if err := yaml.Unmarshal(data, &rawConfig); err != nil {
519			return nil, fmt.Errorf("failed to parse YAML: %w", err)
520		}
521	case configFormatJSON:
522		if err := json.Unmarshal(data, &rawConfig); err != nil {
523			return nil, fmt.Errorf("failed to parse JSON: %w", err)
524		}
525	default:
526		return nil, fmt.Errorf("unsupported config type: %s", configType)
527	}
528
529	return rawConfig, nil
530}
531
532// getNestedValue retrieves a value from a nested map using dot-notation key.
533func (*Migrator) getNestedValue(m map[string]any, key string) any {
534	parts := strings.Split(key, ".")
535	current := m
536
537	for i, part := range parts {
538		val, ok := current[part]
539		if !ok {
540			return nil
541		}
542
543		if i == len(parts)-1 {
544			return val
545		}
546
547		nested, ok := val.(map[string]any)
548		if !ok {
549			return nil
550		}
551		current = nested
552	}
553	return nil
554}
555
556// setNestedValue sets a value in a nested map using dot-notation key.
557func (*Migrator) setNestedValue(m map[string]any, key string, value any) {
558	parts := strings.Split(key, ".")
559	current := m
560
561	for i, part := range parts {
562		if i == len(parts)-1 {
563			current[part] = value
564			return
565		}
566
567		val, ok := current[part]
568		if !ok {
569			// Create intermediate map
570			newMap := make(map[string]any)
571			current[part] = newMap
572			current = newMap
573			continue
574		}
575
576		nested, ok := val.(map[string]any)
577		if !ok {
578			// Overwrite non-map value with a map
579			newMap := make(map[string]any)
580			current[part] = newMap
581			current = newMap
582			continue
583		}
584		current = nested
585	}
586}
587
588// deleteNestedKey removes a key from a nested map using dot-notation key.
589// It also cleans up empty parent maps.
590func (m *Migrator) deleteNestedKey(config map[string]any, key string) {
591	parts := strings.Split(key, ".")
592	m.deleteNestedKeyRecursive(config, parts)
593}
594
595// deleteNestedKeyRecursive recursively deletes a key and cleans up empty parents.
596func (m *Migrator) deleteNestedKeyRecursive(current map[string]any, parts []string) bool {
597	if len(parts) == 0 {
598		return false
599	}
600
601	part := parts[0]
602	val, ok := current[part]
603	if !ok {
604		return false
605	}
606
607	if len(parts) == 1 {
608		// This is the leaf key, delete it
609		delete(current, part)
610		return true
611	}
612
613	// Navigate deeper
614	nested, ok := val.(map[string]any)
615	if !ok {
616		return false
617	}
618
619	// Recursively delete in the nested map
620	deleted := m.deleteNestedKeyRecursive(nested, parts[1:])
621
622	// If the nested map is now empty, remove it too
623	if deleted && len(nested) == 0 {
624		delete(current, part)
625	}
626
627	return deleted
628}
629
630// GetKeyInfo returns detailed information about a config key.
631func (m *Migrator) GetKeyInfo(key string) port.KeyInfo {
632	value := m.defaultValueForKey(key)
633	if value == nil {
634		return port.KeyInfo{
635			Key:          key,
636			Type:         "unknown",
637			DefaultValue: "unknown",
638		}
639	}
640
641	return port.KeyInfo{
642		Key:          key,
643		Type:         m.getTypeName(value),
644		DefaultValue: m.formatValue(value),
645	}
646}
647
648// GetConfigFile returns the path to the user's config file.
649func (*Migrator) GetConfigFile() (string, error) {
650	return GetConfigFile()
651}
652
653func (m *Migrator) defaultValueForKey(key string) any {
654	if value := m.defaultViper.Get(key); value != nil {
655		return value
656	}
657
658	for _, actionMap := range m.defaultActionMaps() {
659		if after, ok := strings.CutPrefix(key, actionMap.key+"."); ok {
660			actionName := after
661			if action, ok := actionMap.actions[actionName]; ok {
662				return action
663			}
664		}
665	}
666
667	return nil
668}
669
670// getAllDefaultKeys returns all keys from the default configuration.
671func (m *Migrator) getAllDefaultKeys() []string {
672	keys := m.defaultViper.AllKeys()
673
674	// Filter out keys that should not be migrated
675	filtered := make([]string, 0, len(keys))
676	for _, key := range keys {
677		if m.isMigrationIgnoredDeprecatedKey(key) {
678			continue
679		}
680		filtered = append(filtered, key)
681	}
682
683	sort.Strings(filtered)
684	return filtered
685}
686
687// getUserConfigKeys parses the user's TOML file and returns all defined keys.
688func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) {
689	keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
690	if err != nil {
691		return nil, err
692	}
693
694	keys := make(map[string]bool)
695	for k := range keysWithValues {
696		keys[k] = true
697	}
698	return keys, nil
699}
700
701// getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values.
702func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) {
703	rawConfig, err := m.readRawConfig(configFile)
704	if err != nil {
705		return nil, err
706	}
707
708	// Transform legacy action bindings and popups before flattening.
709	transformer := NewLegacyConfigTransformer()
710	transformer.TransformLegacyActions(rawConfig)
711	transformer.TransformLegacyEngineConfig(rawConfig)
712	transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
713
714	// Flatten the map to dot-notation keys with values.
715	result := make(map[string]any)
716	m.flattenMapWithValues(rawConfig, "", result)
717
718	return result, nil
719}
720
721func (m *Migrator) getRawUserConfigKeysWithValues(configFile string) (map[string]any, error) {
722	rawConfig, err := m.readRawConfig(configFile)
723	if err != nil {
724		return nil, err
725	}
726
727	result := make(map[string]any)
728	m.flattenMapWithValues(rawConfig, "", result)
729	return result, nil
730}
731
732func (m *Migrator) detectLegacyFavoritesSidebarRename(userKeys map[string]any) []port.KeyChange {
733	const (
734		actionsKey = "workspace.shortcuts.actions"
735		legacyName = "toggle-favorites-systemview"
736		newName    = "toggle-favorites-sidebar"
737	)
738
739	actionsValue, ok := userKeys[actionsKey]
740	if !ok {
741		return nil
742	}
743	actions, ok := m.toStringAnyMap(actionsValue)
744	if !ok || actions[legacyName] == nil || actions[newName] != nil {
745		return nil
746	}
747
748	return []port.KeyChange{{
749		Type:     port.KeyChangeRenamed,
750		OldKey:   actionsKey + "." + legacyName,
751		NewKey:   actionsKey + "." + newName,
752		OldValue: m.formatValue(actions[legacyName]),
753		NewValue: m.formatValue(m.defaultConfig.Workspace.Shortcuts.Actions[newName]),
754	}}
755}
756
757func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any) []port.KeyChange {
758	type renamePair struct {
759		oldKey string
760		newKey string
761	}
762
763	renamePairs := []renamePair{
764		{"workspace.popups.behavior", "workspace.browsing_contexts.behavior"},
765		{"workspace.popups.placement", "workspace.browsing_contexts.placement"},
766		{"workspace.popups.open_in_new_pane", "workspace.browsing_contexts.open_in_new_pane"},
767		{"workspace.popups.follow_pane_context", "workspace.browsing_contexts.follow_pane_context"},
768		{"workspace.popups.blank_target_behavior", "workspace.browsing_contexts.blank_target_behavior"},
769		{"workspace.popups.enable_smart_detection", "workspace.browsing_contexts.enable_smart_detection"},
770		{"workspace.popups.oauth_auto_close", "workspace.browsing_contexts.oauth_auto_close"},
771	}
772
773	changes := make([]port.KeyChange, 0, len(renamePairs))
774	for _, pair := range renamePairs {
775		oldValue, hasOld := rawUserKeys[pair.oldKey]
776		if !hasOld {
777			continue
778		}
779		if _, hasNew := rawUserKeys[pair.newKey]; hasNew {
780			continue
781		}
782		changes = append(changes, port.KeyChange{
783			Type:     port.KeyChangeRenamed,
784			OldKey:   pair.oldKey,
785			NewKey:   pair.newKey,
786			OldValue: m.formatValue(oldValue),
787			NewValue: m.formatValue(m.defaultValueForKey(pair.newKey)),
788		})
789	}
790	return changes
791}
792
793func (m *Migrator) detectLegacyVimModeRenames(rawUserKeys map[string]any) []port.KeyChange {
794	changes := make([]port.KeyChange, 0, 3)
795	for oldKey, oldValue := range rawUserKeys {
796		newKey, ok := legacyVimModeKey(oldKey)
797		if !ok {
798			continue
799		}
800		if _, hasNew := rawUserKeys[newKey]; hasNew {
801			continue
802		}
803		changes = append(changes, port.KeyChange{
804			Type:     port.KeyChangeRenamed,
805			OldKey:   oldKey,
806			NewKey:   newKey,
807			OldValue: m.formatValue(oldValue),
808			NewValue: m.formatValue(m.defaultValueForKey(newKey)),
809		})
810	}
811	return changes
812}
813
814func (m *Migrator) migrateLegacyVimMode(rawConfig map[string]any) {
815	workspace, ok := m.toStringAnyMap(rawConfig["workspace"])
816	if !ok {
817		return
818	}
819	rawConfig["workspace"] = workspace
820	legacyMode, ok := m.toStringAnyMap(workspace["page_mode"])
821	if !ok {
822		return
823	}
824	workspace["page_mode"] = legacyMode
825	vimMode, ok := m.toStringAnyMap(workspace["vim_mode"])
826	if !ok {
827		vimMode = make(map[string]any)
828		workspace["vim_mode"] = vimMode
829	} else {
830		workspace["vim_mode"] = vimMode
831	}
832	if legacyActions, ok := m.toStringAnyMap(legacyMode["actions"]); ok {
833		legacyMode["actions"] = legacyActions
834	}
835	if vimActions, ok := m.toStringAnyMap(vimMode["actions"]); ok {
836		vimMode["actions"] = vimActions
837	}
838	for key, value := range legacyMode {
839		if key == "actions" {
840			continue
841		}
842		if _, exists := vimMode[key]; !exists {
843			vimMode[key] = value
844		}
845	}
846	legacyActions, _ := m.toStringAnyMap(legacyMode["actions"])
847	vimActions, _ := m.toStringAnyMap(vimMode["actions"])
848	if vimActions == nil {
849		vimActions = make(map[string]any)
850		vimMode["actions"] = vimActions
851	}
852	for oldAction, value := range legacyActions {
853		newKey, ok := legacyVimModeKey("workspace.page_mode.actions." + oldAction)
854		if !ok {
855			continue
856		}
857		newAction := strings.TrimPrefix(newKey, "workspace.vim_mode.actions.")
858		if _, exists := vimActions[newAction]; !exists {
859			vimActions[newAction] = value
860		}
861	}
862	delete(workspace, "page_mode")
863}
864
865// legacyVimModeKey converts one legacy Page Mode config key to its canonical
866// Vim Mode key, including action-name normalization.
867func legacyVimModeKey(oldKey string) (string, bool) {
868	const oldPrefix = "workspace.page_mode"
869	const newPrefix = "workspace.vim_mode"
870
871	if !strings.HasPrefix(oldKey, oldPrefix+".") {
872		return "", false
873	}
874	newKey := newPrefix + strings.TrimPrefix(oldKey, oldPrefix)
875	const actionsPrefix = newPrefix + ".actions."
876	if strings.HasPrefix(newKey, actionsPrefix) {
877		return actionsPrefix + legacyVimActionName(strings.TrimPrefix(newKey, actionsPrefix)), true
878	}
879	return newKey, true
880}
881
882func legacyVimActionName(action string) string {
883	for _, prefix := range []string{"page-scroll-", "page_scroll_"} {
884		if strings.HasPrefix(action, prefix) {
885			return "vim-scroll-" + strings.ReplaceAll(strings.TrimPrefix(action, prefix), "_", "-")
886		}
887	}
888	return action
889}
890
891// flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.
892func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {
893	for k, v := range data {
894		key := k
895		if prefix != "" {
896			key = prefix + "." + k
897		}
898
899		switch val := v.(type) {
900		case map[string]any:
901			// Check if this key represents user data (like search_shortcuts, actions)
902			// that should be treated as a single key rather than recursed into.
903			if m.isUserDataSection(key) {
904				result[key] = v
905			} else {
906				// Recurse into nested config sections
907				m.flattenMapWithValues(val, key, result)
908			}
909		default:
910			result[key] = v
911		}
912	}
913}
914
915// flattenMap recursively flattens a nested map to dot-notation keys.
916func (m *Migrator) flattenMap(data map[string]any, prefix string, keys map[string]bool) {
917	for k, v := range data {
918		key := k
919		if prefix != "" {
920			key = prefix + "." + k
921		}
922
923		switch val := v.(type) {
924		case map[string]any:
925			// Check if this key represents user data (like search_shortcuts, actions)
926			// that should be treated as a single key rather than recursed into.
927			if m.isUserDataSection(key) {
928				keys[key] = true
929			} else {
930				// Recurse into nested config sections
931				m.flattenMap(val, key, keys)
932			}
933		default:
934			keys[key] = true
935		}
936	}
937}
938
939// isUserDataSection checks if a key path represents user-defined data
940// that should not be compared key-by-key (e.g., search shortcuts, action bindings).
941func (*Migrator) isUserDataSection(keyPath string) bool {
942	// User data sections - treat entire section as one key.
943	// Some of these sections legitimately default to an empty map/table; keeping
944	// them as leaf keys makes detection agree with TOML writing.
945	switch keyPath {
946	case "search_shortcuts",
947		"workspace.floating_pane.profiles":
948		return true
949	}
950
951	// Action maps are user-customizable
952	userDataSuffixes := []string{
953		".actions", // pane_mode.actions, tab_mode.actions, etc.
954	}
955
956	for _, suffix := range userDataSuffixes {
957		if strings.HasSuffix(keyPath, suffix) {
958			return true
959		}
960	}
961
962	return false
963}
964
965// detectMissingDefaultActions finds default shortcut/modal actions not present in user config.
966func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any) []string {
967	if userKeysWithValues == nil {
968		return nil
969	}
970
971	missing := make([]string, 0)
972	for _, actionMap := range m.defaultActionMaps() {
973		userActionsValue, ok := userKeysWithValues[actionMap.key]
974		if !ok {
975			continue
976		}
977
978		userActions, ok := m.toStringAnyMap(userActionsValue)
979		if !ok || len(actionMap.actions) == 0 {
980			continue
981		}
982
983		for actionName := range actionMap.actions {
984			if _, exists := userActions[actionName]; exists {
985				continue
986			}
987			legacyActionName := strings.ReplaceAll(actionName, "-", "_")
988			if _, exists := userActions[legacyActionName]; exists {
989				continue
990			}
991			missing = append(missing, actionMap.key+"."+actionName)
992		}
993	}
994
995	sort.Strings(missing)
996	return missing
997}
998
999// mergeMissingDefaultActions injects new default shortcut/modal actions while preserving user overrides.
1000func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) {
1001	for _, actionMap := range m.defaultActionMaps() {
1002		actionsValue := m.getNestedValue(rawConfig, actionMap.key)
1003		if actionsValue == nil {
1004			continue
1005		}
1006
1007		userActions, ok := m.toStringAnyMap(actionsValue)
1008		if !ok {
1009			continue
1010		}
1011
1012		for actionName, defaultAction := range actionMap.actions {
1013			if _, exists := userActions[actionName]; exists {
1014				continue
1015			}
1016			legacyActionName := strings.ReplaceAll(actionName, "-", "_")
1017			if _, exists := userActions[legacyActionName]; exists {
1018				continue
1019			}
1020			userActions[actionName] = defaultAction
1021		}
1022
1023		m.setNestedValue(rawConfig, actionMap.key, userActions)
1024	}
1025}
1026
1027func (m *Migrator) upgradeLegacyFavoritesSidebarDefault(rawConfig map[string]any) {
1028	const (
1029		actionsKey = "workspace.shortcuts.actions"
1030		actionName = "toggle-favorites-sidebar"
1031	)
1032
1033	actionsValue := m.getNestedValue(rawConfig, actionsKey)
1034	actions, ok := m.toStringAnyMap(actionsValue)
1035	if !ok {
1036		return
1037	}
1038	binding, ok := m.actionBindingFromAny(actions[actionName])
1039	if !ok || !reflect.DeepEqual(binding, entity.ActionBinding{Keys: []string{}, Desc: "Toggle Favorites in right split"}) {
1040		return
1041	}
1042	actions[actionName] = m.defaultConfig.Workspace.Shortcuts.Actions[actionName]
1043	m.setNestedValue(rawConfig, actionsKey, actions)
1044}
1045
1046func (m *Migrator) actionBindingFromAny(value any) (entity.ActionBinding, bool) {
1047	switch v := value.(type) {
1048	case entity.ActionBinding:
1049		return v, true
1050	case map[string]any:
1051		return actionBindingFromMap(v)
1052	default:
1053		mapping, ok := m.toStringAnyMap(value)
1054		if !ok {
1055			return entity.ActionBinding{}, false
1056		}
1057		return actionBindingFromMap(mapping)
1058	}
1059}
1060
1061func actionBindingFromMap(m map[string]any) (entity.ActionBinding, bool) {
1062	var binding entity.ActionBinding
1063	if keysValue, exists := m["keys"]; exists {
1064		keys, ok := stringSliceFromAny(keysValue)
1065		if !ok {
1066			return entity.ActionBinding{}, false
1067		}
1068		binding.Keys = keys
1069	}
1070	if descValue, exists := m["desc"]; exists {
1071		desc, ok := descValue.(string)
1072		if !ok {
1073			return entity.ActionBinding{}, false
1074		}
1075		binding.Desc = desc
1076	}
1077	return binding, true
1078}
1079
1080func stringSliceFromAny(value any) ([]string, bool) {
1081	switch v := value.(type) {
1082	case []string:
1083		return append([]string(nil), v...), true
1084	case []any:
1085		out := make([]string, 0, len(v))
1086		for _, item := range v {
1087			s, ok := item.(string)
1088			if !ok {
1089				return nil, false
1090			}
1091			out = append(out, s)
1092		}
1093		return out, true
1094	default:
1095		rv := reflect.ValueOf(value)
1096		if rv.Kind() != reflect.Slice {
1097			return nil, false
1098		}
1099		out := make([]string, 0, rv.Len())
1100		for i := 0; i < rv.Len(); i++ {
1101			item := rv.Index(i).Interface()
1102			s, ok := item.(string)
1103			if !ok {
1104				return nil, false
1105			}
1106			out = append(out, s)
1107		}
1108		return out, true
1109	}
1110}
1111
1112func appendUniqueKeys(base, extra []string) []string {
1113	if len(extra) == 0 {
1114		return base
1115	}
1116
1117	seen := make(map[string]bool, len(base)+len(extra))
1118	for _, key := range base {
1119		seen[key] = true
1120	}
1121
1122	for _, key := range extra {
1123		if seen[key] {
1124			continue
1125		}
1126		base = append(base, key)
1127		seen[key] = true
1128	}
1129
1130	sort.Strings(base)
1131	return base
1132}
1133
1134func (*Migrator) isMigrationIgnoredDeprecatedKey(key string) bool {
1135	switch key {
1136	case databasePathKey:
1137		return true
1138	case "appearance.external_theme.path":
1139		// Runtime may derive this path when it is omitted, so it is not a Viper
1140		// default. It remains a valid explicit config key and should not be
1141		// removed as deprecated.
1142		return true
1143	default:
1144		return false
1145	}
1146}
1147
1148func excludeKeys(keys []string, excluded map[string]bool) []string {
1149	if len(excluded) == 0 {
1150		return keys
1151	}
1152
1153	filtered := make([]string, 0, len(keys))
1154	for _, key := range keys {
1155		if excluded[key] {
1156			continue
1157		}
1158		filtered = append(filtered, key)
1159	}
1160	return filtered
1161}
1162
1163func (*Migrator) toStringAnyMap(value any) (map[string]any, bool) {
1164	if value == nil {
1165		return nil, false
1166	}
1167
1168	if m, ok := value.(map[string]any); ok {
1169		return m, true
1170	}
1171
1172	// Some TOML/YAML parser paths yield non-map[string]any map implementations
1173	// for nested tables, so keep a reflect.Map coercion fallback here.
1174	rv := reflect.ValueOf(value)
1175	if rv.Kind() != reflect.Map {
1176		return nil, false
1177	}
1178
1179	out := make(map[string]any, rv.Len())
1180	iter := rv.MapRange()
1181	for iter.Next() {
1182		k := iter.Key()
1183		if k.Kind() != reflect.String {
1184			return nil, false
1185		}
1186		out[k.String()] = iter.Value().Interface()
1187	}
1188
1189	return out, true
1190}
1191
1192// findMissingKeys returns keys that are in defaults but not in user config.
1193func (m *Migrator) findMissingKeys(defaultKeys []string, userKeys map[string]bool) []string {
1194	missing := make([]string, 0)
1195
1196	for _, key := range defaultKeys {
1197		// Check if key or any related key exists in user config
1198		if m.keyOrRelatedExists(key, userKeys) {
1199			continue
1200		}
1201		missing = append(missing, key)
1202	}
1203
1204	// Sort for consistent output
1205	sort.Strings(missing)
1206	return missing
1207}
1208
1209// keyOrRelatedExists checks if a key, any parent, or any child keys exist in the map.
1210// This handles cases where:
1211// - User has defined a parent section but not all children
1212// - Default is a struct key but user has individual sub-keys (e.g., palettes)
1213func (*Migrator) keyOrRelatedExists(key string, keys map[string]bool) bool {
1214	if keys[key] {
1215		return true
1216	}
1217
1218	// Check if this is a child of a leaf map (like search_shortcuts.ddg)
1219	// In that case, we should check if the parent exists
1220	parts := strings.Split(key, ".")
1221	for i := len(parts) - 1; i > 0; i-- {
1222		parentKey := strings.Join(parts[:i], ".")
1223		if keys[parentKey] {
1224			// Parent exists as a leaf map, so child is implicitly defined
1225			return true
1226		}
1227	}
1228
1229	// Check if any child keys exist (e.g., default is "appearance.dark_palette"
1230	// but user has "appearance.dark_palette.background")
1231	keyPrefix := key + "."
1232	for userKey := range keys {
1233		if strings.HasPrefix(userKey, keyPrefix) {
1234			return true
1235		}
1236	}
1237
1238	return false
1239}
1240
1241// getTypeName returns a human-readable type name for a value.
1242func (*Migrator) getTypeName(value any) string {
1243	if value == nil {
1244		return "unknown"
1245	}
1246
1247	t := reflect.TypeOf(value)
1248	switch t.Kind() {
1249	case reflect.Bool:
1250		return "bool"
1251	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
1252		return "int"
1253	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
1254		return "int"
1255	case reflect.Float32, reflect.Float64:
1256		return "float"
1257	case reflect.String:
1258		return "string"
1259	case reflect.Slice:
1260		return "list"
1261	case reflect.Map:
1262		return "map"
1263	default:
1264		return t.String()
1265	}
1266}
1267
1268// formatValue returns a human-readable string representation of a value.
1269func (*Migrator) formatValue(value any) string {
1270	if value == nil {
1271		return "null"
1272	}
1273
1274	switch v := value.(type) {
1275	case bool:
1276		if v {
1277			return "true"
1278		}
1279		return "false"
1280	case int, int8, int16, int32, int64:
1281		return fmt.Sprintf("%d", v)
1282	case uint, uint8, uint16, uint32, uint64:
1283		return fmt.Sprintf("%d", v)
1284	case float32, float64:
1285		return fmt.Sprintf("%v", v)
1286	case string:
1287		if v == "" {
1288			return `""`
1289		}
1290		// Truncate long strings
1291		const maxStringLen = 50
1292		if len(v) > maxStringLen {
1293			return fmt.Sprintf("%q...", v[:maxStringLen-3])
1294		}
1295		return fmt.Sprintf("%q", v)
1296	case []any:
1297		if len(v) == 0 {
1298			return "[]"
1299		}
1300		return fmt.Sprintf("[%d items]", len(v))
1301	case map[string]any:
1302		if len(v) == 0 {
1303			return "{}"
1304		}
1305		return fmt.Sprintf("{%d entries}", len(v))
1306	default:
1307		// Handle slices of strings (common for action bindings)
1308		rv := reflect.ValueOf(value)
1309		if rv.Kind() == reflect.Slice {
1310			if rv.Len() == 0 {
1311				return "[]"
1312			}
1313			return fmt.Sprintf("[%d items]", rv.Len())
1314		}
1315		if rv.Kind() == reflect.Map {
1316			if rv.Len() == 0 {
1317				return "{}"
1318			}
1319			return fmt.Sprintf("{%d entries}", rv.Len())
1320		}
1321		return fmt.Sprintf("%v", v)
1322	}
1323}

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