v0.33.0 62a09f5

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

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