v0.30.0 eb3068f

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

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