v0.27.1 c78ce83

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

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