mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
Add Keybinding type that accepts a string or a sequence of strings
Each user-configurable keybinding is currently a single string in the YAML config. To let users assign alternate keys to a command, introduce a Keybinding type that decodes from either a scalar (the existing single-key form, kept for backward compatibility and for a simpler config file) or a sequence of strings. Marshalling collapses single-element slices back to a scalar so configs and generated docs round-trip cleanly. JSONSchema describes the type as a oneOf union so editors validate either form; subsequent commits will inline the union into the generated schema and start using Keybinding as the field type.
This commit is contained in:
parent
f08a49fe52
commit
06b8d5a1e4
79
pkg/config/keybinding.go
Normal file
79
pkg/config/keybinding.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/karimkhaleel/jsonschema"
|
||||
"github.com/samber/lo"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Keybinding represents the value of a single keybinding entry in the user's
|
||||
// config. It's a slice of key strings to allow alternates, but for backward
|
||||
// compatibility (and because most bindings only have one key) it can be
|
||||
// written in YAML/JSON as either a single scalar string or as a sequence of
|
||||
// strings.
|
||||
type Keybinding []string
|
||||
|
||||
func (k *Keybinding) UnmarshalYAML(node *yaml.Node) error {
|
||||
var ss []string
|
||||
switch node.Kind {
|
||||
case yaml.ScalarNode:
|
||||
var s string
|
||||
if err := node.Decode(&s); err != nil {
|
||||
return err
|
||||
}
|
||||
ss = []string{s}
|
||||
case yaml.SequenceNode:
|
||||
if err := node.Decode(&ss); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("expected a string or a sequence of strings for keybinding, got %v", node.Tag)
|
||||
}
|
||||
// Drop empty and <disabled> entries so clients never have to special-case
|
||||
// them: an empty Keybinding means "no key bound", a non-empty one is
|
||||
// guaranteed to contain only real keys.
|
||||
*k = lo.Filter(ss, func(s string, _ int) bool {
|
||||
return s != "" && s != "<disabled>"
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k Keybinding) MarshalYAML() (any, error) {
|
||||
if len(k) == 1 {
|
||||
return k[0], nil
|
||||
}
|
||||
// Render multi-key bindings in flow style (`[a, b]`) rather than the default
|
||||
// block style, which is more compact and reads better in the generated docs.
|
||||
node := &yaml.Node{
|
||||
Kind: yaml.SequenceNode,
|
||||
Style: yaml.FlowStyle,
|
||||
}
|
||||
for _, s := range k {
|
||||
node.Content = append(node.Content, &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: s,
|
||||
})
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func (k Keybinding) MarshalJSON() ([]byte, error) {
|
||||
if len(k) == 1 {
|
||||
return json.Marshal(k[0])
|
||||
}
|
||||
return json.Marshal([]string(k))
|
||||
}
|
||||
|
||||
// JSONSchema lets the schema generator describe this type as a union of a
|
||||
// string and an array of strings instead of just an array.
|
||||
func (Keybinding) JSONSchema() *jsonschema.Schema {
|
||||
return &jsonschema.Schema{
|
||||
OneOf: []*jsonschema.Schema{
|
||||
{Type: "string"},
|
||||
{Type: "array", Items: &jsonschema.Schema{Type: "string"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
151
pkg/config/keybinding_test.go
Normal file
151
pkg/config/keybinding_test.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestKeybindingUnmarshalYAML(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
input string
|
||||
expected Keybinding
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "scalar string",
|
||||
input: `q`,
|
||||
expected: Keybinding{"q"},
|
||||
},
|
||||
{
|
||||
name: "scalar with special characters",
|
||||
input: `<esc>`,
|
||||
expected: Keybinding{"<esc>"},
|
||||
},
|
||||
{
|
||||
name: "sequence with one element",
|
||||
input: `[q]`,
|
||||
expected: Keybinding{"q"},
|
||||
},
|
||||
{
|
||||
name: "sequence with multiple elements",
|
||||
input: `["q", "<esc>"]`,
|
||||
expected: Keybinding{"q", "<esc>"},
|
||||
},
|
||||
{
|
||||
name: "empty sequence",
|
||||
input: `[]`,
|
||||
expected: Keybinding{},
|
||||
},
|
||||
{
|
||||
name: "scalar <disabled> decodes to empty",
|
||||
input: `<disabled>`,
|
||||
expected: Keybinding{},
|
||||
},
|
||||
{
|
||||
name: "scalar empty string decodes to empty",
|
||||
input: `""`,
|
||||
expected: Keybinding{},
|
||||
},
|
||||
{
|
||||
name: "<disabled> entries are filtered out of a sequence",
|
||||
input: `["q", "<disabled>", "<esc>"]`,
|
||||
expected: Keybinding{"q", "<esc>"},
|
||||
},
|
||||
{
|
||||
name: "mapping is rejected",
|
||||
input: `{key: q}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
var k Keybinding
|
||||
err := yaml.Unmarshal([]byte(s.input), &k)
|
||||
if s.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, s.expected, k)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeybindingMarshalYAML(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
input Keybinding
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single key emits a scalar",
|
||||
input: Keybinding{"q"},
|
||||
expected: "q\n",
|
||||
},
|
||||
{
|
||||
name: "multiple keys emit a flow sequence",
|
||||
input: Keybinding{"q", "<esc>"},
|
||||
expected: "[q, <esc>]\n",
|
||||
},
|
||||
{
|
||||
name: "empty keybinding emits an empty sequence",
|
||||
input: Keybinding{},
|
||||
expected: "[]\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
out, err := yaml.Marshal(s.input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, s.expected, string(out))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeybindingMarshalJSON(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
input Keybinding
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single key emits a string",
|
||||
input: Keybinding{"q"},
|
||||
expected: `"q"`,
|
||||
},
|
||||
{
|
||||
name: "multiple keys emit an array",
|
||||
input: Keybinding{"q", "esc"},
|
||||
expected: `["q","esc"]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
out, err := json.Marshal(s.input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, s.expected, string(out))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeybindingYAMLRoundTrip(t *testing.T) {
|
||||
scenarios := []Keybinding{
|
||||
{"q"},
|
||||
{"q", "<esc>"},
|
||||
{"<ctrl+c>", "<ctrl+d>", "<esc>"},
|
||||
}
|
||||
for _, original := range scenarios {
|
||||
out, err := yaml.Marshal(original)
|
||||
assert.NoError(t, err)
|
||||
var decoded Keybinding
|
||||
assert.NoError(t, yaml.Unmarshal(out, &decoded))
|
||||
assert.Equal(t, original, decoded)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue