detached a2e8a81

wip: legacy config migration and keybinding modal fix

- Add ConfigTransformer port interface for legacy config transformation
- Add TransformLegacyConfigUseCase for clean architecture
- Add LegacyConfigTransformer implementation with tests
- Integrate transformer in loader.go and migrate.go
- Fix alert-dialog-action/cancel to properly pass onclick and children
- Add debug logging to trace message routing issues
commit
a2e8a81935df2c5450b2b3dbcb60dbc166e885ab
author
bnema · · 2026-01-11 17:37 UTC
committer
brice · · 2026-01-24 06:23 UTC
parent
64e8895
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +504 -9
M .mockery.yaml +1 -0
@@ -10,6 +10,7 @@ packages:
1010 interfaces:
1111 ConfigMigrator: {}
1212 ConfigSchemaProvider: {}
13+ ConfigTransformer: {}
1314 DatabaseProvider: {}
1415 DiffFormatter: {}
1516 SessionLogger: {}
@@ -10,6 +10,7 @@ packages:
10 interfaces:10 interfaces:
11 ConfigMigrator: {}11 ConfigMigrator: {}
12 ConfigSchemaProvider: {}12 ConfigSchemaProvider: {}
13+ ConfigTransformer: {}
13 DatabaseProvider: {}14 DatabaseProvider: {}
14 DiffFormatter: {}15 DiffFormatter: {}
15 SessionLogger: {}16 SessionLogger: {}
A internal/application/port/config_transformer.go +9 -0
@@ -0,0 +1,9 @@
1+package port
2+
3+// ConfigTransformer transforms legacy config formats to current format.
4+type ConfigTransformer interface {
5+ // TransformLegacyActions converts old-format action bindings (slices)
6+ // to new ActionBinding format (map with keys and desc).
7+ // Modifies the rawConfig map in place.
8+ TransformLegacyActions(rawConfig map[string]any)
9+}
@@ -0,0 +1,9 @@
1+package port
2+
3+// ConfigTransformer transforms legacy config formats to current format.
4+type ConfigTransformer interface {
5+ // TransformLegacyActions converts old-format action bindings (slices)
6+ // to new ActionBinding format (map with keys and desc).
7+ // Modifies the rawConfig map in place.
8+ TransformLegacyActions(rawConfig map[string]any)
9+}
M internal/application/port/mocks/mocks.go +67 -0
@@ -1037,6 +1037,73 @@ func (_c *MockConfigSchemaProvider_GetSchema_Call) RunAndReturn(run func() []ent
10371037 return _c
10381038 }
10391039
1040+// NewMockConfigTransformer creates a new instance of MockConfigTransformer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1041+// The first argument is typically a *testing.T value.
1042+func NewMockConfigTransformer(t interface {
1043+ mock.TestingT
1044+ Cleanup(func())
1045+}) *MockConfigTransformer {
1046+ mock := &MockConfigTransformer{}
1047+ mock.Mock.Test(t)
1048+
1049+ t.Cleanup(func() { mock.AssertExpectations(t) })
1050+
1051+ return mock
1052+}
1053+
1054+// MockConfigTransformer is an autogenerated mock type for the ConfigTransformer type
1055+type MockConfigTransformer struct {
1056+ mock.Mock
1057+}
1058+
1059+type MockConfigTransformer_Expecter struct {
1060+ mock *mock.Mock
1061+}
1062+
1063+func (_m *MockConfigTransformer) EXPECT() *MockConfigTransformer_Expecter {
1064+ return &MockConfigTransformer_Expecter{mock: &_m.Mock}
1065+}
1066+
1067+// TransformLegacyActions provides a mock function for the type MockConfigTransformer
1068+func (_mock *MockConfigTransformer) TransformLegacyActions(rawConfig map[string]any) {
1069+ _mock.Called(rawConfig)
1070+ return
1071+}
1072+
1073+// MockConfigTransformer_TransformLegacyActions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransformLegacyActions'
1074+type MockConfigTransformer_TransformLegacyActions_Call struct {
1075+ *mock.Call
1076+}
1077+
1078+// TransformLegacyActions is a helper method to define mock.On call
1079+// - rawConfig map[string]any
1080+func (_e *MockConfigTransformer_Expecter) TransformLegacyActions(rawConfig interface{}) *MockConfigTransformer_TransformLegacyActions_Call {
1081+ return &MockConfigTransformer_TransformLegacyActions_Call{Call: _e.mock.On("TransformLegacyActions", rawConfig)}
1082+}
1083+
1084+func (_c *MockConfigTransformer_TransformLegacyActions_Call) Run(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call {
1085+ _c.Call.Run(func(args mock.Arguments) {
1086+ var arg0 map[string]any
1087+ if args[0] != nil {
1088+ arg0 = args[0].(map[string]any)
1089+ }
1090+ run(
1091+ arg0,
1092+ )
1093+ })
1094+ return _c
1095+}
1096+
1097+func (_c *MockConfigTransformer_TransformLegacyActions_Call) Return() *MockConfigTransformer_TransformLegacyActions_Call {
1098+ _c.Call.Return()
1099+ return _c
1100+}
1101+
1102+func (_c *MockConfigTransformer_TransformLegacyActions_Call) RunAndReturn(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call {
1103+ _c.Run(run)
1104+ return _c
1105+}
1106+
10401107 // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
10411108 // The first argument is typically a *testing.T value.
10421109 func NewMockDatabaseProvider(t interface {
@@ -1037,6 +1037,73 @@ func (_c *MockConfigSchemaProvider_GetSchema_Call) RunAndReturn(run func() []ent
1037 return _c1037 return _c
1038 }1038 }
1039 1039
1040+// NewMockConfigTransformer creates a new instance of MockConfigTransformer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1041+// The first argument is typically a *testing.T value.
1042+func NewMockConfigTransformer(t interface {
1043+ mock.TestingT
1044+ Cleanup(func())
1045+}) *MockConfigTransformer {
1046+ mock := &MockConfigTransformer{}
1047+ mock.Mock.Test(t)
1048+
1049+ t.Cleanup(func() { mock.AssertExpectations(t) })
1050+
1051+ return mock
1052+}
1053+
1054+// MockConfigTransformer is an autogenerated mock type for the ConfigTransformer type
1055+type MockConfigTransformer struct {
1056+ mock.Mock
1057+}
1058+
1059+type MockConfigTransformer_Expecter struct {
1060+ mock *mock.Mock
1061+}
1062+
1063+func (_m *MockConfigTransformer) EXPECT() *MockConfigTransformer_Expecter {
1064+ return &MockConfigTransformer_Expecter{mock: &_m.Mock}
1065+}
1066+
1067+// TransformLegacyActions provides a mock function for the type MockConfigTransformer
1068+func (_mock *MockConfigTransformer) TransformLegacyActions(rawConfig map[string]any) {
1069+ _mock.Called(rawConfig)
1070+ return
1071+}
1072+
1073+// MockConfigTransformer_TransformLegacyActions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransformLegacyActions'
1074+type MockConfigTransformer_TransformLegacyActions_Call struct {
1075+ *mock.Call
1076+}
1077+
1078+// TransformLegacyActions is a helper method to define mock.On call
1079+// - rawConfig map[string]any
1080+func (_e *MockConfigTransformer_Expecter) TransformLegacyActions(rawConfig interface{}) *MockConfigTransformer_TransformLegacyActions_Call {
1081+ return &MockConfigTransformer_TransformLegacyActions_Call{Call: _e.mock.On("TransformLegacyActions", rawConfig)}
1082+}
1083+
1084+func (_c *MockConfigTransformer_TransformLegacyActions_Call) Run(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call {
1085+ _c.Call.Run(func(args mock.Arguments) {
1086+ var arg0 map[string]any
1087+ if args[0] != nil {
1088+ arg0 = args[0].(map[string]any)
1089+ }
1090+ run(
1091+ arg0,
1092+ )
1093+ })
1094+ return _c
1095+}
1096+
1097+func (_c *MockConfigTransformer_TransformLegacyActions_Call) Return() *MockConfigTransformer_TransformLegacyActions_Call {
1098+ _c.Call.Return()
1099+ return _c
1100+}
1101+
1102+func (_c *MockConfigTransformer_TransformLegacyActions_Call) RunAndReturn(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call {
1103+ _c.Run(run)
1104+ return _c
1105+}
1106+
1040 // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1107 // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1041 // The first argument is typically a *testing.T value.1108 // The first argument is typically a *testing.T value.
1042 func NewMockDatabaseProvider(t interface {1109 func NewMockDatabaseProvider(t interface {
A internal/application/usecase/transform_legacy_config.go +18 -0
@@ -0,0 +1,18 @@
1+package usecase
2+
3+import "github.com/bnema/dumber/internal/application/port"
4+
5+// TransformLegacyConfigUseCase handles legacy config format transformation.
6+type TransformLegacyConfigUseCase struct {
7+ transformer port.ConfigTransformer
8+}
9+
10+// NewTransformLegacyConfigUseCase creates a new use case instance.
11+func NewTransformLegacyConfigUseCase(transformer port.ConfigTransformer) *TransformLegacyConfigUseCase {
12+ return &TransformLegacyConfigUseCase{transformer: transformer}
13+}
14+
15+// Execute transforms legacy config format in place.
16+func (uc *TransformLegacyConfigUseCase) Execute(rawConfig map[string]any) {
17+ uc.transformer.TransformLegacyActions(rawConfig)
18+}
@@ -0,0 +1,18 @@
1+package usecase
2+
3+import "github.com/bnema/dumber/internal/application/port"
4+
5+// TransformLegacyConfigUseCase handles legacy config format transformation.
6+type TransformLegacyConfigUseCase struct {
7+ transformer port.ConfigTransformer
8+}
9+
10+// NewTransformLegacyConfigUseCase creates a new use case instance.
11+func NewTransformLegacyConfigUseCase(transformer port.ConfigTransformer) *TransformLegacyConfigUseCase {
12+ return &TransformLegacyConfigUseCase{transformer: transformer}
13+}
14+
15+// Execute transforms legacy config format in place.
16+func (uc *TransformLegacyConfigUseCase) Execute(rawConfig map[string]any) {
17+ uc.transformer.TransformLegacyActions(rawConfig)
18+}
A internal/application/usecase/transform_legacy_config_test.go +41 -0
@@ -0,0 +1,41 @@
1+package usecase
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port/mocks"
7+ "github.com/stretchr/testify/mock"
8+)
9+
10+func TestTransformLegacyConfigUseCase_Execute(t *testing.T) {
11+ mockTransformer := mocks.NewMockConfigTransformer(t)
12+
13+ rawConfig := map[string]any{"test": "data"}
14+
15+ mockTransformer.EXPECT().TransformLegacyActions(mock.Anything).Return()
16+
17+ uc := NewTransformLegacyConfigUseCase(mockTransformer)
18+ uc.Execute(rawConfig)
19+
20+ // AssertExpectations is called automatically via t.Cleanup
21+}
22+
23+func TestTransformLegacyConfigUseCase_Execute_CallsTransformer(t *testing.T) {
24+ mockTransformer := mocks.NewMockConfigTransformer(t)
25+
26+ rawConfig := map[string]any{
27+ "workspace": map[string]any{
28+ "pane_mode": map[string]any{
29+ "actions": map[string]any{
30+ "focus-left": []any{"h"},
31+ },
32+ },
33+ },
34+ }
35+
36+ // Verify the transformer receives the exact rawConfig
37+ mockTransformer.EXPECT().TransformLegacyActions(rawConfig).Return()
38+
39+ uc := NewTransformLegacyConfigUseCase(mockTransformer)
40+ uc.Execute(rawConfig)
41+}
@@ -0,0 +1,41 @@
1+package usecase
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port/mocks"
7+ "github.com/stretchr/testify/mock"
8+)
9+
10+func TestTransformLegacyConfigUseCase_Execute(t *testing.T) {
11+ mockTransformer := mocks.NewMockConfigTransformer(t)
12+
13+ rawConfig := map[string]any{"test": "data"}
14+
15+ mockTransformer.EXPECT().TransformLegacyActions(mock.Anything).Return()
16+
17+ uc := NewTransformLegacyConfigUseCase(mockTransformer)
18+ uc.Execute(rawConfig)
19+
20+ // AssertExpectations is called automatically via t.Cleanup
21+}
22+
23+func TestTransformLegacyConfigUseCase_Execute_CallsTransformer(t *testing.T) {
24+ mockTransformer := mocks.NewMockConfigTransformer(t)
25+
26+ rawConfig := map[string]any{
27+ "workspace": map[string]any{
28+ "pane_mode": map[string]any{
29+ "actions": map[string]any{
30+ "focus-left": []any{"h"},
31+ },
32+ },
33+ },
34+ }
35+
36+ // Verify the transformer receives the exact rawConfig
37+ mockTransformer.EXPECT().TransformLegacyActions(rawConfig).Return()
38+
39+ uc := NewTransformLegacyConfigUseCase(mockTransformer)
40+ uc.Execute(rawConfig)
41+}
M internal/infrastructure/config/loader.go +17 -0
@@ -130,9 +130,26 @@ func (m *Manager) readConfigFile() error {
130130 return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err)
131131 }
132132 }
133+
134+ // Transform legacy action bindings before unmarshaling
135+ m.transformLegacyActionBindings()
136+
133137 return nil
134138 }
135139
140+// transformLegacyActionBindings converts old-format action bindings to new format.
141+// This is called after reading config but before unmarshaling.
142+func (m *Manager) transformLegacyActionBindings() {
143+ rawConfig := m.viper.AllSettings()
144+ transformer := NewLegacyConfigTransformer()
145+ transformer.TransformLegacyActions(rawConfig)
146+
147+ // Apply transformed config back to viper
148+ for key, value := range rawConfig {
149+ m.viper.Set(key, value)
150+ }
151+}
152+
136153 func (m *Manager) unmarshalConfig() (*Config, error) {
137154 config := &Config{}
138155 if err := m.viper.Unmarshal(config); err != nil {
@@ -130,9 +130,26 @@ func (m *Manager) readConfigFile() error {
130 return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err)130 return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err)
131 }131 }
132 }132 }
133+
134+ // Transform legacy action bindings before unmarshaling
135+ m.transformLegacyActionBindings()
136+
133 return nil137 return nil
134 }138 }
135 139
140+// transformLegacyActionBindings converts old-format action bindings to new format.
141+// This is called after reading config but before unmarshaling.
142+func (m *Manager) transformLegacyActionBindings() {
143+ rawConfig := m.viper.AllSettings()
144+ transformer := NewLegacyConfigTransformer()
145+ transformer.TransformLegacyActions(rawConfig)
146+
147+ // Apply transformed config back to viper
148+ for key, value := range rawConfig {
149+ m.viper.Set(key, value)
150+ }
151+}
152+
136 func (m *Manager) unmarshalConfig() (*Config, error) {153 func (m *Manager) unmarshalConfig() (*Config, error) {
137 config := &Config{}154 config := &Config{}
138 if err := m.viper.Unmarshal(config); err != nil {155 if err := m.viper.Unmarshal(config); err != nil {
M internal/infrastructure/config/migrate.go +8 -0
@@ -371,6 +371,10 @@ func (m *Migrator) Migrate() ([]string, error) {
371371 return nil, fmt.Errorf("failed to read config file: %w", err)
372372 }
373373
374+ // Transform legacy action bindings (slice -> ActionBinding struct)
375+ transformer := NewLegacyConfigTransformer()
376+ transformer.TransformLegacyActions(rawConfig)
377+
374378 // Build sets of keys to remove and renames to apply
375379 keysToRemove := make(map[string]bool)
376380 renames := make(map[string]string) // oldKey -> newKey
@@ -635,6 +639,10 @@ func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]an
635639 return nil, fmt.Errorf("failed to parse TOML: %w", err)
636640 }
637641
642+ // Transform legacy action bindings before flattening
643+ transformer := NewLegacyConfigTransformer()
644+ transformer.TransformLegacyActions(rawConfig)
645+
638646 // Flatten the map to dot-notation keys with values
639647 result := make(map[string]any)
640648 m.flattenMapWithValues(rawConfig, "", result)
@@ -371,6 +371,10 @@ func (m *Migrator) Migrate() ([]string, error) {
371 return nil, fmt.Errorf("failed to read config file: %w", err)371 return nil, fmt.Errorf("failed to read config file: %w", err)
372 }372 }
373 373
374+ // Transform legacy action bindings (slice -> ActionBinding struct)
375+ transformer := NewLegacyConfigTransformer()
376+ transformer.TransformLegacyActions(rawConfig)
377+
374 // Build sets of keys to remove and renames to apply378 // Build sets of keys to remove and renames to apply
375 keysToRemove := make(map[string]bool)379 keysToRemove := make(map[string]bool)
376 renames := make(map[string]string) // oldKey -> newKey380 renames := make(map[string]string) // oldKey -> newKey
@@ -635,6 +639,10 @@ func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]an
635 return nil, fmt.Errorf("failed to parse TOML: %w", err)639 return nil, fmt.Errorf("failed to parse TOML: %w", err)
636 }640 }
637 641
642+ // Transform legacy action bindings before flattening
643+ transformer := NewLegacyConfigTransformer()
644+ transformer.TransformLegacyActions(rawConfig)
645+
638 // Flatten the map to dot-notation keys with values646 // Flatten the map to dot-notation keys with values
639 result := make(map[string]any)647 result := make(map[string]any)
640 m.flattenMapWithValues(rawConfig, "", result)648 m.flattenMapWithValues(rawConfig, "", result)
A internal/infrastructure/config/transform_legacy.go +109 -0
@@ -0,0 +1,109 @@
1+package config
2+
3+const (
4+ sectionWorkspace = "workspace"
5+ sectionSession = "session"
6+)
7+
8+// LegacyConfigTransformer implements port.ConfigTransformer.
9+// It transforms old-format action bindings (slices) to new ActionBinding format.
10+type LegacyConfigTransformer struct {
11+ defaults *Config
12+}
13+
14+// NewLegacyConfigTransformer creates a new transformer with defaults for description lookup.
15+func NewLegacyConfigTransformer() *LegacyConfigTransformer {
16+ return &LegacyConfigTransformer{defaults: DefaultConfig()}
17+}
18+
19+// TransformLegacyActions converts old-format action bindings to new format.
20+// This handles migration from:
21+//
22+// [workspace.pane_mode.actions]
23+// "focus-left" = ["h", "arrowleft"]
24+//
25+// To:
26+//
27+// [workspace.pane_mode.actions.focus-left]
28+// keys = ["h", "arrowleft"]
29+// desc = "Focus pane to the left"
30+func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) {
31+ actionPaths := [][]string{
32+ {"workspace", "pane_mode", "actions"},
33+ {"workspace", "tab_mode", "actions"},
34+ {"workspace", "resize_mode", "actions"},
35+ {"workspace", "shortcuts", "actions"},
36+ {"session", "session_mode", "actions"},
37+ }
38+
39+ for _, path := range actionPaths {
40+ t.transformActionSection(rawConfig, path)
41+ }
42+}
43+
44+func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) {
45+ // Navigate to the parent of the actions section
46+ current := rawConfig
47+ for _, key := range path[:len(path)-1] {
48+ next, ok := current[key].(map[string]any)
49+ if !ok {
50+ return // Path doesn't exist
51+ }
52+ current = next
53+ }
54+
55+ actionsKey := path[len(path)-1]
56+ actionsRaw, ok := current[actionsKey]
57+ if !ok {
58+ return
59+ }
60+
61+ actions, ok := actionsRaw.(map[string]any)
62+ if !ok {
63+ return
64+ }
65+
66+ // Check each action and transform if needed
67+ for actionName, actionValue := range actions {
68+ // If value is a slice (old format), convert to ActionBinding format
69+ if slice, ok := actionValue.([]any); ok {
70+ keys := make([]string, 0, len(slice))
71+ for _, v := range slice {
72+ if s, ok := v.(string); ok {
73+ keys = append(keys, s)
74+ }
75+ }
76+
77+ actions[actionName] = map[string]any{
78+ "keys": keys,
79+ "desc": t.getDefaultDesc(path, actionName),
80+ }
81+ }
82+ }
83+}
84+
85+func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string {
86+ switch {
87+ case path[0] == sectionWorkspace && path[1] == "pane_mode":
88+ if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok {
89+ return b.Desc
90+ }
91+ case path[0] == sectionWorkspace && path[1] == "tab_mode":
92+ if b, ok := t.defaults.Workspace.TabMode.Actions[actionName]; ok {
93+ return b.Desc
94+ }
95+ case path[0] == sectionWorkspace && path[1] == "resize_mode":
96+ if b, ok := t.defaults.Workspace.ResizeMode.Actions[actionName]; ok {
97+ return b.Desc
98+ }
99+ case path[0] == sectionWorkspace && path[1] == "shortcuts":
100+ if b, ok := t.defaults.Workspace.Shortcuts.Actions[actionName]; ok {
101+ return b.Desc
102+ }
103+ case path[0] == sectionSession && path[1] == "session_mode":
104+ if b, ok := t.defaults.Session.SessionMode.Actions[actionName]; ok {
105+ return b.Desc
106+ }
107+ }
108+ return ""
109+}
@@ -0,0 +1,109 @@
1+package config
2+
3+const (
4+ sectionWorkspace = "workspace"
5+ sectionSession = "session"
6+)
7+
8+// LegacyConfigTransformer implements port.ConfigTransformer.
9+// It transforms old-format action bindings (slices) to new ActionBinding format.
10+type LegacyConfigTransformer struct {
11+ defaults *Config
12+}
13+
14+// NewLegacyConfigTransformer creates a new transformer with defaults for description lookup.
15+func NewLegacyConfigTransformer() *LegacyConfigTransformer {
16+ return &LegacyConfigTransformer{defaults: DefaultConfig()}
17+}
18+
19+// TransformLegacyActions converts old-format action bindings to new format.
20+// This handles migration from:
21+//
22+// [workspace.pane_mode.actions]
23+// "focus-left" = ["h", "arrowleft"]
24+//
25+// To:
26+//
27+// [workspace.pane_mode.actions.focus-left]
28+// keys = ["h", "arrowleft"]
29+// desc = "Focus pane to the left"
30+func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) {
31+ actionPaths := [][]string{
32+ {"workspace", "pane_mode", "actions"},
33+ {"workspace", "tab_mode", "actions"},
34+ {"workspace", "resize_mode", "actions"},
35+ {"workspace", "shortcuts", "actions"},
36+ {"session", "session_mode", "actions"},
37+ }
38+
39+ for _, path := range actionPaths {
40+ t.transformActionSection(rawConfig, path)
41+ }
42+}
43+
44+func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) {
45+ // Navigate to the parent of the actions section
46+ current := rawConfig
47+ for _, key := range path[:len(path)-1] {
48+ next, ok := current[key].(map[string]any)
49+ if !ok {
50+ return // Path doesn't exist
51+ }
52+ current = next
53+ }
54+
55+ actionsKey := path[len(path)-1]
56+ actionsRaw, ok := current[actionsKey]
57+ if !ok {
58+ return
59+ }
60+
61+ actions, ok := actionsRaw.(map[string]any)
62+ if !ok {
63+ return
64+ }
65+
66+ // Check each action and transform if needed
67+ for actionName, actionValue := range actions {
68+ // If value is a slice (old format), convert to ActionBinding format
69+ if slice, ok := actionValue.([]any); ok {
70+ keys := make([]string, 0, len(slice))
71+ for _, v := range slice {
72+ if s, ok := v.(string); ok {
73+ keys = append(keys, s)
74+ }
75+ }
76+
77+ actions[actionName] = map[string]any{
78+ "keys": keys,
79+ "desc": t.getDefaultDesc(path, actionName),
80+ }
81+ }
82+ }
83+}
84+
85+func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string {
86+ switch {
87+ case path[0] == sectionWorkspace && path[1] == "pane_mode":
88+ if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok {
89+ return b.Desc
90+ }
91+ case path[0] == sectionWorkspace && path[1] == "tab_mode":
92+ if b, ok := t.defaults.Workspace.TabMode.Actions[actionName]; ok {
93+ return b.Desc
94+ }
95+ case path[0] == sectionWorkspace && path[1] == "resize_mode":
96+ if b, ok := t.defaults.Workspace.ResizeMode.Actions[actionName]; ok {
97+ return b.Desc
98+ }
99+ case path[0] == sectionWorkspace && path[1] == "shortcuts":
100+ if b, ok := t.defaults.Workspace.Shortcuts.Actions[actionName]; ok {
101+ return b.Desc
102+ }
103+ case path[0] == sectionSession && path[1] == "session_mode":
104+ if b, ok := t.defaults.Session.SessionMode.Actions[actionName]; ok {
105+ return b.Desc
106+ }
107+ }
108+ return ""
109+}
A internal/infrastructure/config/transform_legacy_test.go +193 -0
@@ -0,0 +1,193 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestTransformLegacyActions_SliceFormat(t *testing.T) {
11+ transformer := NewLegacyConfigTransformer()
12+
13+ rawConfig := map[string]any{
14+ "workspace": map[string]any{
15+ "pane_mode": map[string]any{
16+ "actions": map[string]any{
17+ "focus-left": []any{"h", "arrowleft"},
18+ },
19+ },
20+ },
21+ }
22+
23+ transformer.TransformLegacyActions(rawConfig)
24+
25+ actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any)
26+ binding, ok := actions["focus-left"].(map[string]any)
27+ require.True(t, ok, "should be converted to map")
28+
29+ keys, ok := binding["keys"].([]string)
30+ require.True(t, ok, "keys should be []string")
31+ assert.Equal(t, []string{"h", "arrowleft"}, keys)
32+ assert.NotEmpty(t, binding["desc"], "should have description from defaults")
33+}
34+
35+func TestTransformLegacyActions_AllModes(t *testing.T) {
36+ transformer := NewLegacyConfigTransformer()
37+
38+ rawConfig := map[string]any{
39+ "workspace": map[string]any{
40+ "pane_mode": map[string]any{
41+ "actions": map[string]any{
42+ "focus-left": []any{"h"},
43+ },
44+ },
45+ "tab_mode": map[string]any{
46+ "actions": map[string]any{
47+ "next-tab": []any{"l"},
48+ },
49+ },
50+ "resize_mode": map[string]any{
51+ "actions": map[string]any{
52+ "resize-increase": []any{"+"},
53+ },
54+ },
55+ "shortcuts": map[string]any{
56+ "actions": map[string]any{
57+ "close_pane": []any{"ctrl+w"},
58+ },
59+ },
60+ },
61+ "session": map[string]any{
62+ "session_mode": map[string]any{
63+ "actions": map[string]any{
64+ "session-manager": []any{"m"},
65+ },
66+ },
67+ },
68+ }
69+
70+ transformer.TransformLegacyActions(rawConfig)
71+
72+ // Check pane mode
73+ paneActions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any)
74+ paneFocus, ok := paneActions["focus-left"].(map[string]any)
75+ require.True(t, ok)
76+ assert.Equal(t, []string{"h"}, paneFocus["keys"])
77+
78+ // Check tab mode
79+ tabActions := rawConfig["workspace"].(map[string]any)["tab_mode"].(map[string]any)["actions"].(map[string]any)
80+ tabNext, ok := tabActions["next-tab"].(map[string]any)
81+ require.True(t, ok)
82+ assert.Equal(t, []string{"l"}, tabNext["keys"])
83+
84+ // Check resize mode
85+ resizeActions := rawConfig["workspace"].(map[string]any)["resize_mode"].(map[string]any)["actions"].(map[string]any)
86+ resizeInc, ok := resizeActions["resize-increase"].(map[string]any)
87+ require.True(t, ok)
88+ assert.Equal(t, []string{"+"}, resizeInc["keys"])
89+
90+ // Check shortcuts
91+ shortcutActions := rawConfig["workspace"].(map[string]any)["shortcuts"].(map[string]any)["actions"].(map[string]any)
92+ closePane, ok := shortcutActions["close_pane"].(map[string]any)
93+ require.True(t, ok)
94+ assert.Equal(t, []string{"ctrl+w"}, closePane["keys"])
95+
96+ // Check session mode
97+ sessionActions := rawConfig["session"].(map[string]any)["session_mode"].(map[string]any)["actions"].(map[string]any)
98+ sessionMgr, ok := sessionActions["session-manager"].(map[string]any)
99+ require.True(t, ok)
100+ assert.Equal(t, []string{"m"}, sessionMgr["keys"])
101+}
102+
103+func TestTransformLegacyActions_AlreadyNewFormat(t *testing.T) {
104+ transformer := NewLegacyConfigTransformer()
105+
106+ rawConfig := map[string]any{
107+ "workspace": map[string]any{
108+ "pane_mode": map[string]any{
109+ "actions": map[string]any{
110+ "focus-left": map[string]any{
111+ "keys": []string{"h"},
112+ "desc": "Custom description",
113+ },
114+ },
115+ },
116+ },
117+ }
118+
119+ transformer.TransformLegacyActions(rawConfig)
120+
121+ actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any)
122+ binding := actions["focus-left"].(map[string]any)
123+
124+ // Should remain unchanged
125+ assert.Equal(t, "Custom description", binding["desc"])
126+}
127+
128+func TestTransformLegacyActions_MissingSection(t *testing.T) {
129+ transformer := NewLegacyConfigTransformer()
130+
131+ rawConfig := map[string]any{
132+ "appearance": map[string]any{
133+ "theme": "dark",
134+ },
135+ }
136+
137+ // Should not panic
138+ transformer.TransformLegacyActions(rawConfig)
139+
140+ // Config should be unchanged
141+ assert.Equal(t, "dark", rawConfig["appearance"].(map[string]any)["theme"])
142+}
143+
144+func TestTransformLegacyActions_EmptyConfig(t *testing.T) {
145+ transformer := NewLegacyConfigTransformer()
146+
147+ rawConfig := map[string]any{}
148+
149+ // Should not panic
150+ transformer.TransformLegacyActions(rawConfig)
151+
152+ assert.Empty(t, rawConfig)
153+}
154+
155+func TestTransformLegacyActions_PartialPath(t *testing.T) {
156+ transformer := NewLegacyConfigTransformer()
157+
158+ rawConfig := map[string]any{
159+ "workspace": map[string]any{
160+ "pane_mode": map[string]any{
161+ // actions section missing
162+ },
163+ },
164+ }
165+
166+ // Should not panic
167+ transformer.TransformLegacyActions(rawConfig)
168+
169+ // Verify config is unchanged
170+ require.NotNil(t, rawConfig["workspace"])
171+}
172+
173+func TestTransformLegacyActions_UnknownAction(t *testing.T) {
174+ transformer := NewLegacyConfigTransformer()
175+
176+ rawConfig := map[string]any{
177+ "workspace": map[string]any{
178+ "pane_mode": map[string]any{
179+ "actions": map[string]any{
180+ "unknown-action": []any{"x"},
181+ },
182+ },
183+ },
184+ }
185+
186+ transformer.TransformLegacyActions(rawConfig)
187+
188+ actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any)
189+ binding, ok := actions["unknown-action"].(map[string]any)
190+ require.True(t, ok)
191+ assert.Equal(t, []string{"x"}, binding["keys"])
192+ assert.Empty(t, binding["desc"], "unknown action should have empty description")
193+}
@@ -0,0 +1,193 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestTransformLegacyActions_SliceFormat(t *testing.T) {
11+ transformer := NewLegacyConfigTransformer()
12+
13+ rawConfig := map[string]any{
14+ "workspace": map[string]any{
15+ "pane_mode": map[string]any{
16+ "actions": map[string]any{
17+ "focus-left": []any{"h", "arrowleft"},
18+ },
19+ },
20+ },
21+ }
22+
23+ transformer.TransformLegacyActions(rawConfig)
24+
25+ actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any)
26+ binding, ok := actions["focus-left"].(map[string]any)
27+ require.True(t, ok, "should be converted to map")
28+
29+ keys, ok := binding["keys"].([]string)
30+ require.True(t, ok, "keys should be []string")
31+ assert.Equal(t, []string{"h", "arrowleft"}, keys)
32+ assert.NotEmpty(t, binding["desc"], "should have description from defaults")
33+}
34+
35+func TestTransformLegacyActions_AllModes(t *testing.T) {
36+ transformer := NewLegacyConfigTransformer()
37+
38+ rawConfig := map[string]any{
39+ "workspace": map[string]any{
40+ "pane_mode": map[string]any{
41+ "actions": map[string]any{
42+ "focus-left": []any{"h"},
43+ },
44+ },
45+ "tab_mode": map[string]any{
46+ "actions": map[string]any{
47+ "next-tab": []any{"l"},
48+ },
49+ },
50+ "resize_mode": map[string]any{
51+ "actions": map[string]any{
52+ "resize-increase": []any{"+"},
53+ },
54+ },
55+ "shortcuts": map[string]any{
56+ "actions": map[string]any{
57+ "close_pane": []any{"ctrl+w"},
58+ },
59+ },
60+ },
61+ "session": map[string]any{
62+ "session_mode": map[string]any{
63+ "actions": map[string]any{
64+ "session-manager": []any{"m"},
65+ },
66+ },
67+ },
68+ }
69+
70+ transformer.TransformLegacyActions(rawConfig)
71+
72+ // Check pane mode
73+ paneActions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any)
74+ paneFocus, ok := paneActions["focus-left"].(map[string]any)
75+ require.True(t, ok)
76+ assert.Equal(t, []string{"h"}, paneFocus["keys"])
77+
78+ // Check tab mode
79+ tabActions := rawConfig["workspace"].(map[string]any)["tab_mode"].(map[string]any)["actions"].(map[string]any)
80+ tabNext, ok := tabActions["next-tab"].(map[string]any)
81+ require.True(t, ok)
82+ assert.Equal(t, []string{"l"}, tabNext["keys"])
83+
84+ // Check resize mode
85+ resizeActions := rawConfig["workspace"].(map[string]any)["resize_mode"].(map[string]any)["actions"].(map[string]any)
86+ resizeInc, ok := resizeActions["resize-increase"].(map[string]any)
87+ require.True(t, ok)
88+ assert.Equal(t, []string{"+"}, resizeInc["keys"])
89+
90+ // Check shortcuts
91+ shortcutActions := rawConfig["workspace"].(map[string]any)["shortcuts"].(map[string]any)["actions"].(map[string]any)
92+ closePane, ok := shortcutActions["close_pane"].(map[string]any)
93+ require.True(t, ok)
94+ assert.Equal(t, []string{"ctrl+w"}, closePane["keys"])
95+
96+ // Check session mode
97+ sessionActions := rawConfig["session"].(map[string]any)["session_mode"].(map[string]any)["actions"].(map[string]any)
98+ sessionMgr, ok := sessionActions["session-manager"].(map[string]any)
99+ require.True(t, ok)
100+ assert.Equal(t, []string{"m"}, sessionMgr["keys"])
101+}
102+
103+func TestTransformLegacyActions_AlreadyNewFormat(t *testing.T) {
104+ transformer := NewLegacyConfigTransformer()
105+
106+ rawConfig := map[string]any{
107+ "workspace": map[string]any{
108+ "pane_mode": map[string]any{
109+ "actions": map[string]any{
110+ "focus-left": map[string]any{
111+ "keys": []string{"h"},
112+ "desc": "Custom description",
113+ },
114+ },
115+ },
116+ },
117+ }
118+
119+ transformer.TransformLegacyActions(rawConfig)
120+
121+ actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any)
122+ binding := actions["focus-left"].(map[string]any)
123+
124+ // Should remain unchanged
125+ assert.Equal(t, "Custom description", binding["desc"])
126+}
127+
128+func TestTransformLegacyActions_MissingSection(t *testing.T) {
129+ transformer := NewLegacyConfigTransformer()
130+
131+ rawConfig := map[string]any{
132+ "appearance": map[string]any{
133+ "theme": "dark",
134+ },
135+ }
136+
137+ // Should not panic
138+ transformer.TransformLegacyActions(rawConfig)
139+
140+ // Config should be unchanged
141+ assert.Equal(t, "dark", rawConfig["appearance"].(map[string]any)["theme"])
142+}
143+
144+func TestTransformLegacyActions_EmptyConfig(t *testing.T) {
145+ transformer := NewLegacyConfigTransformer()
146+
147+ rawConfig := map[string]any{}
148+
149+ // Should not panic
150+ transformer.TransformLegacyActions(rawConfig)
151+
152+ assert.Empty(t, rawConfig)
153+}
154+
155+func TestTransformLegacyActions_PartialPath(t *testing.T) {
156+ transformer := NewLegacyConfigTransformer()
157+
158+ rawConfig := map[string]any{
159+ "workspace": map[string]any{
160+ "pane_mode": map[string]any{
161+ // actions section missing
162+ },
163+ },
164+ }
165+
166+ // Should not panic
167+ transformer.TransformLegacyActions(rawConfig)
168+
169+ // Verify config is unchanged
170+ require.NotNil(t, rawConfig["workspace"])
171+}
172+
173+func TestTransformLegacyActions_UnknownAction(t *testing.T) {
174+ transformer := NewLegacyConfigTransformer()
175+
176+ rawConfig := map[string]any{
177+ "workspace": map[string]any{
178+ "pane_mode": map[string]any{
179+ "actions": map[string]any{
180+ "unknown-action": []any{"x"},
181+ },
182+ },
183+ },
184+ }
185+
186+ transformer.TransformLegacyActions(rawConfig)
187+
188+ actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any)
189+ binding, ok := actions["unknown-action"].(map[string]any)
190+ require.True(t, ok)
191+ assert.Equal(t, []string{"x"}, binding["keys"])
192+ assert.Empty(t, binding["desc"], "unknown action should have empty description")
193+}
M internal/infrastructure/webkit/messaging.go +10 -2
@@ -213,7 +213,13 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
213213
214214 entry, ok := r.getHandler(msg.Type)
215215 if !ok || entry.handler == nil {
216- log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")
216+ log.Warn().Str("type", msg.Type).Int("registered_handlers", len(r.handlers)).Msg("no handler registered for message type")
217+ // Debug: list all registered handlers
218+ r.mu.RLock()
219+ for handlerType := range r.handlers {
220+ log.Debug().Str("registered_type", handlerType).Msg("available handler")
221+ }
222+ r.mu.RUnlock()
217223 return
218224 }
219225
@@ -221,9 +227,11 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
221227 Str("type", msg.Type).
222228 Uint64("webview_id", msg.WebViewID).
223229 Int("payload_len", len(msg.Payload)).
224- Msg("received script message")
230+ Str("callback", entry.callback).
231+ Msg("received script message, dispatching to handler")
225232
226233 resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload)
234+ log.Debug().Str("type", msg.Type).Err(err).Msg("handler execution completed")
227235 if err != nil {
228236 log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
229237 if entry.errorCallback != "" {
@@ -213,7 +213,13 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
213 213
214 entry, ok := r.getHandler(msg.Type)214 entry, ok := r.getHandler(msg.Type)
215 if !ok || entry.handler == nil {215 if !ok || entry.handler == nil {
216- log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")216+ log.Warn().Str("type", msg.Type).Int("registered_handlers", len(r.handlers)).Msg("no handler registered for message type")
217+ // Debug: list all registered handlers
218+ r.mu.RLock()
219+ for handlerType := range r.handlers {
220+ log.Debug().Str("registered_type", handlerType).Msg("available handler")
221+ }
222+ r.mu.RUnlock()
217 return223 return
218 }224 }
219 225
@@ -221,9 +227,11 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
221 Str("type", msg.Type).227 Str("type", msg.Type).
222 Uint64("webview_id", msg.WebViewID).228 Uint64("webview_id", msg.WebViewID).
223 Int("payload_len", len(msg.Payload)).229 Int("payload_len", len(msg.Payload)).
224- Msg("received script message")230+ Str("callback", entry.callback).
231+ Msg("received script message, dispatching to handler")
225 232
226 resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload)233 resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload)
234+ log.Debug().Str("type", msg.Type).Err(err).Msg("handler execution completed")
227 if err != nil {235 if err != nil {
228 log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")236 log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
229 if entry.errorCallback != "" {237 if entry.errorCallback != "" {
M webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +12 -2
@@ -2,16 +2,26 @@
22 import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
33 import { buttonVariants } from "$lib/components/ui/button/index.js";
44 import { cn } from "$lib/utils.js";
5+ import type { Snippet } from "svelte";
6+ import type { MouseEventHandler } from "svelte/elements";
57
68 let {
79 ref = $bindable(null),
810 class: className,
11+ children,
12+ onclick,
913 ...restProps
10- }: AlertDialogPrimitive.ActionProps = $props();
14+ }: AlertDialogPrimitive.ActionProps & {
15+ children?: Snippet;
16+ onclick?: MouseEventHandler<HTMLButtonElement>;
17+ } = $props();
1118 </script>
1219
1320 <AlertDialogPrimitive.Action
1421 bind:ref
1522 class={cn(buttonVariants(), className)}
23+ {onclick}
1624 {...restProps}
17-/>
25+>
26+ {@render children?.()}
27+</AlertDialogPrimitive.Action>
@@ -2,16 +2,26 @@
2 import { AlertDialog as AlertDialogPrimitive } from "bits-ui";2 import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3 import { buttonVariants } from "$lib/components/ui/button/index.js";3 import { buttonVariants } from "$lib/components/ui/button/index.js";
4 import { cn } from "$lib/utils.js";4 import { cn } from "$lib/utils.js";
5+ import type { Snippet } from "svelte";
6+ import type { MouseEventHandler } from "svelte/elements";
5 7
6 let {8 let {
7 ref = $bindable(null),9 ref = $bindable(null),
8 class: className,10 class: className,
11+ children,
12+ onclick,
9 ...restProps13 ...restProps
10- }: AlertDialogPrimitive.ActionProps = $props();14+ }: AlertDialogPrimitive.ActionProps & {
15+ children?: Snippet;
16+ onclick?: MouseEventHandler<HTMLButtonElement>;
17+ } = $props();
11 </script>18 </script>
12 19
13 <AlertDialogPrimitive.Action20 <AlertDialogPrimitive.Action
14 bind:ref21 bind:ref
15 class={cn(buttonVariants(), className)}22 class={cn(buttonVariants(), className)}
23+ {onclick}
16 {...restProps}24 {...restProps}
17-/>25+>
26+ {@render children?.()}
27+</AlertDialogPrimitive.Action>
M webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte +8 -2
@@ -2,16 +2,22 @@
22 import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
33 import { buttonVariants } from "$lib/components/ui/button/index.js";
44 import { cn } from "$lib/utils.js";
5+ import type { Snippet } from "svelte";
56
67 let {
78 ref = $bindable(null),
89 class: className,
10+ children,
911 ...restProps
10- }: AlertDialogPrimitive.CancelProps = $props();
12+ }: AlertDialogPrimitive.CancelProps & {
13+ children?: Snippet;
14+ } = $props();
1115 </script>
1216
1317 <AlertDialogPrimitive.Cancel
1418 bind:ref
1519 class={cn(buttonVariants({ variant: "outline" }), className)}
1620 {...restProps}
17-/>
21+>
22+ {@render children?.()}
23+</AlertDialogPrimitive.Cancel>
@@ -2,16 +2,22 @@
2 import { AlertDialog as AlertDialogPrimitive } from "bits-ui";2 import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3 import { buttonVariants } from "$lib/components/ui/button/index.js";3 import { buttonVariants } from "$lib/components/ui/button/index.js";
4 import { cn } from "$lib/utils.js";4 import { cn } from "$lib/utils.js";
5+ import type { Snippet } from "svelte";
5 6
6 let {7 let {
7 ref = $bindable(null),8 ref = $bindable(null),
8 class: className,9 class: className,
10+ children,
9 ...restProps11 ...restProps
10- }: AlertDialogPrimitive.CancelProps = $props();12+ }: AlertDialogPrimitive.CancelProps & {
13+ children?: Snippet;
14+ } = $props();
11 </script>15 </script>
12 16
13 <AlertDialogPrimitive.Cancel17 <AlertDialogPrimitive.Cancel
14 bind:ref18 bind:ref
15 class={cn(buttonVariants({ variant: "outline" }), className)}19 class={cn(buttonVariants({ variant: "outline" }), className)}
16 {...restProps}20 {...restProps}
17-/>21+>
22+ {@render children?.()}
23+</AlertDialogPrimitive.Cancel>
M webui/src/pages/config/KeyCaptureModal.svelte +1 -1
@@ -189,7 +189,7 @@
189189
190190 <AlertDialog.Footer>
191191 <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
192- <AlertDialog.Action onclick={() => onSave(newKeys)}>Save</AlertDialog.Action>
192+ <AlertDialog.Action onclick={() => { console.log("[DEBUG] Save clicked, newKeys:", newKeys); onSave(newKeys); }}>Save</AlertDialog.Action>
193193 </AlertDialog.Footer>
194194 </AlertDialog.Content>
195195 </AlertDialog.Root>
@@ -189,7 +189,7 @@
189 189
190 <AlertDialog.Footer>190 <AlertDialog.Footer>
191 <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>191 <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
192- <AlertDialog.Action onclick={() => onSave(newKeys)}>Save</AlertDialog.Action>192+ <AlertDialog.Action onclick={() => { console.log("[DEBUG] Save clicked, newKeys:", newKeys); onSave(newKeys); }}>Save</AlertDialog.Action>
193 </AlertDialog.Footer>193 </AlertDialog.Footer>
194 </AlertDialog.Content>194 </AlertDialog.Content>
195 </AlertDialog.Root>195 </AlertDialog.Root>
M webui/src/pages/config/KeybindingsTab.svelte +10 -2
@@ -74,10 +74,18 @@
7474 }
7575
7676 async function saveBinding(keys: string[]) {
77- if (!editingBinding) return;
77+ console.log("[DEBUG] saveBinding called with keys:", keys, "editingBinding:", editingBinding);
78+ if (!editingBinding) {
79+ console.log("[DEBUG] saveBinding: no editingBinding, returning");
80+ return;
81+ }
7882
7983 const bridge = getWebKitBridge();
80- if (!bridge) return;
84+ console.log("[DEBUG] saveBinding: bridge =", bridge);
85+ if (!bridge) {
86+ console.log("[DEBUG] saveBinding: no bridge, returning");
87+ return;
88+ }
8189
8290 (window as any).__dumber_keybinding_set = () => {
8391 loadKeybindings();
@@ -74,10 +74,18 @@
74 }74 }
75 75
76 async function saveBinding(keys: string[]) {76 async function saveBinding(keys: string[]) {
77- if (!editingBinding) return;77+ console.log("[DEBUG] saveBinding called with keys:", keys, "editingBinding:", editingBinding);
78+ if (!editingBinding) {
79+ console.log("[DEBUG] saveBinding: no editingBinding, returning");
80+ return;
81+ }
78 82
79 const bridge = getWebKitBridge();83 const bridge = getWebKitBridge();
80- if (!bridge) return;84+ console.log("[DEBUG] saveBinding: bridge =", bridge);
85+ if (!bridge) {
86+ console.log("[DEBUG] saveBinding: no bridge, returning");
87+ return;
88+ }
81 89
82 (window as any).__dumber_keybinding_set = () => {90 (window as any).__dumber_keybinding_set = () => {
83 loadKeybindings();91 loadKeybindings();

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