Validate user config keybindings (#4275)

- **PR Description**

This improves the user experience when users try to use an invalid key
name in their config, either for one of our standard keybindings or for
the key of a custom command.

Fixes half of #4256 (only the keybindings aspect of it, not the context
names).
This commit is contained in:
Stefan Haller 2025-02-21 13:24:41 +01:00 committed by GitHub
commit 16f5348790
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 219 additions and 70 deletions

View file

@ -20,11 +20,15 @@
| `<pgup>` | Pgup |
| `<pgdown>` | Pgdn |
| `<up>` | ArrowUp |
| `<s-up>` | ShiftArrowUp |
| `<down>` | ArrowDown |
| `<s-down>` | ShiftArrowDown |
| `<left>` | ArrowLeft |
| `<right>` | ArrowRight |
| `<tab>` | Tab |
| `<backtab>` | Backtab |
| `<enter>` | Enter |
| `<a-enter>` | AltEnter |
| `<esc>` | Esc |
| `<backspace>` | Backspace |
| `<c-space>` | CtrlSpace |

93
pkg/config/keynames.go Normal file
View file

@ -0,0 +1,93 @@
package config
import (
"strings"
"unicode/utf8"
"github.com/jesseduffield/gocui"
"github.com/samber/lo"
)
// NOTE: if you make changes to this table, be sure to update
// docs/keybindings/Custom_Keybindings.md as well
var LabelByKey = map[gocui.Key]string{
gocui.KeyF1: "<f1>",
gocui.KeyF2: "<f2>",
gocui.KeyF3: "<f3>",
gocui.KeyF4: "<f4>",
gocui.KeyF5: "<f5>",
gocui.KeyF6: "<f6>",
gocui.KeyF7: "<f7>",
gocui.KeyF8: "<f8>",
gocui.KeyF9: "<f9>",
gocui.KeyF10: "<f10>",
gocui.KeyF11: "<f11>",
gocui.KeyF12: "<f12>",
gocui.KeyInsert: "<insert>",
gocui.KeyDelete: "<delete>",
gocui.KeyHome: "<home>",
gocui.KeyEnd: "<end>",
gocui.KeyPgup: "<pgup>",
gocui.KeyPgdn: "<pgdown>",
gocui.KeyArrowUp: "<up>",
gocui.KeyShiftArrowUp: "<s-up>",
gocui.KeyArrowDown: "<down>",
gocui.KeyShiftArrowDown: "<s-down>",
gocui.KeyArrowLeft: "<left>",
gocui.KeyArrowRight: "<right>",
gocui.KeyTab: "<tab>", // <c-i>
gocui.KeyBacktab: "<backtab>",
gocui.KeyEnter: "<enter>", // <c-m>
gocui.KeyAltEnter: "<a-enter>",
gocui.KeyEsc: "<esc>", // <c-[>, <c-3>
gocui.KeyBackspace: "<backspace>", // <c-h>
gocui.KeyCtrlSpace: "<c-space>", // <c-~>, <c-2>
gocui.KeyCtrlSlash: "<c-/>", // <c-_>
gocui.KeySpace: "<space>",
gocui.KeyCtrlA: "<c-a>",
gocui.KeyCtrlB: "<c-b>",
gocui.KeyCtrlC: "<c-c>",
gocui.KeyCtrlD: "<c-d>",
gocui.KeyCtrlE: "<c-e>",
gocui.KeyCtrlF: "<c-f>",
gocui.KeyCtrlG: "<c-g>",
gocui.KeyCtrlJ: "<c-j>",
gocui.KeyCtrlK: "<c-k>",
gocui.KeyCtrlL: "<c-l>",
gocui.KeyCtrlN: "<c-n>",
gocui.KeyCtrlO: "<c-o>",
gocui.KeyCtrlP: "<c-p>",
gocui.KeyCtrlQ: "<c-q>",
gocui.KeyCtrlR: "<c-r>",
gocui.KeyCtrlS: "<c-s>",
gocui.KeyCtrlT: "<c-t>",
gocui.KeyCtrlU: "<c-u>",
gocui.KeyCtrlV: "<c-v>",
gocui.KeyCtrlW: "<c-w>",
gocui.KeyCtrlX: "<c-x>",
gocui.KeyCtrlY: "<c-y>",
gocui.KeyCtrlZ: "<c-z>",
gocui.KeyCtrl4: "<c-4>", // <c-\>
gocui.KeyCtrl5: "<c-5>", // <c-]>
gocui.KeyCtrl6: "<c-6>",
gocui.KeyCtrl8: "<c-8>",
gocui.MouseWheelUp: "mouse wheel up",
gocui.MouseWheelDown: "mouse wheel down",
}
var KeyByLabel = lo.Invert(LabelByKey)
func isValidKeybindingKey(key string) bool {
runeCount := utf8.RuneCountInString(key)
if key == "<disabled>" {
return true
}
if runeCount > 1 {
_, ok := KeyByLabel[strings.ToLower(key)]
return ok
}
return true
}

View file

@ -2,8 +2,12 @@ package config
import (
"fmt"
"log"
"reflect"
"slices"
"strings"
"github.com/jesseduffield/lazygit/pkg/constants"
)
func (config *UserConfig) Validate() error {
@ -15,6 +19,12 @@ func (config *UserConfig) Validate() error {
[]string{"none", "onlyArrow", "arrowAndNumber"}); err != nil {
return err
}
if err := validateKeybindings(config.Keybinding); err != nil {
return err
}
if err := validateCustomCommands(config.CustomCommands); err != nil {
return err
}
return nil
}
@ -25,3 +35,67 @@ func validateEnum(name string, value string, allowedValues []string) error {
allowedValuesStr := strings.Join(allowedValues, ", ")
return fmt.Errorf("Unexpected value '%s' for '%s'. Allowed values: %s", value, name, allowedValuesStr)
}
func validateKeybindingsRecurse(path string, node any) error {
value := reflect.ValueOf(node)
if value.Kind() == reflect.Struct {
for _, field := range reflect.VisibleFields(reflect.TypeOf(node)) {
var newPath string
if len(path) == 0 {
newPath = field.Name
} else {
newPath = fmt.Sprintf("%s.%s", path, field.Name)
}
if err := validateKeybindingsRecurse(newPath,
value.FieldByName(field.Name).Interface()); err != nil {
return err
}
}
} else if value.Kind() == reflect.Slice {
for i := 0; i < value.Len(); i++ {
if err := validateKeybindingsRecurse(
fmt.Sprintf("%s[%d]", path, i), value.Index(i).Interface()); err != nil {
return err
}
}
} else if value.Kind() == reflect.String {
key := node.(string)
if !isValidKeybindingKey(key) {
return fmt.Errorf("Unrecognized key '%s' for keybinding '%s'. For permitted values see %s",
key, path, constants.Links.Docs.CustomKeybindings)
}
} else {
log.Fatalf("Unexpected type for property '%s': %s", path, value.Kind())
}
return nil
}
func validateKeybindings(keybindingConfig KeybindingConfig) error {
if err := validateKeybindingsRecurse("", keybindingConfig); err != nil {
return err
}
if len(keybindingConfig.Universal.JumpToBlock) != 5 {
return fmt.Errorf("keybinding.universal.jumpToBlock must have 5 elements; found %d.",
len(keybindingConfig.Universal.JumpToBlock))
}
return nil
}
func validateCustomCommandKey(key string) error {
if !isValidKeybindingKey(key) {
return fmt.Errorf("Unrecognized key '%s' for custom command. For permitted values see %s",
key, constants.Links.Docs.CustomKeybindings)
}
return nil
}
func validateCustomCommands(customCommands []CustomCommand) error {
for _, customCommand := range customCommands {
if err := validateCustomCommandKey(customCommand.Key); err != nil {
return err
}
}
return nil
}

View file

@ -1,6 +1,7 @@
package config
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
@ -29,6 +30,50 @@ func TestUserConfigValidate_enums(t *testing.T) {
{value: "invalid_value", valid: false},
},
},
{
name: "Keybindings",
setup: func(config *UserConfig, value string) {
config.Keybinding.Universal.Quit = value
},
testCases: []testCase{
{value: "", valid: true},
{value: "<disabled>", valid: true},
{value: "q", valid: true},
{value: "<c-c>", valid: true},
{value: "invalid_value", valid: false},
},
},
{
name: "JumpToBlock keybinding",
setup: func(config *UserConfig, value string) {
config.Keybinding.Universal.JumpToBlock = strings.Split(value, ",")
},
testCases: []testCase{
{value: "", valid: false},
{value: "1,2,3", valid: false},
{value: "1,2,3,4,5", valid: true},
{value: "1,2,3,4,invalid", valid: false},
{value: "1,2,3,4,5,6", valid: false},
},
},
{
name: "Custom command keybinding",
setup: func(config *UserConfig, value string) {
config.CustomCommands = []CustomCommand{
{
Key: value,
Command: "echo 'hello'",
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "<disabled>", valid: true},
{value: "q", valid: true},
{value: "<c-c>", valid: true},
{value: "invalid_value", valid: false},
},
},
}
for _, s := range scenarios {

View file

@ -7,78 +7,11 @@ import (
"unicode/utf8"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/constants"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
var labelByKey = map[gocui.Key]string{
gocui.KeyF1: "<f1>",
gocui.KeyF2: "<f2>",
gocui.KeyF3: "<f3>",
gocui.KeyF4: "<f4>",
gocui.KeyF5: "<f5>",
gocui.KeyF6: "<f6>",
gocui.KeyF7: "<f7>",
gocui.KeyF8: "<f8>",
gocui.KeyF9: "<f9>",
gocui.KeyF10: "<f10>",
gocui.KeyF11: "<f11>",
gocui.KeyF12: "<f12>",
gocui.KeyInsert: "<insert>",
gocui.KeyDelete: "<delete>",
gocui.KeyHome: "<home>",
gocui.KeyEnd: "<end>",
gocui.KeyPgup: "<pgup>",
gocui.KeyPgdn: "<pgdown>",
gocui.KeyArrowUp: "<up>",
gocui.KeyShiftArrowUp: "<s-up>",
gocui.KeyArrowDown: "<down>",
gocui.KeyShiftArrowDown: "<s-down>",
gocui.KeyArrowLeft: "<left>",
gocui.KeyArrowRight: "<right>",
gocui.KeyTab: "<tab>", // <c-i>
gocui.KeyBacktab: "<backtab>",
gocui.KeyEnter: "<enter>", // <c-m>
gocui.KeyAltEnter: "<a-enter>",
gocui.KeyEsc: "<esc>", // <c-[>, <c-3>
gocui.KeyBackspace: "<backspace>", // <c-h>
gocui.KeyCtrlSpace: "<c-space>", // <c-~>, <c-2>
gocui.KeyCtrlSlash: "<c-/>", // <c-_>
gocui.KeySpace: "<space>",
gocui.KeyCtrlA: "<c-a>",
gocui.KeyCtrlB: "<c-b>",
gocui.KeyCtrlC: "<c-c>",
gocui.KeyCtrlD: "<c-d>",
gocui.KeyCtrlE: "<c-e>",
gocui.KeyCtrlF: "<c-f>",
gocui.KeyCtrlG: "<c-g>",
gocui.KeyCtrlJ: "<c-j>",
gocui.KeyCtrlK: "<c-k>",
gocui.KeyCtrlL: "<c-l>",
gocui.KeyCtrlN: "<c-n>",
gocui.KeyCtrlO: "<c-o>",
gocui.KeyCtrlP: "<c-p>",
gocui.KeyCtrlQ: "<c-q>",
gocui.KeyCtrlR: "<c-r>",
gocui.KeyCtrlS: "<c-s>",
gocui.KeyCtrlT: "<c-t>",
gocui.KeyCtrlU: "<c-u>",
gocui.KeyCtrlV: "<c-v>",
gocui.KeyCtrlW: "<c-w>",
gocui.KeyCtrlX: "<c-x>",
gocui.KeyCtrlY: "<c-y>",
gocui.KeyCtrlZ: "<c-z>",
gocui.KeyCtrl4: "<c-4>", // <c-\>
gocui.KeyCtrl5: "<c-5>", // <c-]>
gocui.KeyCtrl6: "<c-6>",
gocui.KeyCtrl8: "<c-8>",
gocui.MouseWheelUp: "mouse wheel up",
gocui.MouseWheelDown: "mouse wheel down",
}
var keyByLabel = lo.Invert(labelByKey)
func Label(name string) string {
return LabelFromKey(GetKey(name))
}
@ -90,7 +23,7 @@ func LabelFromKey(key types.Key) string {
case rune:
keyInt = int(key)
case gocui.Key:
value, ok := labelByKey[key]
value, ok := config.LabelByKey[key]
if ok {
return value
}
@ -105,7 +38,7 @@ func GetKey(key string) types.Key {
if key == "<disabled>" {
return nil
} else if runeCount > 1 {
binding, ok := keyByLabel[strings.ToLower(key)]
binding, ok := config.KeyByLabel[strings.ToLower(key)]
if !ok {
log.Fatalf("Unrecognized key %s for keybinding. For permitted values see %s", strings.ToLower(key), constants.Links.Docs.CustomKeybindings)
} else {