v0.23.0 d05d132

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	// Build sets of keys to remove and renames to apply
375	keysToRemove := make(map[string]bool)
376	renames := make(map[string]string) // oldKey -> newKey
377
378	var appliedKeys []string
379	for _, change := range changes {
380		switch change.Type {
381		case port.KeyChangeRenamed:
382			renames[change.OldKey] = change.NewKey
383			keysToRemove[change.OldKey] = true
384			appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey))
385		case port.KeyChangeAdded:
386			appliedKeys = append(appliedKeys, change.NewKey)
387		case port.KeyChangeRemoved:
388			keysToRemove[change.OldKey] = true
389			appliedKeys = append(appliedKeys, fmt.Sprintf("(removed: %s)", change.OldKey))
390		}
391	}
392
393	// Apply renames: copy value from old key to new key before removing old key
394	for oldKey, newKey := range renames {
395		if value := m.getNestedValue(rawConfig, oldKey); value != nil {
396			m.setNestedValue(rawConfig, newKey, value)
397		}
398	}
399
400	// Remove deprecated and renamed keys from the raw config
401	for key := range keysToRemove {
402		m.deleteNestedKey(rawConfig, key)
403	}
404
405	// Create a new Viper instance with defaults for added keys
406	userViper := viper.New()
407	userViper.SetConfigFile(configFile)
408	userViper.SetConfigType(m.getConfigType(configFile))
409
410	// Set all defaults first
411	mgr := &Manager{viper: userViper}
412	mgr.setDefaults()
413
414	// Merge the cleaned raw config on top of defaults
415	if err := userViper.MergeConfigMap(rawConfig); err != nil {
416		return nil, fmt.Errorf("failed to merge config: %w", err)
417	}
418
419	// Write the merged config back
420	if err := userViper.WriteConfig(); err != nil {
421		return nil, fmt.Errorf("failed to write config file: %w", err)
422	}
423
424	return appliedKeys, nil
425}
426
427// getConfigType returns the config type based on file extension.
428func (*Migrator) getConfigType(configFile string) string {
429	ext := strings.TrimPrefix(filepath.Ext(configFile), ".")
430	switch ext {
431	case configFormatYAML, "yml":
432		return configFormatYAML
433	case configFormatJSON:
434		return configFormatJSON
435	case configFormatTOML:
436		return configFormatTOML
437	default:
438		return configFormatTOML // default to TOML
439	}
440}
441
442// readRawConfig reads the config file into a nested map without applying defaults.
443func (m *Migrator) readRawConfig(configFile string) (map[string]any, error) {
444	data, err := os.ReadFile(configFile)
445	if err != nil {
446		return nil, err
447	}
448
449	var rawConfig map[string]any
450	configType := m.getConfigType(configFile)
451
452	switch configType {
453	case configFormatTOML:
454		if err := toml.Unmarshal(data, &rawConfig); err != nil {
455			return nil, fmt.Errorf("failed to parse TOML: %w", err)
456		}
457	case configFormatYAML:
458		if err := yaml.Unmarshal(data, &rawConfig); err != nil {
459			return nil, fmt.Errorf("failed to parse YAML: %w", err)
460		}
461	case configFormatJSON:
462		if err := json.Unmarshal(data, &rawConfig); err != nil {
463			return nil, fmt.Errorf("failed to parse JSON: %w", err)
464		}
465	default:
466		return nil, fmt.Errorf("unsupported config type: %s", configType)
467	}
468
469	return rawConfig, nil
470}
471
472// getNestedValue retrieves a value from a nested map using dot-notation key.
473func (*Migrator) getNestedValue(m map[string]any, key string) any {
474	parts := strings.Split(key, ".")
475	current := m
476
477	for i, part := range parts {
478		val, ok := current[part]
479		if !ok {
480			return nil
481		}
482
483		if i == len(parts)-1 {
484			return val
485		}
486
487		nested, ok := val.(map[string]any)
488		if !ok {
489			return nil
490		}
491		current = nested
492	}
493	return nil
494}
495
496// setNestedValue sets a value in a nested map using dot-notation key.
497func (*Migrator) setNestedValue(m map[string]any, key string, value any) {
498	parts := strings.Split(key, ".")
499	current := m
500
501	for i, part := range parts {
502		if i == len(parts)-1 {
503			current[part] = value
504			return
505		}
506
507		val, ok := current[part]
508		if !ok {
509			// Create intermediate map
510			newMap := make(map[string]any)
511			current[part] = newMap
512			current = newMap
513			continue
514		}
515
516		nested, ok := val.(map[string]any)
517		if !ok {
518			// Overwrite non-map value with a map
519			newMap := make(map[string]any)
520			current[part] = newMap
521			current = newMap
522			continue
523		}
524		current = nested
525	}
526}
527
528// deleteNestedKey removes a key from a nested map using dot-notation key.
529// It also cleans up empty parent maps.
530func (m *Migrator) deleteNestedKey(config map[string]any, key string) {
531	parts := strings.Split(key, ".")
532	m.deleteNestedKeyRecursive(config, parts)
533}
534
535// deleteNestedKeyRecursive recursively deletes a key and cleans up empty parents.
536func (m *Migrator) deleteNestedKeyRecursive(current map[string]any, parts []string) bool {
537	if len(parts) == 0 {
538		return false
539	}
540
541	part := parts[0]
542	val, ok := current[part]
543	if !ok {
544		return false
545	}
546
547	if len(parts) == 1 {
548		// This is the leaf key, delete it
549		delete(current, part)
550		return true
551	}
552
553	// Navigate deeper
554	nested, ok := val.(map[string]any)
555	if !ok {
556		return false
557	}
558
559	// Recursively delete in the nested map
560	deleted := m.deleteNestedKeyRecursive(nested, parts[1:])
561
562	// If the nested map is now empty, remove it too
563	if deleted && len(nested) == 0 {
564		delete(current, part)
565	}
566
567	return deleted
568}
569
570// GetKeyInfo returns detailed information about a config key.
571func (m *Migrator) GetKeyInfo(key string) port.KeyInfo {
572	value := m.defaultViper.Get(key)
573	if value == nil {
574		return port.KeyInfo{
575			Key:          key,
576			Type:         "unknown",
577			DefaultValue: "unknown",
578		}
579	}
580
581	return port.KeyInfo{
582		Key:          key,
583		Type:         m.getTypeName(value),
584		DefaultValue: m.formatValue(value),
585	}
586}
587
588// GetConfigFile returns the path to the user's config file.
589func (*Migrator) GetConfigFile() (string, error) {
590	return GetConfigFile()
591}
592
593// getAllDefaultKeys returns all keys from the default configuration.
594func (m *Migrator) getAllDefaultKeys() []string {
595	keys := m.defaultViper.AllKeys()
596
597	// Filter out keys that should not be migrated
598	filtered := make([]string, 0, len(keys))
599	for _, key := range keys {
600		// Skip database.path as it's set dynamically
601		if key == "database.path" {
602			continue
603		}
604		filtered = append(filtered, key)
605	}
606
607	sort.Strings(filtered)
608	return filtered
609}
610
611// getUserConfigKeys parses the user's TOML file and returns all defined keys.
612func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) {
613	keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
614	if err != nil {
615		return nil, err
616	}
617
618	keys := make(map[string]bool)
619	for k := range keysWithValues {
620		keys[k] = true
621	}
622	return keys, nil
623}
624
625// getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values.
626func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) {
627	data, err := os.ReadFile(configFile)
628	if err != nil {
629		return nil, fmt.Errorf("failed to read config file: %w", err)
630	}
631
632	// Parse TOML into a generic map
633	var rawConfig map[string]any
634	if err := toml.Unmarshal(data, &rawConfig); err != nil {
635		return nil, fmt.Errorf("failed to parse TOML: %w", err)
636	}
637
638	// Flatten the map to dot-notation keys with values
639	result := make(map[string]any)
640	m.flattenMapWithValues(rawConfig, "", result)
641
642	return result, nil
643}
644
645// flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.
646func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {
647	for k, v := range data {
648		key := k
649		if prefix != "" {
650			key = prefix + "." + k
651		}
652
653		switch val := v.(type) {
654		case map[string]any:
655			// Check if this key represents user data (like search_shortcuts, actions)
656			// that should be treated as a single key rather than recursed into.
657			if m.isUserDataSection(key) {
658				result[key] = v
659			} else {
660				// Recurse into nested config sections
661				m.flattenMapWithValues(val, key, result)
662			}
663		default:
664			result[key] = v
665		}
666	}
667}
668
669// flattenMap recursively flattens a nested map to dot-notation keys.
670func (m *Migrator) flattenMap(data map[string]any, prefix string, keys map[string]bool) {
671	for k, v := range data {
672		key := k
673		if prefix != "" {
674			key = prefix + "." + k
675		}
676
677		switch val := v.(type) {
678		case map[string]any:
679			// Check if this key represents user data (like search_shortcuts, actions)
680			// that should be treated as a single key rather than recursed into.
681			if m.isUserDataSection(key) {
682				keys[key] = true
683			} else {
684				// Recurse into nested config sections
685				m.flattenMap(val, key, keys)
686			}
687		default:
688			keys[key] = true
689		}
690	}
691}
692
693// isUserDataSection checks if a key path represents user-defined data
694// that should not be compared key-by-key (e.g., search shortcuts, action bindings).
695func (*Migrator) isUserDataSection(keyPath string) bool {
696	// User data sections - treat entire section as one key
697	switch keyPath {
698	case "search_shortcuts":
699		return true
700	}
701
702	// Action maps are user-customizable
703	userDataSuffixes := []string{
704		".actions", // pane_mode.actions, tab_mode.actions, etc.
705	}
706
707	for _, suffix := range userDataSuffixes {
708		if strings.HasSuffix(keyPath, suffix) {
709			return true
710		}
711	}
712
713	return false
714}
715
716// findMissingKeys returns keys that are in defaults but not in user config.
717func (m *Migrator) findMissingKeys(defaultKeys []string, userKeys map[string]bool) []string {
718	missing := make([]string, 0)
719
720	for _, key := range defaultKeys {
721		// Check if key or any related key exists in user config
722		if m.keyOrRelatedExists(key, userKeys) {
723			continue
724		}
725		missing = append(missing, key)
726	}
727
728	// Sort for consistent output
729	sort.Strings(missing)
730	return missing
731}
732
733// keyOrRelatedExists checks if a key, any parent, or any child keys exist in the map.
734// This handles cases where:
735// - User has defined a parent section but not all children
736// - Default is a struct key but user has individual sub-keys (e.g., palettes)
737func (*Migrator) keyOrRelatedExists(key string, keys map[string]bool) bool {
738	if keys[key] {
739		return true
740	}
741
742	// Check if this is a child of a leaf map (like search_shortcuts.ddg)
743	// In that case, we should check if the parent exists
744	parts := strings.Split(key, ".")
745	for i := len(parts) - 1; i > 0; i-- {
746		parentKey := strings.Join(parts[:i], ".")
747		if keys[parentKey] {
748			// Parent exists as a leaf map, so child is implicitly defined
749			return true
750		}
751	}
752
753	// Check if any child keys exist (e.g., default is "appearance.dark_palette"
754	// but user has "appearance.dark_palette.background")
755	keyPrefix := key + "."
756	for userKey := range keys {
757		if strings.HasPrefix(userKey, keyPrefix) {
758			return true
759		}
760	}
761
762	return false
763}
764
765// getTypeName returns a human-readable type name for a value.
766func (*Migrator) getTypeName(value any) string {
767	if value == nil {
768		return "unknown"
769	}
770
771	t := reflect.TypeOf(value)
772	switch t.Kind() {
773	case reflect.Bool:
774		return "bool"
775	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
776		return "int"
777	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
778		return "int"
779	case reflect.Float32, reflect.Float64:
780		return "float"
781	case reflect.String:
782		return "string"
783	case reflect.Slice:
784		return "list"
785	case reflect.Map:
786		return "map"
787	default:
788		return t.String()
789	}
790}
791
792// formatValue returns a human-readable string representation of a value.
793func (*Migrator) formatValue(value any) string {
794	if value == nil {
795		return "null"
796	}
797
798	switch v := value.(type) {
799	case bool:
800		if v {
801			return "true"
802		}
803		return "false"
804	case int, int8, int16, int32, int64:
805		return fmt.Sprintf("%d", v)
806	case uint, uint8, uint16, uint32, uint64:
807		return fmt.Sprintf("%d", v)
808	case float32, float64:
809		return fmt.Sprintf("%v", v)
810	case string:
811		if v == "" {
812			return `""`
813		}
814		// Truncate long strings
815		const maxStringLen = 50
816		if len(v) > maxStringLen {
817			return fmt.Sprintf("%q...", v[:maxStringLen-3])
818		}
819		return fmt.Sprintf("%q", v)
820	case []any:
821		if len(v) == 0 {
822			return "[]"
823		}
824		return fmt.Sprintf("[%d items]", len(v))
825	case map[string]any:
826		if len(v) == 0 {
827			return "{}"
828		}
829		return fmt.Sprintf("{%d entries}", len(v))
830	default:
831		// Handle slices of strings (common for action bindings)
832		rv := reflect.ValueOf(value)
833		if rv.Kind() == reflect.Slice {
834			if rv.Len() == 0 {
835				return "[]"
836			}
837			return fmt.Sprintf("[%d items]", rv.Len())
838		}
839		if rv.Kind() == reflect.Map {
840			if rv.Len() == 0 {
841				return "{}"
842			}
843			return fmt.Sprintf("{%d entries}", rv.Len())
844		}
845		return fmt.Sprintf("%v", v)
846	}
847}

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