Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom commands menus #4276

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion docs/Custom_Command_Keybindings.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Custom Command Keybindings

You can add custom command keybindings in your config.yml (accessible by pressing 'o' on the status panel from within lazygit) like so:
You can add custom command keybindings in your config.yml (accessible by pressing 'e' on the status panel from within lazygit) like so:

```yml
customCommands:
Expand Down Expand Up @@ -324,6 +324,25 @@ We don't support accessing all elements of a range selection yet. We might add t

If your custom keybinding collides with an inbuilt keybinding that is defined for the same context, only the custom keybinding will be executed. This also applies to the global context. However, one caveat is that if you have a custom keybinding defined on the global context for some key, and there is an in-built keybinding defined for the same key and for a specific context (say the 'files' context), then the in-built keybinding will take precedence. See how to change in-built keybindings [here](https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#keybindings)

## Menus of custom commands

For custom commands that are not used very frequently it may be preferable to hide them in a menu; you can assign a key to open the menu, and the commands will appear inside. This has the advantage that you don't have to come up with individual keybindings for all those commands that you don't use often. Here is an example:

```yml
customCommandsMenus:
- key: X
description: "Copy/paste commits across repos"
commands:
- key: c
command: 'git format-patch --stdout {{.SelectedCommitRange.From}}^..{{.SelectedCommitRange.To}} | pbcopy'
context: commits, subCommits
description: "Copy selected commits to clipboard"
- key: v
command: 'pbpaste | git am'
context: "commits"
description: "Paste selected commits from clipboard"
```

## Debugging

If you want to verify that your command actually does what you expect, you can wrap it in an 'echo' call and set `showOutput: true` so that it doesn't actually execute the command but you can see how the placeholders were resolved.
Expand Down
3 changes: 3 additions & 0 deletions pkg/config/app_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,15 @@ func loadUserConfig(configFiles []*ConfigFile, base *UserConfig) (*UserConfig, e
}

existingCustomCommands := base.CustomCommands
existingCustomCommandsMenus := base.CustomCommandsMenus

if err := yaml.Unmarshal(content, base); err != nil {
return nil, fmt.Errorf("The config at `%s` couldn't be parsed, please inspect it before opening up an issue.\n%w", path, err)
}

base.CustomCommands = append(base.CustomCommands, existingCustomCommands...)
// TODO: is this good enough? Should we maybe merge the customCommandsMenus based on their key bindings?
base.CustomCommandsMenus = append(base.CustomCommandsMenus, existingCustomCommandsMenus...)

if err := base.Validate(); err != nil {
return nil, fmt.Errorf("The config at `%s` has a validation error.\n%w", path, err)
Expand Down
19 changes: 19 additions & 0 deletions pkg/config/user_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ type UserConfig struct {
DisableStartupPopups bool `yaml:"disableStartupPopups"`
// User-configured commands that can be invoked from within Lazygit
CustomCommands []CustomCommand `yaml:"customCommands" jsonschema:"uniqueItems=true"`
// Menus containing custom commands. Useful for grouping not-so-frequently used commands under a single key binding.
CustomCommandsMenus []CustomCommandsMenu `yaml:"customCommandsMenus" jsonschema:"uniqueItems=true"`
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-pull-request-urls
Services map[string]string `yaml:"services"`
// What to do when opening Lazygit outside of a git repo.
Expand Down Expand Up @@ -636,6 +638,23 @@ type CustomCommand struct {
After CustomCommandAfterHook `yaml:"after"`
}

func (c *CustomCommand) GetDescription() string {
if c.Description != "" {
return c.Description
}

return c.Command
}

type CustomCommandsMenu struct {
// The key to open the menu. Use a single letter or one of the values from https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md
Key string `yaml:"key"`
// Label for the custom commands menu when displayed in the global keybindings menu
Description string `yaml:"description"`
// The commands to show in this menu
Commands []CustomCommand `yaml:"commands"`
}

type CustomCommandPrompt struct {
// One of: 'input' | 'menu' | 'confirm' | 'menuFromCommand'
Type string `yaml:"type"`
Expand Down
18 changes: 18 additions & 0 deletions pkg/config/user_config_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ func (config *UserConfig) Validate() error {
if err := validateCustomCommands(config.CustomCommands); err != nil {
return err
}
if err := validateCustomCommandsMenus(config.CustomCommandsMenus); err != nil {
return err
}
return nil
}

Expand Down Expand Up @@ -99,3 +102,18 @@ func validateCustomCommands(customCommands []CustomCommand) error {
}
return nil
}

func validateCustomCommandsMenus(customCommandsMenus []CustomCommandsMenu) error {
for _, customCommandsMenu := range customCommandsMenus {
if err := validateCustomCommandKey(customCommandsMenu.Key); err != nil {
return err
}

for _, customCommand := range customCommandsMenu.Commands {
if err := validateCustomCommandKey(customCommand.Key); err != nil {
return err
}
}
}
return nil
}
35 changes: 35 additions & 0 deletions pkg/config/user_config_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,41 @@ func TestUserConfigValidate_enums(t *testing.T) {
{value: "invalid_value", valid: false},
},
},
{
name: "Custom commands menu keybinding",
setup: func(config *UserConfig, value string) {
config.CustomCommandsMenus = []CustomCommandsMenu{
{
Key: value,
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "a", valid: true},
{value: "invalid_value", valid: false},
},
},
{
name: "Custom command in custom commands menu keybinding",
setup: func(config *UserConfig, value string) {
config.CustomCommandsMenus = []CustomCommandsMenu{
{
Commands: []CustomCommand{
{
Key: value,
Command: "echo 'hello'",
},
},
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "a", valid: true},
{value: "invalid_value", valid: false},
},
},
}

for _, s := range scenarios {
Expand Down
65 changes: 62 additions & 3 deletions pkg/gui/services/custom_commands/client.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
package custom_commands

import (
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/samber/lo"
)

// Client is the entry point to this package. It returns a list of keybindings based on the config's user-defined custom commands.
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Command_Keybindings.md for more info.
type Client struct {
c *common.Common
c *helpers.HelperCommon
handlerCreator *HandlerCreator
keybindingCreator *KeybindingCreator
}
Expand All @@ -28,14 +32,27 @@ func NewClient(
keybindingCreator := NewKeybindingCreator(c)

return &Client{
c: c.Common,
c: c,
keybindingCreator: keybindingCreator,
handlerCreator: handlerCreator,
}
}

func (self *Client) GetCustomCommandKeybindings() ([]*types.Binding, error) {
bindings := []*types.Binding{}
for _, customCommandsMenu := range self.c.UserConfig().CustomCommandsMenus {
handler := func() error {
return self.showCustomCommandsMenu(customCommandsMenu)
}
bindings = append(bindings, &types.Binding{
ViewName: "", // custom commands menus are global; we filter the commands inside by context
Key: keybindings.GetKey(customCommandsMenu.Key),
Modifier: gocui.ModNone,
Handler: handler,
Description: getCustomCommandsMenuDescription(customCommandsMenu, self.c.Tr),
})
}

for _, customCommand := range self.c.UserConfig().CustomCommands {
handler := self.handlerCreator.call(customCommand)
compoundBindings, err := self.keybindingCreator.call(customCommand, handler)
Expand All @@ -47,3 +64,45 @@ func (self *Client) GetCustomCommandKeybindings() ([]*types.Binding, error) {

return bindings, nil
}

func (self *Client) showCustomCommandsMenu(customCommandsMenu config.CustomCommandsMenu) error {
menuItems := make([]*types.MenuItem, 0, len(customCommandsMenu.Commands))
for _, command := range customCommandsMenu.Commands {
if command.Context != "" && command.Context != "global" {
viewNames, err := self.keybindingCreator.getViewNamesAndContexts(command)
if err != nil {
return err
}

currentView := self.c.GocuiGui().CurrentView()
enabled := currentView != nil && lo.Contains(viewNames, currentView.Name())
if !enabled {
continue
}
Comment on lines +79 to +81
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we omit commands that are not applicable in the current context. At first I was a bit torn about this, and considered including them with a DisabledReason so that they appear crossed out. However, omitting them is similar to what we do in the global keybindings menu, and actually I do find it important, because it allows us to configure different commands with the same key in different contexts.

}

menuItems = append(menuItems, &types.MenuItem{
Label: command.GetDescription(),
Key: keybindings.GetKey(command.Key),
OnPress: self.handlerCreator.call(command),
})
}

if len(menuItems) == 0 {
menuItems = append(menuItems, &types.MenuItem{
Label: self.c.Tr.NoApplicableCommandsInThisContext,
OnPress: func() error { return nil },
})
}

title := getCustomCommandsMenuDescription(customCommandsMenu, self.c.Tr)
return self.c.Menu(types.CreateMenuOptions{Title: title, Items: menuItems, HideCancel: true})
}

func getCustomCommandsMenuDescription(customCommandsMenu config.CustomCommandsMenu, tr *i18n.TranslationSet) string {
if customCommandsMenu.Description != "" {
return customCommandsMenu.Description
}

return tr.CustomCommands
}
7 changes: 1 addition & 6 deletions pkg/gui/services/custom_commands/keybinding_creator.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,13 @@ func (self *KeybindingCreator) call(customCommand config.CustomCommand, handler
return nil, err
}

description := customCommand.Description
if description == "" {
description = customCommand.Command
}

return lo.Map(viewNames, func(viewName string, _ int) *types.Binding {
return &types.Binding{
ViewName: viewName,
Key: keybindings.GetKey(customCommand.Key),
Modifier: gocui.ModNone,
Handler: handler,
Description: description,
Description: customCommand.GetDescription(),
}
}), nil
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/gui/services/custom_commands/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import (
// compatibility. We already did this for Commit.Sha, which was renamed to Hash.

type Commit struct {
Hash string // deprecated: use Sha
Sha string
Hash string
Sha string // deprecated: use Hash
Name string
Status models.CommitStatus
Action todo.TodoCommand
Expand Down
4 changes: 4 additions & 0 deletions pkg/i18n/english.go
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,8 @@ type TranslationSet struct {
RangeSelectNotSupportedForSubmodules string
OldCherryPickKeyWarning string
CommandDoesNotSupportOpeningInEditor string
CustomCommands string
NoApplicableCommandsInThisContext string
Actions Actions
Bisect Bisect
Log Log
Expand Down Expand Up @@ -1877,6 +1879,8 @@ func EnglishTranslationSet() *TranslationSet {
RangeSelectNotSupportedForSubmodules: "Range select not supported for submodules",
OldCherryPickKeyWarning: "The 'c' key is no longer the default key for copying commits to cherry pick. Please use `{{.copy}}` instead (and `{{.paste}}` to paste). The reason for this change is that the 'v' key for selecting a range of lines when staging is now also used for selecting a range of lines in any list view, meaning that we needed to find a new key for pasting commits, and if we're going to now use `{{.paste}}` for pasting commits, we may as well use `{{.copy}}` for copying them. If you want to configure the keybindings to get the old behaviour, set the following in your config:\n\nkeybinding:\n universal:\n toggleRangeSelect: <something other than v>\n commits:\n cherryPickCopy: 'c'\n pasteCommits: 'v'",
CommandDoesNotSupportOpeningInEditor: "This command doesn't support switching to the editor",
CustomCommands: "Custom commands",
NoApplicableCommandsInThisContext: "(No applicable commands in this context)",

Actions: Actions{
// TODO: combine this with the original keybinding descriptions (those are all in lowercase atm)
Expand Down
77 changes: 77 additions & 0 deletions pkg/integration/tests/custom_commands/custom_commands_menu.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package custom_commands

import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)

var CustomCommandsMenu = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Using custom commands from a custom commands menu",
ExtraCmdArgs: []string{},
Skip: false,
SetupRepo: func(shell *Shell) {
// shell.EmptyCommit("blah")
},
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().CustomCommandsMenus = []config.CustomCommandsMenu{
{
Key: "x",
Description: "My Custom Commands",
Commands: []config.CustomCommand{
{
Key: "1",
Context: "global",
Command: "touch myfile-global",
},
{
Key: "2",
Context: "files",
Command: "touch myfile-files",
},
{
Key: "3",
Context: "commits",
Command: "touch myfile-commits",
},
},
},
}
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Focus().
IsEmpty().
Press("x").
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("My Custom Commands")).
Lines(
Contains("1 touch myfile-global"),
Contains("2 touch myfile-files"),
).
Select(Contains("touch myfile-files")).Confirm()
}).
Lines(
Contains("myfile-files"),
)

t.Views().Commits().
Focus().
Press("x").
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("My Custom Commands")).
Lines(
Contains("1 touch myfile-global"),
Contains("3 touch myfile-commits"),
)
t.GlobalPress("3")
})

t.Views().Files().
Lines(
Contains("myfile-commits"),
Contains("myfile-files"),
)
},
})
1 change: 1 addition & 0 deletions pkg/integration/tests/test_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ var tests = []*components.IntegrationTest{
custom_commands.AccessCommitProperties,
custom_commands.BasicCommand,
custom_commands.CheckForConflicts,
custom_commands.CustomCommandsMenu,
custom_commands.FormPrompts,
custom_commands.GlobalContext,
custom_commands.MenuFromCommand,
Expand Down
Loading