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

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