From 24f9a5531a41042ae65a92276f46a9d4889b32dc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 15 Feb 2025 13:55:07 +0100 Subject: [PATCH 1/4] Fix wrong comment This was backwards; we renamed Sha to Hash, so Sha is now deprecated, not Hash. --- pkg/gui/services/custom_commands/models.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/gui/services/custom_commands/models.go b/pkg/gui/services/custom_commands/models.go index 261bace45ac..eee52ff5d0b 100644 --- a/pkg/gui/services/custom_commands/models.go +++ b/pkg/gui/services/custom_commands/models.go @@ -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 From 4003a5d091019a9f570b60eec823d3f0d0c8042b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 16 Feb 2025 18:41:43 +0100 Subject: [PATCH 2/4] Fix a small mistake in Custom_Command_Keybindings.md It has been 'e' instead of 'o' for quite a while now. --- docs/Custom_Command_Keybindings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Custom_Command_Keybindings.md b/docs/Custom_Command_Keybindings.md index b940a7bb3c4..c9959aa1da6 100644 --- a/docs/Custom_Command_Keybindings.md +++ b/docs/Custom_Command_Keybindings.md @@ -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: From 9dd9d70ad560e6b96956bebfceb8a0938e2542cb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 15 Feb 2025 16:19:19 +0100 Subject: [PATCH 3/4] Extract a method CustomCommand.GetDescription We'll reuse it in the next commit. --- pkg/config/user_config.go | 8 ++++++++ pkg/gui/services/custom_commands/keybinding_creator.go | 7 +------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index d6a27733e60..28023cfb93f 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -636,6 +636,14 @@ type CustomCommand struct { After CustomCommandAfterHook `yaml:"after"` } +func (c *CustomCommand) GetDescription() string { + if c.Description != "" { + return c.Description + } + + return c.Command +} + type CustomCommandPrompt struct { // One of: 'input' | 'menu' | 'confirm' | 'menuFromCommand' Type string `yaml:"type"` diff --git a/pkg/gui/services/custom_commands/keybinding_creator.go b/pkg/gui/services/custom_commands/keybinding_creator.go index 4e6f9d8c777..259954c17bb 100644 --- a/pkg/gui/services/custom_commands/keybinding_creator.go +++ b/pkg/gui/services/custom_commands/keybinding_creator.go @@ -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 } From 835b58ca3d28f3955d6fde08c7d46e49f02867fa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 15 Feb 2025 17:49:15 +0100 Subject: [PATCH 4/4] Add user config customCommandsMenus --- docs/Custom_Command_Keybindings.md | 19 ++ pkg/config/app_config.go | 3 + pkg/config/user_config.go | 11 + pkg/config/user_config_validation.go | 18 ++ pkg/config/user_config_validation_test.go | 35 +++ pkg/gui/services/custom_commands/client.go | 65 +++++- pkg/i18n/english.go | 4 + .../custom_commands/custom_commands_menu.go | 77 +++++++ pkg/integration/tests/test_list.go | 1 + schema/config.json | 210 ++++++++++++++++++ 10 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 pkg/integration/tests/custom_commands/custom_commands_menu.go diff --git a/docs/Custom_Command_Keybindings.md b/docs/Custom_Command_Keybindings.md index c9959aa1da6..857e74cc647 100644 --- a/docs/Custom_Command_Keybindings.md +++ b/docs/Custom_Command_Keybindings.md @@ -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. diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index cfdc75e3156..f8c70124a10 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -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) diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 28023cfb93f..4fe751e9171 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -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. @@ -644,6 +646,15 @@ func (c *CustomCommand) GetDescription() string { 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"` diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 735c5aad2c2..a6382783f9f 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -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 } @@ -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 +} diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index 1d842801966..50c065aed59 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -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 { diff --git a/pkg/gui/services/custom_commands/client.go b/pkg/gui/services/custom_commands/client.go index 6cb1bf19c7b..7956539a6c7 100644 --- a/pkg/gui/services/custom_commands/client.go +++ b/pkg/gui/services/custom_commands/client.go @@ -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 } @@ -28,7 +32,7 @@ func NewClient( keybindingCreator := NewKeybindingCreator(c) return &Client{ - c: c.Common, + c: c, keybindingCreator: keybindingCreator, handlerCreator: handlerCreator, } @@ -36,6 +40,19 @@ func NewClient( 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) @@ -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 + } + } + + 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 +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 3bd2ca5285b..ecfacfafb76 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -843,6 +843,8 @@ type TranslationSet struct { RangeSelectNotSupportedForSubmodules string OldCherryPickKeyWarning string CommandDoesNotSupportOpeningInEditor string + CustomCommands string + NoApplicableCommandsInThisContext string Actions Actions Bisect Bisect Log Log @@ -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: \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) diff --git a/pkg/integration/tests/custom_commands/custom_commands_menu.go b/pkg/integration/tests/custom_commands/custom_commands_menu.go new file mode 100644 index 00000000000..0451abf091e --- /dev/null +++ b/pkg/integration/tests/custom_commands/custom_commands_menu.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index d0dc2a8a06a..9f6a49a5fad 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -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, diff --git a/schema/config.json b/schema/config.json index ef8a642cf86..d9cb1a20341 100644 --- a/schema/config.json +++ b/schema/config.json @@ -1070,6 +1070,216 @@ "uniqueItems": true, "description": "User-configured commands that can be invoked from within Lazygit" }, + "customCommandsMenus": { + "items": { + "properties": { + "key": { + "type": "string", + "description": "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" + }, + "description": { + "type": "string", + "description": "Label for the custom commands menu when displayed in the global keybindings menu" + }, + "commands": { + "items": { + "properties": { + "key": { + "type": "string", + "description": "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" + }, + "context": { + "type": "string", + "description": "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\".", + "examples": [ + "status", + "files", + "worktrees", + "localBranches", + "remotes", + "remoteBranches", + "tags", + "commits", + "reflogCommits", + "subCommits", + "commitFiles", + "stash", + "global" + ] + }, + "command": { + "type": "string", + "description": "The command to run (using Go template syntax for placeholder values)", + "examples": [ + "git fetch {{.Form.Remote}} {{.Form.Branch}} \u0026\u0026 git checkout FETCH_HEAD" + ] + }, + "subprocess": { + "type": "boolean", + "description": "If true, run the command in a subprocess (e.g. if the command requires user input)" + }, + "prompts": { + "items": { + "properties": { + "type": { + "type": "string", + "description": "One of: 'input' | 'menu' | 'confirm' | 'menuFromCommand'" + }, + "key": { + "type": "string", + "description": "Used to reference the entered value from within the custom command. E.g. a prompt with `key: 'Branch'` can be referred to as `{{.Form.Branch}}` in the command" + }, + "title": { + "type": "string", + "description": "The title to display in the popup panel" + }, + "initialValue": { + "type": "string", + "description": "The initial value to appear in the text box.\nOnly for input prompts." + }, + "suggestions": { + "properties": { + "preset": { + "type": "string", + "enum": [ + "authors", + "branches", + "files", + "refs", + "remotes", + "remoteBranches", + "tags" + ], + "description": "Uses built-in logic to obtain the suggestions. One of 'authors' | 'branches' | 'files' | 'refs' | 'remotes' | 'remoteBranches' | 'tags'" + }, + "command": { + "type": "string", + "description": "Command to run such that each line in the output becomes a suggestion. Mutually exclusive with 'preset' field.", + "examples": [ + "git fetch {{.Form.Remote}} {{.Form.Branch}} \u0026\u0026 git checkout FETCH_HEAD" + ] + } + }, + "additionalProperties": false, + "type": "object", + "description": "Shows suggestions as the input is entered\nOnly for input prompts." + }, + "body": { + "type": "string", + "description": "The message of the confirmation prompt.\nOnly for confirm prompts.", + "examples": [ + "Are you sure you want to push to the remote?" + ] + }, + "options": { + "items": { + "properties": { + "name": { + "type": "string", + "description": "The first part of the label" + }, + "description": { + "type": "string", + "description": "The second part of the label" + }, + "value": { + "type": "string", + "minLength": 1, + "description": "The value that will be used in the command", + "examples": [ + "feature" + ] + } + }, + "additionalProperties": false, + "type": "object" + }, + "type": "array", + "description": "Menu options.\nOnly for menu prompts." + }, + "command": { + "type": "string", + "description": "The command to run to generate menu options\nOnly for menuFromCommand prompts.", + "examples": [ + "git fetch {{.Form.Remote}} {{.Form.Branch}} \u0026\u0026 git checkout FETCH_HEAD" + ] + }, + "filter": { + "type": "string", + "description": "The regexp to run specifying groups which are going to be kept from the command's output.\nOnly for menuFromCommand prompts.", + "examples": [ + ".*{{.SelectedRemote.Name }}/(?P\u003cbranch\u003e.*)" + ] + }, + "valueFormat": { + "type": "string", + "description": "How to format matched groups from the filter to construct a menu item's value.\nOnly for menuFromCommand prompts.", + "examples": [ + "{{ .branch }}" + ] + }, + "labelFormat": { + "type": "string", + "description": "Like valueFormat but for the labels. If `labelFormat` is not specified, `valueFormat` is shown instead.\nOnly for menuFromCommand prompts.", + "examples": [ + "{{ .branch | green }}" + ] + } + }, + "additionalProperties": false, + "type": "object" + }, + "type": "array", + "description": "A list of prompts that will request user input before running the final command" + }, + "loadingText": { + "type": "string", + "description": "Text to display while waiting for command to finish", + "examples": [ + "Loading..." + ] + }, + "description": { + "type": "string", + "description": "Label for the custom command when displayed in the keybindings menu" + }, + "stream": { + "type": "boolean", + "description": "If true, stream the command's output to the Command Log panel" + }, + "showOutput": { + "type": "boolean", + "description": "If true, show the command's output in a popup within Lazygit" + }, + "outputTitle": { + "type": "string", + "description": "The title to display in the popup panel if showOutput is true. If left unset, the command will be used as the title." + }, + "after": { + "properties": { + "checkForConflicts": { + "type": "boolean" + } + }, + "additionalProperties": false, + "type": "object", + "description": "Actions to take after the command has completed" + } + }, + "additionalProperties": false, + "type": "object" + }, + "type": "array", + "description": "The commands to show in this menu" + } + }, + "additionalProperties": false, + "type": "object" + }, + "type": "array", + "uniqueItems": true, + "description": "Menus containing custom commands. Useful for grouping not-so-frequently used commands under a single key binding." + }, "services": { "additionalProperties": { "type": "string"