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

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