Skip to content

Custom commands submenus #4324

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

Merged
merged 7 commits into from
Feb 28, 2025
Merged
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
23 changes: 22 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,27 @@ 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 unique keybindings for all those commands that you don't use often; the keybindings for the commands in the menu only need to be unique within the menu. Here is an example:

```yml
customCommands:
- key: X
description: "Copy/paste commits across repos"
commandMenu:
- 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"
```

If you use the commandMenu property, none of the other properties except key and description can be used.

## 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
23 changes: 19 additions & 4 deletions pkg/config/user_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -614,26 +614,41 @@ type CustomCommandAfterHook struct {
type CustomCommand struct {
// The key to trigger the command. 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"`
// Instead of defining a single custom command, create a menu of custom commands. Useful for grouping related commands together under a single keybinding, and for keeping them out of the global keybindings menu.
// When using this, all other fields except Key and Description are ignored and must be empty.
CommandMenu []CustomCommand `yaml:"commandMenu"`
// The context in which to listen for the key. Valid values are: status, files, worktrees, localBranches, remotes, remoteBranches, tags, commits, reflogCommits, subCommits, commitFiles, stash, and global. Multiple contexts separated by comma are allowed; most useful for "commits, subCommits" or "files, commitFiles".
Context string `yaml:"context" jsonschema:"example=status,example=files,example=worktrees,example=localBranches,example=remotes,example=remoteBranches,example=tags,example=commits,example=reflogCommits,example=subCommits,example=commitFiles,example=stash,example=global"`
// The command to run (using Go template syntax for placeholder values)
Command string `yaml:"command" jsonschema:"example=git fetch {{.Form.Remote}} {{.Form.Branch}} && git checkout FETCH_HEAD"`
// If true, run the command in a subprocess (e.g. if the command requires user input)
Subprocess bool `yaml:"subprocess"`
// [dev] Pointer to bool so that we can distinguish unset (nil) from false.
Subprocess *bool `yaml:"subprocess"`
// A list of prompts that will request user input before running the final command
Prompts []CustomCommandPrompt `yaml:"prompts"`
// Text to display while waiting for command to finish
LoadingText string `yaml:"loadingText" jsonschema:"example=Loading..."`
// Label for the custom command when displayed in the keybindings menu
Description string `yaml:"description"`
// If true, stream the command's output to the Command Log panel
Stream bool `yaml:"stream"`
// [dev] Pointer to bool so that we can distinguish unset (nil) from false.
Stream *bool `yaml:"stream"`
// If true, show the command's output in a popup within Lazygit
ShowOutput bool `yaml:"showOutput"`
// [dev] Pointer to bool so that we can distinguish unset (nil) from false.
ShowOutput *bool `yaml:"showOutput"`
// The title to display in the popup panel if showOutput is true. If left unset, the command will be used as the title.
OutputTitle string `yaml:"outputTitle"`
// Actions to take after the command has completed
After CustomCommandAfterHook `yaml:"after"`
// [dev] Pointer so that we can tell whether it appears in the config file
After *CustomCommandAfterHook `yaml:"after"`
}

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

return c.Command
}

type CustomCommandPrompt struct {
Expand Down
17 changes: 17 additions & 0 deletions pkg/config/user_config_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,23 @@ func validateCustomCommands(customCommands []CustomCommand) error {
if err := validateCustomCommandKey(customCommand.Key); err != nil {
return err
}

if len(customCommand.CommandMenu) > 0 &&
(len(customCommand.Context) > 0 ||
len(customCommand.Command) > 0 ||
customCommand.Subprocess != nil ||
len(customCommand.Prompts) > 0 ||
len(customCommand.LoadingText) > 0 ||
customCommand.Stream != nil ||
customCommand.ShowOutput != nil ||
len(customCommand.OutputTitle) > 0 ||
customCommand.After != nil) {
commandRef := ""
if len(customCommand.Key) > 0 {
commandRef = fmt.Sprintf(" with key '%s'", customCommand.Key)
}
return fmt.Errorf("Error with custom command%s: it is not allowed to use both commandMenu and any of the other fields except key and description.", commandRef)
}
}
return nil
}
52 changes: 52 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,58 @@ func TestUserConfigValidate_enums(t *testing.T) {
{value: "invalid_value", valid: false},
},
},
{
name: "Custom command sub menu",
setup: func(config *UserConfig, _ string) {
config.CustomCommands = []CustomCommand{
{
Key: "X",
Description: "My Custom Commands",
CommandMenu: []CustomCommand{
{Key: "1", Command: "echo 'hello'", Context: "global"},
},
},
}
},
testCases: []testCase{
{value: "", valid: true},
},
},
{
name: "Custom command sub menu",
setup: func(config *UserConfig, _ string) {
config.CustomCommands = []CustomCommand{
{
Key: "X",
Context: "global",
CommandMenu: []CustomCommand{
{Key: "1", Command: "echo 'hello'", Context: "global"},
},
},
}
},
testCases: []testCase{
{value: "", valid: false},
},
},
{
name: "Custom command sub menu",
setup: func(config *UserConfig, _ string) {
falseVal := false
config.CustomCommands = []CustomCommand{
{
Key: "X",
Subprocess: &falseVal,
CommandMenu: []CustomCommand{
{Key: "1", Command: "echo 'hello'", Context: "global"},
},
},
}
},
testCases: []testCase{
{value: "", valid: false},
},
},
}

for _, s := range scenarios {
Expand Down
88 changes: 80 additions & 8 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,7 +32,7 @@ func NewClient(
keybindingCreator := NewKeybindingCreator(c)

return &Client{
c: c.Common,
c: c,
keybindingCreator: keybindingCreator,
handlerCreator: handlerCreator,
}
Expand All @@ -37,13 +41,81 @@ func NewClient(
func (self *Client) GetCustomCommandKeybindings() ([]*types.Binding, error) {
bindings := []*types.Binding{}
for _, customCommand := range self.c.UserConfig().CustomCommands {
handler := self.handlerCreator.call(customCommand)
compoundBindings, err := self.keybindingCreator.call(customCommand, handler)
if err != nil {
return nil, err
if len(customCommand.CommandMenu) > 0 {
handler := func() error {
return self.showCustomCommandsMenu(customCommand)
}
bindings = append(bindings, &types.Binding{
ViewName: "", // custom commands menus are global; we filter the commands inside by context
Copy link
Owner

Choose a reason for hiding this comment

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

Why not allow optionally specifying a context on a top-level custom command menu?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good question; complexity? This would give us two levels of filtering by context, one for the menu itself and then again for the commands inside. I think this could be confusing, I find it easier to understand if menus are always global.

Copy link
Owner

Choose a reason for hiding this comment

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

I don't think that it would be confusing, but I also don't mind not supporting it at the moment. We can wait and see if the use case comes up.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm thinking about cases like this:

customCommands:
  - context: files
    commandMenu:
      - command: whatever
        context: commits

This sub-command would just never show up in the menu, ever. And I think you could easily run into cases like that in practice when you start to restructure your menus and move commands from one to another.

We could probably add validation to catch and disallow such cases, but it's actually not trivial if you think about the details, and I don't think it's worth it.

Key: keybindings.GetKey(customCommand.Key),
Modifier: gocui.ModNone,
Handler: handler,
Description: getCustomCommandsMenuDescription(customCommand, self.c.Tr),
OpensMenu: true,
})
} else {
handler := self.handlerCreator.call(customCommand)
compoundBindings, err := self.keybindingCreator.call(customCommand, handler)
if err != nil {
return nil, err
}
bindings = append(bindings, compoundBindings...)
}
bindings = append(bindings, compoundBindings...)
}

return bindings, nil
}

func (self *Client) showCustomCommandsMenu(customCommand config.CustomCommand) error {
menuItems := make([]*types.MenuItem, 0, len(customCommand.CommandMenu))
for _, subCommand := range customCommand.CommandMenu {
if len(subCommand.CommandMenu) > 0 {
handler := func() error {
return self.showCustomCommandsMenu(subCommand)
}
menuItems = append(menuItems, &types.MenuItem{
Label: subCommand.GetDescription(),
Key: keybindings.GetKey(subCommand.Key),
OnPress: handler,
OpensMenu: true,
})
} else {
if subCommand.Context != "" && subCommand.Context != "global" {
viewNames, err := self.keybindingCreator.getViewNamesAndContexts(subCommand)
if err != nil {
return err
}

currentView := self.c.GocuiGui().CurrentView()
enabled := currentView != nil && lo.Contains(viewNames, currentView.Name())
if !enabled {
continue
}
}

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

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

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

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

return tr.CustomCommands
}
8 changes: 4 additions & 4 deletions pkg/gui/services/custom_commands/handler_creator.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses

cmdObj := self.c.OS().Cmd.NewShell(cmdStr)

if customCommand.Subprocess {
if customCommand.Subprocess != nil && *customCommand.Subprocess {
return self.c.RunSubprocessAndRefresh(cmdObj)
}

Expand All @@ -273,7 +273,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
return self.c.WithWaitingStatus(loadingText, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.CustomCommand)

if customCommand.Stream {
if customCommand.Stream != nil && *customCommand.Stream {
cmdObj.StreamOutput()
}
output, err := cmdObj.RunWithOutput()
Expand All @@ -283,14 +283,14 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
}

if err != nil {
if customCommand.After.CheckForConflicts {
if customCommand.After != nil && customCommand.After.CheckForConflicts {
return self.mergeAndRebaseHelper.CheckForConflicts(err)
}

return err
}

if customCommand.ShowOutput {
if customCommand.ShowOutput != nil && *customCommand.ShowOutput {
if strings.TrimSpace(output) == "" {
output = self.c.Tr.EmptyOutput
}
Expand Down
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 @@ -1879,6 +1881,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
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ var CheckForConflicts = NewIntegrationTest(NewIntegrationTestArgs{
Key: "m",
Context: "localBranches",
Command: "git merge {{ .SelectedLocalBranch.Name | quote }}",
After: config.CustomCommandAfterHook{
After: &config.CustomCommandAfterHook{
CheckForConflicts: true,
},
},
Expand Down
Loading