v0.33.0 62a09f5

transform_legacy.go RawHistoryFolder

1package config
2
3import (
4	"math"
5	"slices"
6	"strings"
7
8	"github.com/bnema/dumber/internal/logging"
9)
10
11const (
12	sectionWorkspace = "workspace"
13	sectionSession   = "session"
14)
15
16// LegacyConfigTransformer implements port.ConfigTransformer.
17// It transforms old-format action bindings (slices) to new ActionBinding format.
18type LegacyConfigTransformer struct {
19	defaults *Config
20}
21
22// NewLegacyConfigTransformer creates a new transformer with defaults for description lookup.
23func NewLegacyConfigTransformer() *LegacyConfigTransformer {
24	return &LegacyConfigTransformer{defaults: DefaultConfig()}
25}
26
27// legacyActionSlicePaths are the action maps that older configs wrote as plain
28// key lists. Vim Mode never had that format.
29var legacyActionSlicePaths = [][]string{
30	{"workspace", "prefix_mode", "actions"},
31	{"workspace", "pane_mode", "actions"},
32	{"workspace", "tab_mode", "actions"},
33	{"workspace", "resize_mode", "actions"},
34	{"workspace", "shortcuts", "actions"},
35	{"session", "session_mode", "actions"},
36}
37
38// actionNamePaths are all configurable action maps.
39var actionNamePaths = append(slices.Clone(legacyActionSlicePaths), []string{"workspace", "vim_mode", "actions"})
40
41// TransformLegacyActions canonicalizes action names and converts old-format
42// action bindings to the new format.
43//
44// Names: older configs spell actions with underscores (stack_pane); the
45// canonical spelling is hyphenated (stack-pane). Every action map is renamed to
46// the hyphen form so the rest of the system only ever sees one spelling. When a
47// map holds both spellings of an action, the hyphenated one wins.
48//
49// Bindings: this handles migration from:
50//
51//	[workspace.pane_mode.actions]
52//	"focus-left" = ["h", "arrowleft"]
53//
54// To:
55//
56//	[workspace.pane_mode.actions.focus-left]
57//	keys = ["h", "arrowleft"]
58//	desc = "Focus pane to the left"
59func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) {
60	for _, path := range actionNamePaths {
61		if actions, ok := rawActionSection(rawConfig, path); ok {
62			canonicalizeActionNames(actions, strings.Join(path, "."))
63		}
64	}
65	for _, path := range legacyActionSlicePaths {
66		if actions, ok := rawActionSection(rawConfig, path); ok {
67			t.transformActionSection(actions, path)
68		}
69	}
70}
71
72// canonicalActionName returns the canonical (hyphenated) spelling of an action name.
73func canonicalActionName(name string) string {
74	return strings.ReplaceAll(name, "_", "-")
75}
76
77// canonicalizeActionNames renames underscore action names to their hyphenated
78// form in place. If both spellings exist, the hyphenated entry is kept.
79func canonicalizeActionNames(actions map[string]any, section string) {
80	var legacyNames []string
81	for name := range actions {
82		if canonicalActionName(name) != name {
83			legacyNames = append(legacyNames, name)
84		}
85	}
86	slices.Sort(legacyNames)
87
88	for _, name := range legacyNames {
89		canonical := canonicalActionName(name)
90		value := actions[name]
91		delete(actions, name)
92
93		log := logging.NewFromEnv()
94		if _, exists := actions[canonical]; exists {
95			log.Debug().
96				Str("section", section).
97				Str("action", name).
98				Str("canonical", canonical).
99				Msg("config: legacy action name ignored because the canonical spelling is also configured")
100			continue
101		}
102		actions[canonical] = value
103		log.Debug().
104			Str("section", section).
105			Str("action", name).
106			Str("canonical", canonical).
107			Msg("config: legacy action name renamed")
108	}
109}
110
111// rawActionSection returns the raw action map at path, if the path exists and
112// holds a table.
113func rawActionSection(rawConfig map[string]any, path []string) (map[string]any, bool) {
114	current := rawConfig
115	for _, key := range path[:len(path)-1] {
116		next, ok := current[key].(map[string]any)
117		if !ok {
118			return nil, false
119		}
120		current = next
121	}
122	actions, ok := current[path[len(path)-1]].(map[string]any)
123	return actions, ok
124}
125
126// TransformLegacyEngineConfig removes deprecated engine config keys.
127func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
128	adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate")
129	t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled)
130}
131
132func (t *LegacyConfigTransformer) TransformLegacyEngineConfigWithExplicitAdaptive(
133	rawConfig map[string]any,
134	hasAdaptive, adaptiveEnabled bool,
135) {
136	t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled)
137}
138
139func (*LegacyConfigTransformer) transformLegacyEngineConfig(rawConfig map[string]any, hasAdaptive, adaptiveEnabled bool) {
140	engine, ok := rawConfig["engine"].(map[string]any)
141	if !ok {
142		return
143	}
144
145	cef, ok := engine["cef"].(map[string]any)
146	if !ok {
147		return
148	}
149
150	delete(cef, "enable_context_menu_handler")
151	migrateLegacyCEFWindowlessFrameRateDefault(cef, hasAdaptive, adaptiveEnabled)
152	migrateLegacyCEFInputScrollDefaults(cef)
153}
154
155const (
156	legacyCEFScrollTouchpadMultiplierDefault = 0.35
157	configFloatTolerance                     = 0.000001
158)
159
160func migrateLegacyCEFInputScrollDefaults(cef map[string]any) {
161	input, ok := cef["input"].(map[string]any)
162	if !ok {
163		return
164	}
165
166	if legacyTouchpad, hasLegacyTouchpad := input["scroll_touchpad_multiplier"]; hasLegacyTouchpad {
167		if precise, hasPrecise := input["scroll_precise_multiplier"]; !hasPrecise {
168			input["scroll_precise_multiplier"] = migratedCEFPreciseScrollMultiplier(legacyTouchpad)
169		} else if shouldPreferLegacyTouchpadScrollMultiplier(legacyTouchpad, precise) {
170			input["scroll_precise_multiplier"] = legacyTouchpad
171		}
172		delete(input, "scroll_touchpad_multiplier")
173	}
174
175	if current, hasPrecise := input["scroll_precise_multiplier"]; hasPrecise {
176		input["scroll_precise_multiplier"] = migratedCEFPreciseScrollMultiplier(current)
177	}
178}
179
180func shouldPreferLegacyTouchpadScrollMultiplier(legacyTouchpad, precise any) bool {
181	legacyMultiplier, legacyOK := float64ConfigValue(legacyTouchpad)
182	preciseMultiplier, preciseOK := float64ConfigValue(precise)
183	if !legacyOK || !preciseOK {
184		return false
185	}
186	return !sameConfigFloat(legacyMultiplier, legacyCEFScrollTouchpadMultiplierDefault) &&
187		(sameConfigFloat(preciseMultiplier, defaultCEFScrollPreciseMultiplier) ||
188			sameConfigFloat(preciseMultiplier, legacyCEFScrollTouchpadMultiplierDefault))
189}
190
191func migratedCEFPreciseScrollMultiplier(value any) any {
192	multiplier, ok := float64ConfigValue(value)
193	if !ok || !sameConfigFloat(multiplier, legacyCEFScrollTouchpadMultiplierDefault) {
194		return value
195	}
196	return defaultCEFScrollPreciseMultiplier
197}
198
199func sameConfigFloat(a, b float64) bool {
200	return math.Abs(a-b) <= configFloatTolerance
201}
202
203func migrateLegacyCEFWindowlessFrameRateDefault(cef map[string]any, hasAdaptive, adaptiveEnabled bool) {
204	if hasAdaptive && !adaptiveEnabled {
205		return
206	}
207	frameRate, ok := int64ConfigValue(cef["windowless_frame_rate"])
208	if !ok || frameRate != defaultCEFWindowlessFrameRate {
209		return
210	}
211	cef["adaptive_windowless_frame_rate"] = true
212	cef["windowless_frame_rate"] = 0
213	if _, hasMax := cef["windowless_frame_rate_max"]; !hasMax {
214		cef["windowless_frame_rate_max"] = defaultCEFWindowlessFrameRateMax
215	}
216}
217
218func nestedConfigBoolValue(rawConfig map[string]any, path ...string) (bool, bool) {
219	current := rawConfig
220	for i, key := range path {
221		value, ok := current[key]
222		if !ok {
223			return false, false
224		}
225		if i == len(path)-1 {
226			parsed, parsedOK := value.(bool)
227			return parsed, parsedOK
228		}
229		next, ok := value.(map[string]any)
230		if !ok {
231			return false, false
232		}
233		current = next
234	}
235	return false, false
236}
237
238func float64ConfigValue(value any) (float64, bool) {
239	switch v := value.(type) {
240	case float32:
241		return float64(v), true
242	case float64:
243		return v, true
244	case int:
245		return float64(v), true
246	case int8:
247		return float64(v), true
248	case int16:
249		return float64(v), true
250	case int32:
251		return float64(v), true
252	case int64:
253		return float64(v), true
254	case uint:
255		return float64(v), true
256	case uint8:
257		return float64(v), true
258	case uint16:
259		return float64(v), true
260	case uint32:
261		return float64(v), true
262	case uint64:
263		return float64(v), true
264	default:
265		return 0, false
266	}
267}
268
269func int64ConfigValue(value any) (int64, bool) {
270	switch v := value.(type) {
271	case int:
272		return int64(v), true
273	case int8:
274		return int64(v), true
275	case int16:
276		return int64(v), true
277	case int32:
278		return int64(v), true
279	case int64:
280		return v, true
281	case uint:
282		return int64(v), true
283	case uint8:
284		return int64(v), true
285	case uint16:
286		return int64(v), true
287	case uint32:
288		return int64(v), true
289	case uint64:
290		if v > math.MaxInt64 {
291			return 0, false
292		}
293		return int64(v), true
294	default:
295		return 0, false
296	}
297}
298
299// transformActionSection converts old-format (key list) bindings of one action
300// map to the ActionBinding format.
301func (t *LegacyConfigTransformer) transformActionSection(actions map[string]any, path []string) {
302	for actionName, actionValue := range actions {
303		// If value is a slice (old format), convert to ActionBinding format
304		if slice, ok := actionValue.([]any); ok {
305			keys := make([]string, 0, len(slice))
306			for _, v := range slice {
307				if s, ok := v.(string); ok {
308					keys = append(keys, s)
309				}
310			}
311
312			actions[actionName] = map[string]any{
313				"keys": keys,
314				"desc": t.getDefaultDesc(path, actionName),
315			}
316		}
317	}
318}
319
320// TransformLegacyPopupsToBrowsingContexts maps old workspace.popups.* keys to
321// workspace.browsing_contexts.* when the new section is absent. This allows
322// existing user configs with [workspace.popups] to be silently migrated.
323func (t *LegacyConfigTransformer) TransformLegacyPopupsToBrowsingContexts(rawConfig map[string]any) {
324	workspace, ok := rawConfig["workspace"].(map[string]any)
325	if !ok {
326		return
327	}
328
329	// Only transform if the new key does not already exist.
330	if _, hasNew := workspace["browsing_contexts"]; hasNew {
331		return
332	}
333
334	t.ForceLegacyPopupsToBrowsingContexts(rawConfig)
335}
336
337// ForceLegacyPopupsToBrowsingContexts maps old workspace.popups.* keys to
338// workspace.browsing_contexts.* even when the new section already exists in the
339// raw map. This is used by the loader because Viper's AllSettings includes
340// defaults for the new section that do not indicate user intent.
341func (*LegacyConfigTransformer) ForceLegacyPopupsToBrowsingContexts(rawConfig map[string]any) {
342	workspace, ok := rawConfig["workspace"].(map[string]any)
343	if !ok {
344		return
345	}
346
347	oldPopups, ok := workspace["popups"]
348	if !ok {
349		return
350	}
351
352	// Move the popups map to browsing_contexts and delete the old key.
353	workspace["browsing_contexts"] = oldPopups
354	delete(workspace, "popups")
355}
356
357func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string {
358	switch {
359	case path[0] == sectionWorkspace && path[1] == "prefix_mode":
360		if b, ok := t.defaults.Workspace.PrefixMode.Actions[actionName]; ok {
361			return b.Desc
362		}
363	case path[0] == sectionWorkspace && path[1] == "pane_mode":
364		if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok {
365			return b.Desc
366		}
367	case path[0] == sectionWorkspace && path[1] == "tab_mode":
368		if b, ok := t.defaults.Workspace.TabMode.Actions[actionName]; ok {
369			return b.Desc
370		}
371	case path[0] == sectionWorkspace && path[1] == "resize_mode":
372		if b, ok := t.defaults.Workspace.ResizeMode.Actions[actionName]; ok {
373			return b.Desc
374		}
375	case path[0] == sectionWorkspace && path[1] == "shortcuts":
376		if b, ok := t.defaults.Workspace.Shortcuts.Actions[actionName]; ok {
377			return b.Desc
378		}
379	case path[0] == sectionSession && path[1] == "session_mode":
380		if b, ok := t.defaults.Session.SessionMode.Actions[actionName]; ok {
381			return b.Desc
382		}
383	}
384	return ""
385}

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