v0.30.0 eb3068f

transform_legacy.go RawHistoryFolder

1package config
2
3import "math"
4
5const (
6	sectionWorkspace = "workspace"
7	sectionSession   = "session"
8)
9
10// LegacyConfigTransformer implements port.ConfigTransformer.
11// It transforms old-format action bindings (slices) to new ActionBinding format.
12type LegacyConfigTransformer struct {
13	defaults *Config
14}
15
16// NewLegacyConfigTransformer creates a new transformer with defaults for description lookup.
17func NewLegacyConfigTransformer() *LegacyConfigTransformer {
18	return &LegacyConfigTransformer{defaults: DefaultConfig()}
19}
20
21// TransformLegacyActions converts old-format action bindings to new format.
22// This handles migration from:
23//
24//	[workspace.pane_mode.actions]
25//	"focus-left" = ["h", "arrowleft"]
26//
27// To:
28//
29//	[workspace.pane_mode.actions.focus-left]
30//	keys = ["h", "arrowleft"]
31//	desc = "Focus pane to the left"
32func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) {
33	actionPaths := [][]string{
34		{"workspace", "pane_mode", "actions"},
35		{"workspace", "tab_mode", "actions"},
36		{"workspace", "resize_mode", "actions"},
37		{"workspace", "shortcuts", "actions"},
38		{"session", "session_mode", "actions"},
39	}
40
41	for _, path := range actionPaths {
42		t.transformActionSection(rawConfig, path)
43	}
44}
45
46// TransformLegacyEngineConfig removes deprecated engine config keys.
47func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
48	adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate")
49	t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled)
50}
51
52func (t *LegacyConfigTransformer) TransformLegacyEngineConfigWithExplicitAdaptive(
53	rawConfig map[string]any,
54	hasAdaptive, adaptiveEnabled bool,
55) {
56	t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled)
57}
58
59func (*LegacyConfigTransformer) transformLegacyEngineConfig(rawConfig map[string]any, hasAdaptive, adaptiveEnabled bool) {
60	engine, ok := rawConfig["engine"].(map[string]any)
61	if !ok {
62		return
63	}
64
65	cef, ok := engine["cef"].(map[string]any)
66	if !ok {
67		return
68	}
69
70	delete(cef, "enable_context_menu_handler")
71	migrateLegacyCEFWindowlessFrameRateDefault(cef, hasAdaptive, adaptiveEnabled)
72	migrateLegacyCEFInputScrollDefaults(cef)
73}
74
75const (
76	legacyCEFScrollTouchpadMultiplierDefault = 0.35
77	configFloatTolerance                     = 0.000001
78)
79
80func migrateLegacyCEFInputScrollDefaults(cef map[string]any) {
81	input, ok := cef["input"].(map[string]any)
82	if !ok {
83		return
84	}
85
86	if legacyTouchpad, hasLegacyTouchpad := input["scroll_touchpad_multiplier"]; hasLegacyTouchpad {
87		if precise, hasPrecise := input["scroll_precise_multiplier"]; !hasPrecise {
88			input["scroll_precise_multiplier"] = migratedCEFPreciseScrollMultiplier(legacyTouchpad)
89		} else if shouldPreferLegacyTouchpadScrollMultiplier(legacyTouchpad, precise) {
90			input["scroll_precise_multiplier"] = legacyTouchpad
91		}
92		delete(input, "scroll_touchpad_multiplier")
93	}
94
95	if current, hasPrecise := input["scroll_precise_multiplier"]; hasPrecise {
96		input["scroll_precise_multiplier"] = migratedCEFPreciseScrollMultiplier(current)
97	}
98}
99
100func shouldPreferLegacyTouchpadScrollMultiplier(legacyTouchpad, precise any) bool {
101	legacyMultiplier, legacyOK := float64ConfigValue(legacyTouchpad)
102	preciseMultiplier, preciseOK := float64ConfigValue(precise)
103	if !legacyOK || !preciseOK {
104		return false
105	}
106	return !sameConfigFloat(legacyMultiplier, legacyCEFScrollTouchpadMultiplierDefault) &&
107		(sameConfigFloat(preciseMultiplier, defaultCEFScrollPreciseMultiplier) ||
108			sameConfigFloat(preciseMultiplier, legacyCEFScrollTouchpadMultiplierDefault))
109}
110
111func migratedCEFPreciseScrollMultiplier(value any) any {
112	multiplier, ok := float64ConfigValue(value)
113	if !ok || !sameConfigFloat(multiplier, legacyCEFScrollTouchpadMultiplierDefault) {
114		return value
115	}
116	return defaultCEFScrollPreciseMultiplier
117}
118
119func sameConfigFloat(a, b float64) bool {
120	return math.Abs(a-b) <= configFloatTolerance
121}
122
123func migrateLegacyCEFWindowlessFrameRateDefault(cef map[string]any, hasAdaptive, adaptiveEnabled bool) {
124	if hasAdaptive && !adaptiveEnabled {
125		return
126	}
127	frameRate, ok := int64ConfigValue(cef["windowless_frame_rate"])
128	if !ok || frameRate != defaultCEFWindowlessFrameRate {
129		return
130	}
131	cef["adaptive_windowless_frame_rate"] = true
132	cef["windowless_frame_rate"] = 0
133	if _, hasMax := cef["windowless_frame_rate_max"]; !hasMax {
134		cef["windowless_frame_rate_max"] = defaultCEFWindowlessFrameRateMax
135	}
136}
137
138func nestedConfigBoolValue(rawConfig map[string]any, path ...string) (bool, bool) {
139	current := rawConfig
140	for i, key := range path {
141		value, ok := current[key]
142		if !ok {
143			return false, false
144		}
145		if i == len(path)-1 {
146			parsed, parsedOK := value.(bool)
147			return parsed, parsedOK
148		}
149		next, ok := value.(map[string]any)
150		if !ok {
151			return false, false
152		}
153		current = next
154	}
155	return false, false
156}
157
158func float64ConfigValue(value any) (float64, bool) {
159	switch v := value.(type) {
160	case float32:
161		return float64(v), true
162	case float64:
163		return v, true
164	case int:
165		return float64(v), true
166	case int8:
167		return float64(v), true
168	case int16:
169		return float64(v), true
170	case int32:
171		return float64(v), true
172	case int64:
173		return float64(v), true
174	case uint:
175		return float64(v), true
176	case uint8:
177		return float64(v), true
178	case uint16:
179		return float64(v), true
180	case uint32:
181		return float64(v), true
182	case uint64:
183		return float64(v), true
184	default:
185		return 0, false
186	}
187}
188
189func int64ConfigValue(value any) (int64, bool) {
190	switch v := value.(type) {
191	case int:
192		return int64(v), true
193	case int8:
194		return int64(v), true
195	case int16:
196		return int64(v), true
197	case int32:
198		return int64(v), true
199	case int64:
200		return v, true
201	case uint:
202		return int64(v), true
203	case uint8:
204		return int64(v), true
205	case uint16:
206		return int64(v), true
207	case uint32:
208		return int64(v), true
209	case uint64:
210		if v > math.MaxInt64 {
211			return 0, false
212		}
213		return int64(v), true
214	default:
215		return 0, false
216	}
217}
218
219func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) {
220	// Navigate to the parent of the actions section
221	current := rawConfig
222	for _, key := range path[:len(path)-1] {
223		next, ok := current[key].(map[string]any)
224		if !ok {
225			return // Path doesn't exist
226		}
227		current = next
228	}
229
230	actionsKey := path[len(path)-1]
231	actionsRaw, ok := current[actionsKey]
232	if !ok {
233		return
234	}
235
236	actions, ok := actionsRaw.(map[string]any)
237	if !ok {
238		return
239	}
240
241	// Check each action and transform if needed
242	for actionName, actionValue := range actions {
243		// If value is a slice (old format), convert to ActionBinding format
244		if slice, ok := actionValue.([]any); ok {
245			keys := make([]string, 0, len(slice))
246			for _, v := range slice {
247				if s, ok := v.(string); ok {
248					keys = append(keys, s)
249				}
250			}
251
252			actions[actionName] = map[string]any{
253				"keys": keys,
254				"desc": t.getDefaultDesc(path, actionName),
255			}
256		}
257	}
258}
259
260// TransformLegacyPopupsToBrowsingContexts maps old workspace.popups.* keys to
261// workspace.browsing_contexts.* when the new section is absent. This allows
262// existing user configs with [workspace.popups] to be silently migrated.
263func (t *LegacyConfigTransformer) TransformLegacyPopupsToBrowsingContexts(rawConfig map[string]any) {
264	workspace, ok := rawConfig["workspace"].(map[string]any)
265	if !ok {
266		return
267	}
268
269	// Only transform if the new key does not already exist.
270	if _, hasNew := workspace["browsing_contexts"]; hasNew {
271		return
272	}
273
274	t.ForceLegacyPopupsToBrowsingContexts(rawConfig)
275}
276
277// ForceLegacyPopupsToBrowsingContexts maps old workspace.popups.* keys to
278// workspace.browsing_contexts.* even when the new section already exists in the
279// raw map. This is used by the loader because Viper's AllSettings includes
280// defaults for the new section that do not indicate user intent.
281func (*LegacyConfigTransformer) ForceLegacyPopupsToBrowsingContexts(rawConfig map[string]any) {
282	workspace, ok := rawConfig["workspace"].(map[string]any)
283	if !ok {
284		return
285	}
286
287	oldPopups, ok := workspace["popups"]
288	if !ok {
289		return
290	}
291
292	// Move the popups map to browsing_contexts and delete the old key.
293	workspace["browsing_contexts"] = oldPopups
294	delete(workspace, "popups")
295}
296
297func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string {
298	switch {
299	case path[0] == sectionWorkspace && path[1] == "pane_mode":
300		if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok {
301			return b.Desc
302		}
303	case path[0] == sectionWorkspace && path[1] == "tab_mode":
304		if b, ok := t.defaults.Workspace.TabMode.Actions[actionName]; ok {
305			return b.Desc
306		}
307	case path[0] == sectionWorkspace && path[1] == "resize_mode":
308		if b, ok := t.defaults.Workspace.ResizeMode.Actions[actionName]; ok {
309			return b.Desc
310		}
311	case path[0] == sectionWorkspace && path[1] == "shortcuts":
312		if b, ok := t.defaults.Workspace.Shortcuts.Actions[actionName]; ok {
313			return b.Desc
314		}
315	case path[0] == sectionSession && path[1] == "session_mode":
316		if b, ok := t.defaults.Session.SessionMode.Actions[actionName]; ok {
317			return b.Desc
318		}
319	}
320	return ""
321}

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