Skip to content

Commit 1b571f9

Browse files
authored
Custom commands submenus (#4324)
- **PR Description** I want to be able to configure custom commands that I don't need very often; I don't want these to pollute the global keybindings menu, and I don't want to assign globally unique keybindings to them (because there are only so many of these available, and also because I wouldn't be able to remember them, because the commands are not used often). However, I still want to invoke them through keybindings somehow. I find that the perfect solution for this is to configure a menu that contains custom commands. I can pop open the menu using only one key that I need to remember, but I can access the individual custom commands inside using keys that don't need to be unique with the rest of the global keybindings. In this PR we achieve this by adding an optional `subCommands` property to customCommand that can be used instead of the other properties like `command`, etc. This is an alternative approach to #4276, which added a new top-level property for custom command menus. Potentially closes #3799. - **Please check if the PR fulfills these requirements** * [x] Cheatsheets are up-to-date (run `go generate ./...`) * [x] Code has been formatted (see [here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting)) * [x] Tests have been added/updated (see [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md) for the integration test guide) * [x] Text is internationalised (see [here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation)) * [x] If a new UserConfig entry was added, make sure it can be hot-reloaded (see [here](https://github.com/jesseduffield/lazygit/blob/master/docs/dev/Codebase_Guide.md#using-userconfig)) * [x] Docs have been updated if necessary * [x] You've read through your own file changes for silly mistakes etc
2 parents a7b0ccf + df17896 commit 1b571f9

16 files changed

+294
-36
lines changed

docs/Custom_Command_Keybindings.md

+22-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Custom Command Keybindings
22

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

55
```yml
66
customCommands:
@@ -324,6 +324,27 @@ We don't support accessing all elements of a range selection yet. We might add t
324324

325325
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)
326326

327+
## Menus of custom commands
328+
329+
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:
330+
331+
```yml
332+
customCommands:
333+
- key: X
334+
description: "Copy/paste commits across repos"
335+
commandMenu:
336+
- key: c
337+
command: 'git format-patch --stdout {{.SelectedCommitRange.From}}^..{{.SelectedCommitRange.To}} | pbcopy'
338+
context: commits, subCommits
339+
description: "Copy selected commits to clipboard"
340+
- key: v
341+
command: 'pbpaste | git am'
342+
context: "commits"
343+
description: "Paste selected commits from clipboard"
344+
```
345+
346+
If you use the commandMenu property, none of the other properties except key and description can be used.
347+
327348
## Debugging
328349

329350
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.

pkg/config/user_config.go

+19-4
Original file line numberDiff line numberDiff line change
@@ -614,26 +614,41 @@ type CustomCommandAfterHook struct {
614614
type CustomCommand struct {
615615
// 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
616616
Key string `yaml:"key"`
617+
// 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.
618+
// When using this, all other fields except Key and Description are ignored and must be empty.
619+
CommandMenu []CustomCommand `yaml:"commandMenu"`
617620
// 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".
618621
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"`
619622
// The command to run (using Go template syntax for placeholder values)
620623
Command string `yaml:"command" jsonschema:"example=git fetch {{.Form.Remote}} {{.Form.Branch}} && git checkout FETCH_HEAD"`
621624
// If true, run the command in a subprocess (e.g. if the command requires user input)
622-
Subprocess bool `yaml:"subprocess"`
625+
// [dev] Pointer to bool so that we can distinguish unset (nil) from false.
626+
Subprocess *bool `yaml:"subprocess"`
623627
// A list of prompts that will request user input before running the final command
624628
Prompts []CustomCommandPrompt `yaml:"prompts"`
625629
// Text to display while waiting for command to finish
626630
LoadingText string `yaml:"loadingText" jsonschema:"example=Loading..."`
627631
// Label for the custom command when displayed in the keybindings menu
628632
Description string `yaml:"description"`
629633
// If true, stream the command's output to the Command Log panel
630-
Stream bool `yaml:"stream"`
634+
// [dev] Pointer to bool so that we can distinguish unset (nil) from false.
635+
Stream *bool `yaml:"stream"`
631636
// If true, show the command's output in a popup within Lazygit
632-
ShowOutput bool `yaml:"showOutput"`
637+
// [dev] Pointer to bool so that we can distinguish unset (nil) from false.
638+
ShowOutput *bool `yaml:"showOutput"`
633639
// The title to display in the popup panel if showOutput is true. If left unset, the command will be used as the title.
634640
OutputTitle string `yaml:"outputTitle"`
635641
// Actions to take after the command has completed
636-
After CustomCommandAfterHook `yaml:"after"`
642+
// [dev] Pointer so that we can tell whether it appears in the config file
643+
After *CustomCommandAfterHook `yaml:"after"`
644+
}
645+
646+
func (c *CustomCommand) GetDescription() string {
647+
if c.Description != "" {
648+
return c.Description
649+
}
650+
651+
return c.Command
637652
}
638653

639654
type CustomCommandPrompt struct {

pkg/config/user_config_validation.go

+17
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,23 @@ func validateCustomCommands(customCommands []CustomCommand) error {
9696
if err := validateCustomCommandKey(customCommand.Key); err != nil {
9797
return err
9898
}
99+
100+
if len(customCommand.CommandMenu) > 0 &&
101+
(len(customCommand.Context) > 0 ||
102+
len(customCommand.Command) > 0 ||
103+
customCommand.Subprocess != nil ||
104+
len(customCommand.Prompts) > 0 ||
105+
len(customCommand.LoadingText) > 0 ||
106+
customCommand.Stream != nil ||
107+
customCommand.ShowOutput != nil ||
108+
len(customCommand.OutputTitle) > 0 ||
109+
customCommand.After != nil) {
110+
commandRef := ""
111+
if len(customCommand.Key) > 0 {
112+
commandRef = fmt.Sprintf(" with key '%s'", customCommand.Key)
113+
}
114+
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)
115+
}
99116
}
100117
return nil
101118
}

pkg/config/user_config_validation_test.go

+52
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,58 @@ func TestUserConfigValidate_enums(t *testing.T) {
7474
{value: "invalid_value", valid: false},
7575
},
7676
},
77+
{
78+
name: "Custom command sub menu",
79+
setup: func(config *UserConfig, _ string) {
80+
config.CustomCommands = []CustomCommand{
81+
{
82+
Key: "X",
83+
Description: "My Custom Commands",
84+
CommandMenu: []CustomCommand{
85+
{Key: "1", Command: "echo 'hello'", Context: "global"},
86+
},
87+
},
88+
}
89+
},
90+
testCases: []testCase{
91+
{value: "", valid: true},
92+
},
93+
},
94+
{
95+
name: "Custom command sub menu",
96+
setup: func(config *UserConfig, _ string) {
97+
config.CustomCommands = []CustomCommand{
98+
{
99+
Key: "X",
100+
Context: "global",
101+
CommandMenu: []CustomCommand{
102+
{Key: "1", Command: "echo 'hello'", Context: "global"},
103+
},
104+
},
105+
}
106+
},
107+
testCases: []testCase{
108+
{value: "", valid: false},
109+
},
110+
},
111+
{
112+
name: "Custom command sub menu",
113+
setup: func(config *UserConfig, _ string) {
114+
falseVal := false
115+
config.CustomCommands = []CustomCommand{
116+
{
117+
Key: "X",
118+
Subprocess: &falseVal,
119+
CommandMenu: []CustomCommand{
120+
{Key: "1", Command: "echo 'hello'", Context: "global"},
121+
},
122+
},
123+
}
124+
},
125+
testCases: []testCase{
126+
{value: "", valid: false},
127+
},
128+
},
77129
}
78130

79131
for _, s := range scenarios {

pkg/gui/services/custom_commands/client.go

+80-8
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
package custom_commands
22

33
import (
4-
"github.com/jesseduffield/lazygit/pkg/common"
4+
"github.com/jesseduffield/gocui"
5+
"github.com/jesseduffield/lazygit/pkg/config"
56
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
7+
"github.com/jesseduffield/lazygit/pkg/gui/keybindings"
68
"github.com/jesseduffield/lazygit/pkg/gui/types"
9+
"github.com/jesseduffield/lazygit/pkg/i18n"
10+
"github.com/samber/lo"
711
)
812

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

3034
return &Client{
31-
c: c.Common,
35+
c: c,
3236
keybindingCreator: keybindingCreator,
3337
handlerCreator: handlerCreator,
3438
}
@@ -37,13 +41,81 @@ func NewClient(
3741
func (self *Client) GetCustomCommandKeybindings() ([]*types.Binding, error) {
3842
bindings := []*types.Binding{}
3943
for _, customCommand := range self.c.UserConfig().CustomCommands {
40-
handler := self.handlerCreator.call(customCommand)
41-
compoundBindings, err := self.keybindingCreator.call(customCommand, handler)
42-
if err != nil {
43-
return nil, err
44+
if len(customCommand.CommandMenu) > 0 {
45+
handler := func() error {
46+
return self.showCustomCommandsMenu(customCommand)
47+
}
48+
bindings = append(bindings, &types.Binding{
49+
ViewName: "", // custom commands menus are global; we filter the commands inside by context
50+
Key: keybindings.GetKey(customCommand.Key),
51+
Modifier: gocui.ModNone,
52+
Handler: handler,
53+
Description: getCustomCommandsMenuDescription(customCommand, self.c.Tr),
54+
OpensMenu: true,
55+
})
56+
} else {
57+
handler := self.handlerCreator.call(customCommand)
58+
compoundBindings, err := self.keybindingCreator.call(customCommand, handler)
59+
if err != nil {
60+
return nil, err
61+
}
62+
bindings = append(bindings, compoundBindings...)
4463
}
45-
bindings = append(bindings, compoundBindings...)
4664
}
4765

4866
return bindings, nil
4967
}
68+
69+
func (self *Client) showCustomCommandsMenu(customCommand config.CustomCommand) error {
70+
menuItems := make([]*types.MenuItem, 0, len(customCommand.CommandMenu))
71+
for _, subCommand := range customCommand.CommandMenu {
72+
if len(subCommand.CommandMenu) > 0 {
73+
handler := func() error {
74+
return self.showCustomCommandsMenu(subCommand)
75+
}
76+
menuItems = append(menuItems, &types.MenuItem{
77+
Label: subCommand.GetDescription(),
78+
Key: keybindings.GetKey(subCommand.Key),
79+
OnPress: handler,
80+
OpensMenu: true,
81+
})
82+
} else {
83+
if subCommand.Context != "" && subCommand.Context != "global" {
84+
viewNames, err := self.keybindingCreator.getViewNamesAndContexts(subCommand)
85+
if err != nil {
86+
return err
87+
}
88+
89+
currentView := self.c.GocuiGui().CurrentView()
90+
enabled := currentView != nil && lo.Contains(viewNames, currentView.Name())
91+
if !enabled {
92+
continue
93+
}
94+
}
95+
96+
menuItems = append(menuItems, &types.MenuItem{
97+
Label: subCommand.GetDescription(),
98+
Key: keybindings.GetKey(subCommand.Key),
99+
OnPress: self.handlerCreator.call(subCommand),
100+
})
101+
}
102+
}
103+
104+
if len(menuItems) == 0 {
105+
menuItems = append(menuItems, &types.MenuItem{
106+
Label: self.c.Tr.NoApplicableCommandsInThisContext,
107+
OnPress: func() error { return nil },
108+
})
109+
}
110+
111+
title := getCustomCommandsMenuDescription(customCommand, self.c.Tr)
112+
return self.c.Menu(types.CreateMenuOptions{Title: title, Items: menuItems, HideCancel: true})
113+
}
114+
115+
func getCustomCommandsMenuDescription(customCommand config.CustomCommand, tr *i18n.TranslationSet) string {
116+
if customCommand.Description != "" {
117+
return customCommand.Description
118+
}
119+
120+
return tr.CustomCommands
121+
}

pkg/gui/services/custom_commands/handler_creator.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
261261

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

264-
if customCommand.Subprocess {
264+
if customCommand.Subprocess != nil && *customCommand.Subprocess {
265265
return self.c.RunSubprocessAndRefresh(cmdObj)
266266
}
267267

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

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

285285
if err != nil {
286-
if customCommand.After.CheckForConflicts {
286+
if customCommand.After != nil && customCommand.After.CheckForConflicts {
287287
return self.mergeAndRebaseHelper.CheckForConflicts(err)
288288
}
289289

290290
return err
291291
}
292292

293-
if customCommand.ShowOutput {
293+
if customCommand.ShowOutput != nil && *customCommand.ShowOutput {
294294
if strings.TrimSpace(output) == "" {
295295
output = self.c.Tr.EmptyOutput
296296
}

pkg/gui/services/custom_commands/keybinding_creator.go

+1-6
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,13 @@ func (self *KeybindingCreator) call(customCommand config.CustomCommand, handler
3434
return nil, err
3535
}
3636

37-
description := customCommand.Description
38-
if description == "" {
39-
description = customCommand.Command
40-
}
41-
4237
return lo.Map(viewNames, func(viewName string, _ int) *types.Binding {
4338
return &types.Binding{
4439
ViewName: viewName,
4540
Key: keybindings.GetKey(customCommand.Key),
4641
Modifier: gocui.ModNone,
4742
Handler: handler,
48-
Description: description,
43+
Description: customCommand.GetDescription(),
4944
}
5045
}), nil
5146
}

pkg/gui/services/custom_commands/models.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import (
1414
// compatibility. We already did this for Commit.Sha, which was renamed to Hash.
1515

1616
type Commit struct {
17-
Hash string // deprecated: use Sha
18-
Sha string
17+
Hash string
18+
Sha string // deprecated: use Hash
1919
Name string
2020
Status models.CommitStatus
2121
Action todo.TodoCommand

pkg/i18n/english.go

+4
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,8 @@ type TranslationSet struct {
843843
RangeSelectNotSupportedForSubmodules string
844844
OldCherryPickKeyWarning string
845845
CommandDoesNotSupportOpeningInEditor string
846+
CustomCommands string
847+
NoApplicableCommandsInThisContext string
846848
Actions Actions
847849
Bisect Bisect
848850
Log Log
@@ -1879,6 +1881,8 @@ func EnglishTranslationSet() *TranslationSet {
18791881
RangeSelectNotSupportedForSubmodules: "Range select not supported for submodules",
18801882
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'",
18811883
CommandDoesNotSupportOpeningInEditor: "This command doesn't support switching to the editor",
1884+
CustomCommands: "Custom commands",
1885+
NoApplicableCommandsInThisContext: "(No applicable commands in this context)",
18821886

18831887
Actions: Actions{
18841888
// TODO: combine this with the original keybinding descriptions (those are all in lowercase atm)

pkg/integration/tests/custom_commands/check_for_conflicts.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ var CheckForConflicts = NewIntegrationTest(NewIntegrationTestArgs{
1919
Key: "m",
2020
Context: "localBranches",
2121
Command: "git merge {{ .SelectedLocalBranch.Name | quote }}",
22-
After: config.CustomCommandAfterHook{
22+
After: &config.CustomCommandAfterHook{
2323
CheckForConflicts: true,
2424
},
2525
},

0 commit comments

Comments
 (0)