v0.26.2 3cd9d2e

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

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