v0.33.0 62a09f5

default_actions.go RawHistoryFolder

1package config
2
3import (
4	"fmt"
5	"slices"
6	"sort"
7
8	"github.com/bnema/dumber/internal/logging"
9)
10
11// actionMapSpec describes one configurable action map. It is the single source
12// of truth for the list of action maps shared by load-time completion and the
13// config migrator.
14type actionMapSpec struct {
15	// key is the dotted config path of the action map.
16	key string
17	// actions returns a pointer to the action map inside a Config.
18	actions func(*Config) *map[string]ActionBinding
19	// canonKey canonicalizes a key sequence the way the validator does for
20	// duplicate detection. nil means single key chords (see canonicalizeChordKey).
21	canonKey func(string) (string, error)
22}
23
24func actionMapSpecs() []actionMapSpec {
25	return []actionMapSpec{
26		{key: "workspace.shortcuts.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.Shortcuts.Actions }},
27		{key: "workspace.prefix_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.PrefixMode.Actions }},
28		{key: "workspace.pane_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.PaneMode.Actions }},
29		{key: "workspace.tab_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.TabMode.Actions }},
30		{
31			key:      "workspace.vim_mode.actions",
32			actions:  func(c *Config) *map[string]ActionBinding { return &c.Workspace.VimMode.Actions },
33			canonKey: canonicalizeVimModeSequenceKey,
34		},
35		{key: "workspace.resize_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.ResizeMode.Actions }},
36		{key: "session.session_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Session.SessionMode.Actions }},
37	}
38}
39
40// skippedDefaultAction is a default action not added because a user binding wins.
41type skippedDefaultAction struct {
42	name     string
43	sequence string
44	by       string
45}
46
47// missingDefaultActions returns the sorted names of default actions that the
48// user did not configure and whose key sequences are not already used by a user
49// action. userKeys maps each user action name to its keys; an action with no
50// keys (disabled) is still present, so it is never re-added. Action names are
51// expected in canonical (hyphenated) form: LegacyConfigTransformer renames
52// underscore spellings before this runs.
53func (s actionMapSpec) missingDefaultActions(
54	defaults map[string]ActionBinding,
55	userKeys map[string][]string,
56) (missing []string, skipped []skippedDefaultAction) {
57	canon := s.canonKey
58	if canon == nil {
59		canon = canonicalizeChordKey
60	}
61	canonOrRaw := func(key string) string {
62		if c, err := canon(key); err == nil {
63			return c
64		}
65		return key
66	}
67
68	used := make(map[string]string)
69	userNames := make([]string, 0, len(userKeys))
70	for name := range userKeys {
71		userNames = append(userNames, name)
72	}
73	sort.Strings(userNames)
74	for _, name := range userNames {
75		for _, key := range userKeys[name] {
76			used[canonOrRaw(key)] = name
77		}
78	}
79
80	defaultNames := make([]string, 0, len(defaults))
81	for name := range defaults {
82		defaultNames = append(defaultNames, name)
83	}
84	sort.Strings(defaultNames)
85
86	for _, name := range defaultNames {
87		if _, ok := userKeys[name]; ok {
88			continue
89		}
90		conflict := false
91		for _, key := range defaults[name].Keys {
92			sequence := canonOrRaw(key)
93			if by, ok := used[sequence]; ok {
94				skipped = append(skipped, skippedDefaultAction{name: name, sequence: sequence, by: by})
95				conflict = true
96				break
97			}
98		}
99		if !conflict {
100			missing = append(missing, name)
101		}
102	}
103	return missing, skipped
104}
105
106// completeDefaultActions fills every action map of cfg with the default
107// actions the user did not configure. User actions always win: a default is
108// skipped when one of its key sequences is already used by a user action in the
109// same map. Maps are replaced, never mutated, so copies of cfg sharing them are
110// unaffected. The config file is not touched.
111func completeDefaultActions(cfg *Config) {
112	if cfg == nil {
113		return
114	}
115	defaults := DefaultConfig()
116	var skippedAll []string
117	for _, spec := range actionMapSpecs() {
118		userActions := *spec.actions(cfg)
119		userKeys := make(map[string][]string, len(userActions))
120		for name, binding := range userActions {
121			userKeys[name] = binding.Keys
122		}
123
124		defaultActions := *spec.actions(defaults)
125		missing, skipped := spec.missingDefaultActions(defaultActions, userKeys)
126
127		merged := make(map[string]ActionBinding, len(userActions)+len(missing))
128		for name, binding := range userActions {
129			merged[name] = binding
130		}
131		for _, name := range missing {
132			binding := defaultActions[name]
133			binding.Keys = slices.Clone(binding.Keys)
134			merged[name] = binding
135		}
136		*spec.actions(cfg) = merged
137
138		for _, s := range skipped {
139			skippedAll = append(skippedAll, fmt.Sprintf("%s.%s (key '%s' used by %s)", spec.key, s.name, s.sequence, s.by))
140		}
141	}
142	if len(skippedAll) > 0 {
143		log := logging.NewFromEnv()
144		log.Debug().Strs("skipped", skippedAll).Msg("config: default actions not added because user bindings take precedence")
145	}
146}

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