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

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