detached ef74c93

fix(input): honor legacy underscore names for built-in global shortcuts

Treat a built-in browser action configured under its legacy underscore name
(open_find) as configured, using the same rule as default-action completion,
and resolve such names to their action. Setting open_find.keys = [] now
disables the default ctrl+f, and rebinding it replaces the default instead of
leaving both active.
commit
ef74c930b4092d585ca20894a95812b0f8fe0940
author
brice · · 2026-10-14 07:41 UTC
parent
28632c9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +125 -4
A internal/ui/input/builtin_shortcuts_test.go +85 -0
@@ -0,0 +1,85 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/puregotk/v4/gdk"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func globalShortcutsConfig(actions map[string]entity.ActionBinding) *entity.WorkspaceConfig {
14+ return &entity.WorkspaceConfig{Shortcuts: entity.GlobalShortcutsConfig{Actions: actions}}
15+}
16+
17+func lookupGlobal(set *ShortcutSet, keyval uint, mods Modifier) (Action, bool) {
18+ return set.Lookup(KeyBinding{Keyval: keyval, Modifiers: mods}, ModeNormal)
19+}
20+
21+func TestBuiltinShortcutFallback_AppliesWhenActionIsUnconfigured(t *testing.T) {
22+ set := NewShortcutSet(context.Background(), globalShortcutsConfig(nil), nil)
23+
24+ action, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl)
25+ require.True(t, ok)
26+ assert.Equal(t, ActionOpenFind, action)
27+}
28+
29+func TestBuiltinShortcutFallback_HyphenNameWithNoKeysDisablesDefault(t *testing.T) {
30+ set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{
31+ "open-find": {Keys: []string{}},
32+ }), nil)
33+
34+ _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl)
35+ assert.False(t, ok)
36+}
37+
38+func TestBuiltinShortcutFallback_LegacyNameWithNoKeysDisablesDefault(t *testing.T) {
39+ set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{
40+ "open_find": {Keys: []string{}},
41+ }), nil)
42+
43+ _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl)
44+ assert.False(t, ok, "a legacy-named action with no keys disables the built-in default")
45+}
46+
47+func TestBuiltinShortcutFallback_LegacyNameRebindReplacesDefault(t *testing.T) {
48+ set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{
49+ "open_find": {Keys: []string{"ctrl+shift+f"}},
50+ }), nil)
51+
52+ _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl)
53+ assert.False(t, ok, "the default ctrl+f is not active next to the rebinding")
54+
55+ action, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl|ModShift)
56+ require.True(t, ok, "the legacy-named binding is registered")
57+ assert.Equal(t, ActionOpenFind, action)
58+}
59+
60+func TestBuiltinShortcutFallback_LegacyNameOnlyAffectsItsOwnAction(t *testing.T) {
61+ set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{
62+ "open_find": {Keys: []string{}},
63+ }), nil)
64+
65+ action, ok := lookupGlobal(set, uint(gdk.KEY_l), ModCtrl)
66+ require.True(t, ok)
67+ assert.Equal(t, ActionOpenOmnibox, action)
68+}
69+
70+func TestBuiltinShortcutFallback_LegacyMultiWordNames(t *testing.T) {
71+ set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{
72+ "toggle_fullscreen": {Keys: []string{}},
73+ "hard_reload": {Keys: []string{"ctrl+alt+r"}},
74+ }), nil)
75+
76+ _, ok := lookupGlobal(set, uint(gdk.KEY_F11), ModNone)
77+ assert.False(t, ok, "toggle_fullscreen with no keys disables f11")
78+
79+ _, ok = lookupGlobal(set, uint(gdk.KEY_R), ModCtrl|ModShift)
80+ assert.False(t, ok, "the default hard-reload key is replaced")
81+
82+ action, ok := lookupGlobal(set, uint(gdk.KEY_r), ModCtrl|ModAlt)
83+ require.True(t, ok)
84+ assert.Equal(t, ActionHardReload, action)
85+}
@@ -0,0 +1,85 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/puregotk/v4/gdk"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func globalShortcutsConfig(actions map[string]entity.ActionBinding) *entity.WorkspaceConfig {
14+ return &entity.WorkspaceConfig{Shortcuts: entity.GlobalShortcutsConfig{Actions: actions}}
15+}
16+
17+func lookupGlobal(set *ShortcutSet, keyval uint, mods Modifier) (Action, bool) {
18+ return set.Lookup(KeyBinding{Keyval: keyval, Modifiers: mods}, ModeNormal)
19+}
20+
21+func TestBuiltinShortcutFallback_AppliesWhenActionIsUnconfigured(t *testing.T) {
22+ set := NewShortcutSet(context.Background(), globalShortcutsConfig(nil), nil)
23+
24+ action, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl)
25+ require.True(t, ok)
26+ assert.Equal(t, ActionOpenFind, action)
27+}
28+
29+func TestBuiltinShortcutFallback_HyphenNameWithNoKeysDisablesDefault(t *testing.T) {
30+ set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{
31+ "open-find": {Keys: []string{}},
32+ }), nil)
33+
34+ _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl)
35+ assert.False(t, ok)
36+}
37+
38+func TestBuiltinShortcutFallback_LegacyNameWithNoKeysDisablesDefault(t *testing.T) {
39+ set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{
40+ "open_find": {Keys: []string{}},
41+ }), nil)
42+
43+ _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl)
44+ assert.False(t, ok, "a legacy-named action with no keys disables the built-in default")
45+}
46+
47+func TestBuiltinShortcutFallback_LegacyNameRebindReplacesDefault(t *testing.T) {
48+ set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{
49+ "open_find": {Keys: []string{"ctrl+shift+f"}},
50+ }), nil)
51+
52+ _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl)
53+ assert.False(t, ok, "the default ctrl+f is not active next to the rebinding")
54+
55+ action, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl|ModShift)
56+ require.True(t, ok, "the legacy-named binding is registered")
57+ assert.Equal(t, ActionOpenFind, action)
58+}
59+
60+func TestBuiltinShortcutFallback_LegacyNameOnlyAffectsItsOwnAction(t *testing.T) {
61+ set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{
62+ "open_find": {Keys: []string{}},
63+ }), nil)
64+
65+ action, ok := lookupGlobal(set, uint(gdk.KEY_l), ModCtrl)
66+ require.True(t, ok)
67+ assert.Equal(t, ActionOpenOmnibox, action)
68+}
69+
70+func TestBuiltinShortcutFallback_LegacyMultiWordNames(t *testing.T) {
71+ set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{
72+ "toggle_fullscreen": {Keys: []string{}},
73+ "hard_reload": {Keys: []string{"ctrl+alt+r"}},
74+ }), nil)
75+
76+ _, ok := lookupGlobal(set, uint(gdk.KEY_F11), ModNone)
77+ assert.False(t, ok, "toggle_fullscreen with no keys disables f11")
78+
79+ _, ok = lookupGlobal(set, uint(gdk.KEY_R), ModCtrl|ModShift)
80+ assert.False(t, ok, "the default hard-reload key is replaced")
81+
82+ action, ok := lookupGlobal(set, uint(gdk.KEY_r), ModCtrl|ModAlt)
83+ require.True(t, ok)
84+ assert.Equal(t, ActionHardReload, action)
85+}
M internal/ui/input/shortcuts.go +40 -4
@@ -376,13 +376,39 @@ func (s *ShortcutSet) registerActivation(ctx context.Context, shortcut string, a
376376 Msg("mode activation registered")
377377 }
378378
379+// legacyActionName returns the underscore spelling older configs use for an
380+// action. It is the same rule the config loader applies when it completes
381+// missing default actions, so a legacy-named action counts as configured in
382+// both places.
383+func legacyActionName(name string) string {
384+ return strings.ReplaceAll(name, "-", "_")
385+}
386+
387+// globalShortcutAction resolves a configured global shortcut name to its
388+// action. Besides the names in configActionToAction, a built-in browser action
389+// is also recognized by its legacy underscore spelling.
390+func globalShortcutAction(name string) (Action, bool) {
391+ if action, ok := configActionToAction[name]; ok {
392+ return action, true
393+ }
394+ for builtin := range entity.BuiltinGlobalShortcuts() {
395+ if legacyActionName(builtin) == name {
396+ action, ok := configActionToAction[builtin]
397+ return action, ok
398+ }
399+ }
400+ return "", false
401+}
402+
379403 // registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose
380404 // action is missing from the configured global shortcuts, so configs written
381405 // before these actions became configurable keep working until migrated.
382406 func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) {
383407 for name, binding := range entity.BuiltinGlobalShortcuts() {
384408 if cfg != nil {
385- if _, configured := cfg.Shortcuts.Actions[name]; configured {
409+ _, configured := cfg.Shortcuts.Actions[name]
410+ _, configuredLegacy := cfg.Shortcuts.Actions[legacyActionName(name)]
411+ if configured || configuredLegacy {
386412 continue
387413 }
388414 }
@@ -403,7 +429,7 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
403429 return
404430 }
405431 for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) {
406- action, ok := configActionToAction[actionName]
432+ action, ok := globalShortcutAction(actionName)
407433 if !ok {
408434 continue
409435 }
@@ -421,10 +447,20 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
421447 // key conflict the same across restarts.
422448 func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string {
423449 builtins := entity.BuiltinGlobalShortcuts()
450+ isBuiltin := func(name string) bool {
451+ if _, ok := builtins[name]; ok {
452+ return true
453+ }
454+ for builtin := range builtins {
455+ if legacyActionName(builtin) == name {
456+ return true
457+ }
458+ }
459+ return false
460+ }
424461 names := slices.Sorted(maps.Keys(actions))
425462 slices.SortStableFunc(names, func(a, b string) int {
426- _, aBuiltin := builtins[a]
427- _, bBuiltin := builtins[b]
463+ aBuiltin, bBuiltin := isBuiltin(a), isBuiltin(b)
428464 switch {
429465 case aBuiltin == bBuiltin:
430466 return 0
@@ -376,13 +376,39 @@ func (s *ShortcutSet) registerActivation(ctx context.Context, shortcut string, a
376 Msg("mode activation registered")376 Msg("mode activation registered")
377 }377 }
378 378
379+// legacyActionName returns the underscore spelling older configs use for an
380+// action. It is the same rule the config loader applies when it completes
381+// missing default actions, so a legacy-named action counts as configured in
382+// both places.
383+func legacyActionName(name string) string {
384+ return strings.ReplaceAll(name, "-", "_")
385+}
386+
387+// globalShortcutAction resolves a configured global shortcut name to its
388+// action. Besides the names in configActionToAction, a built-in browser action
389+// is also recognized by its legacy underscore spelling.
390+func globalShortcutAction(name string) (Action, bool) {
391+ if action, ok := configActionToAction[name]; ok {
392+ return action, true
393+ }
394+ for builtin := range entity.BuiltinGlobalShortcuts() {
395+ if legacyActionName(builtin) == name {
396+ action, ok := configActionToAction[builtin]
397+ return action, ok
398+ }
399+ }
400+ return "", false
401+}
402+
379 // registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose403 // registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose
380 // action is missing from the configured global shortcuts, so configs written404 // action is missing from the configured global shortcuts, so configs written
381 // before these actions became configurable keep working until migrated.405 // before these actions became configurable keep working until migrated.
382 func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) {406 func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) {
383 for name, binding := range entity.BuiltinGlobalShortcuts() {407 for name, binding := range entity.BuiltinGlobalShortcuts() {
384 if cfg != nil {408 if cfg != nil {
385- if _, configured := cfg.Shortcuts.Actions[name]; configured {409+ _, configured := cfg.Shortcuts.Actions[name]
410+ _, configuredLegacy := cfg.Shortcuts.Actions[legacyActionName(name)]
411+ if configured || configuredLegacy {
386 continue412 continue
387 }413 }
388 }414 }
@@ -403,7 +429,7 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
403 return429 return
404 }430 }
405 for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) {431 for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) {
406- action, ok := configActionToAction[actionName]432+ action, ok := globalShortcutAction(actionName)
407 if !ok {433 if !ok {
408 continue434 continue
409 }435 }
@@ -421,10 +447,20 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
421 // key conflict the same across restarts.447 // key conflict the same across restarts.
422 func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string {448 func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string {
423 builtins := entity.BuiltinGlobalShortcuts()449 builtins := entity.BuiltinGlobalShortcuts()
450+ isBuiltin := func(name string) bool {
451+ if _, ok := builtins[name]; ok {
452+ return true
453+ }
454+ for builtin := range builtins {
455+ if legacyActionName(builtin) == name {
456+ return true
457+ }
458+ }
459+ return false
460+ }
424 names := slices.Sorted(maps.Keys(actions))461 names := slices.Sorted(maps.Keys(actions))
425 slices.SortStableFunc(names, func(a, b string) int {462 slices.SortStableFunc(names, func(a, b string) int {
426- _, aBuiltin := builtins[a]463+ aBuiltin, bBuiltin := isBuiltin(a), isBuiltin(b)
427- _, bBuiltin := builtins[b]
428 switch {464 switch {
429 case aBuiltin == bBuiltin:465 case aBuiltin == bBuiltin:
430 return 0466 return 0

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