diff --git a/docs/Custom_Command_Keybindings.md b/docs/Custom_Command_Keybindings.md index 62c84d28e..aef9c3500 100644 --- a/docs/Custom_Command_Keybindings.md +++ b/docs/Custom_Command_Keybindings.md @@ -14,7 +14,7 @@ customCommands: - key: 'C' context: 'global' command: "git commit" - subprocess: true + output: terminal - key: 'n' context: 'localBranches' prompts: @@ -53,13 +53,11 @@ For a given custom command, here are the allowed fields: | key | The key to trigger the command. Use a single letter or one of the values from [here](https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md). Custom commands without a key specified can be triggered by selecting them from the keybindings (`?`) menu | no | | command | The command to run (using Go template syntax for placeholder values) | yes | | context | The context in which to listen for the key (see [below](#contexts)) | yes | -| subprocess | Whether you want the command to run in a subprocess (e.g. if the command requires user input) | no | | prompts | A list of prompts that will request user input before running the final command | no | | loadingText | Text to display while waiting for command to finish | no | | description | Label for the custom command when displayed in the keybindings menu | no | -| stream | Whether you want to stream the command's output to the Command Log panel | no | -| showOutput | Whether you want to show the command's output in a popup within Lazygit | no | -| outputTitle | The title to display in the popup panel if showOutput is true. If left unset, the command will be used as the title. | no | +| output | Where the output of the command should go. 'none' discards it, 'terminal' suspends lazygit and runs the command in the terminal (useful for commands that require user input), 'log' streams it to the command log, 'logWithPty' is like 'log' but runs the command in a pseudo terminal (can be useful for commands that produce colored output when the output is a terminal), and 'popup' shows it in a popup. | no | +| outputTitle | The title to display in the popup panel if output is set to 'popup'. If left unset, the command will be used as the title. | no | | after | Actions to take after the command has completed | no | Here are the options for the `after` key: @@ -365,7 +363,7 @@ If you use the commandMenu property, none of the other properties except key and ## 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. +If you want to verify that your command actually does what you expect, you can wrap it in an 'echo' call and set `output: popup` so that it doesn't actually execute the command but you can see how the placeholders were resolved. ## More Examples diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index 12b377f96..59a91aa5f 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -281,6 +281,11 @@ func computeMigratedConfig(path string, content []byte) ([]byte, error) { return nil, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err) } + err = changeCustomCommandStreamAndOutputToOutputEnum(&rootNode) + if err != nil { + return nil, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err) + } + // Add more migrations here... if !reflect.DeepEqual(rootNode, originalCopy) { @@ -341,6 +346,46 @@ func changeCommitPrefixesMap(rootNode *yaml.Node) error { }) } +func changeCustomCommandStreamAndOutputToOutputEnum(rootNode *yaml.Node) error { + return yaml_utils.Walk(rootNode, func(node *yaml.Node, path string) { + // We are being lazy here and rely on the fact that the only mapping + // nodes in the tree under customCommands are actual custom commands. If + // this ever changes (e.g. because we add a struct field to + // customCommand), then we need to change this to iterate properly. + if strings.HasPrefix(path, "customCommands[") && node.Kind == yaml.MappingNode { + output := "" + if streamKey, streamValue := yaml_utils.RemoveKey(node, "subprocess"); streamKey != nil { + if streamValue.Kind == yaml.ScalarNode && streamValue.Value == "true" { + output = "terminal" + } + } + if streamKey, streamValue := yaml_utils.RemoveKey(node, "stream"); streamKey != nil { + if streamValue.Kind == yaml.ScalarNode && streamValue.Value == "true" && output == "" { + output = "log" + } + } + if streamKey, streamValue := yaml_utils.RemoveKey(node, "showOutput"); streamKey != nil { + if streamValue.Kind == yaml.ScalarNode && streamValue.Value == "true" && output == "" { + output = "popup" + } + } + if output != "" { + outputKeyNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Value: "output", + Tag: "!!str", + } + outputValueNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Value: output, + Tag: "!!str", + } + node.Content = append(node.Content, outputKeyNode, outputValueNode) + } + } + }) +} + func (c *AppConfig) GetDebug() bool { return c.debug } diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go index c9199fdc7..15e0732a7 100644 --- a/pkg/config/app_config_test.go +++ b/pkg/config/app_config_test.go @@ -88,6 +88,92 @@ git: } } +func TestCustomCommandsOutputMigration(t *testing.T) { + scenarios := []struct { + name string + input string + expected string + }{ + { + name: "Empty String", + input: "", + expected: "", + }, { + name: "Convert subprocess to output=terminal", + input: `customCommands: + - command: echo 'hello' + subprocess: true + `, + expected: `customCommands: + - command: echo 'hello' + output: terminal +`, + }, { + name: "Convert stream to output=log", + input: `customCommands: + - command: echo 'hello' + stream: true + `, + expected: `customCommands: + - command: echo 'hello' + output: log +`, + }, { + name: "Convert showOutput to output=popup", + input: `customCommands: + - command: echo 'hello' + showOutput: true + `, + expected: `customCommands: + - command: echo 'hello' + output: popup +`, + }, { + name: "Subprocess wins over the other two", + input: `customCommands: + - command: echo 'hello' + subprocess: true + stream: true + showOutput: true + `, + expected: `customCommands: + - command: echo 'hello' + output: terminal +`, + }, { + name: "Stream wins over showOutput", + input: `customCommands: + - command: echo 'hello' + stream: true + showOutput: true + `, + expected: `customCommands: + - command: echo 'hello' + output: log +`, + }, { + name: "Explicitly setting to false doesn't create an output=none key", + input: `customCommands: + - command: echo 'hello' + subprocess: false + stream: false + showOutput: false + `, + expected: `customCommands: + - command: echo 'hello' +`, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + actual, err := computeMigratedConfig("path doesn't matter", []byte(s.input)) + assert.NoError(t, err) + assert.Equal(t, s.expected, string(actual)) + }) + } +} + var largeConfiguration = []byte(` # Config relating to the Lazygit UI gui: diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index d35e35772..9064303e2 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -637,22 +637,15 @@ type CustomCommand struct { 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) - // [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 - // [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 - // [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. + // Where the output of the command should go. 'none' discards it, 'terminal' suspends lazygit and runs the command in the terminal (useful for commands that require user input), 'log' streams it to the command log, 'logWithPty' is like 'log' but runs the command in a pseudo terminal (can be useful for commands that produce colored output when the output is a terminal), and 'popup' shows it in a popup. + Output string `yaml:"output" jsonschema:"enum=none,enum=terminal,enum=log,enum=logWithPty,enum=popup"` + // The title to display in the popup panel if output is set to 'popup'. If left unset, the command will be used as the title. OutputTitle string `yaml:"outputTitle"` // Actions to take after the command has completed // [dev] Pointer so that we can tell whether it appears in the config file diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index ffb52d875..a84b70aa7 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -104,11 +104,9 @@ func validateCustomCommands(customCommands []CustomCommand) error { if len(customCommand.CommandMenu) > 0 { if 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.Output) > 0 || len(customCommand.OutputTitle) > 0 || customCommand.After != nil { commandRef := "" @@ -121,6 +119,11 @@ func validateCustomCommands(customCommands []CustomCommand) error { if err := validateCustomCommands(customCommand.CommandMenu); err != nil { return err } + } else { + if err := validateEnum("customCommand.output", customCommand.Output, + []string{"", "none", "terminal", "log", "logWithPty", "popup"}); 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 87186a354..8440d89b2 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -95,6 +95,25 @@ func TestUserConfigValidate_enums(t *testing.T) { {value: "invalid_value", valid: false}, }, }, + { + name: "Custom command output", + setup: func(config *UserConfig, value string) { + config.CustomCommands = []CustomCommand{ + { + Output: value, + }, + } + }, + testCases: []testCase{ + {value: "", valid: true}, + {value: "none", valid: true}, + {value: "terminal", valid: true}, + {value: "log", valid: true}, + {value: "logWithPty", valid: true}, + {value: "popup", valid: true}, + {value: "invalid_value", valid: false}, + }, + }, { name: "Custom command sub menu", setup: func(config *UserConfig, _ string) { @@ -132,11 +151,10 @@ func TestUserConfigValidate_enums(t *testing.T) { { name: "Custom command sub menu", setup: func(config *UserConfig, _ string) { - falseVal := false config.CustomCommands = []CustomCommand{ { - Key: "X", - Subprocess: &falseVal, // other properties are not allowed for submenus (using subprocess as an example) + Key: "X", + LoadingText: "loading", // other properties are not allowed for submenus (using loadingText as an example) CommandMenu: []CustomCommand{ {Key: "1", Command: "echo 'hello'", Context: "global"}, }, diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go index 58e4d0aff..35c3feb05 100644 --- a/pkg/gui/services/custom_commands/handler_creator.go +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -262,7 +262,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses cmdObj := self.c.OS().Cmd.NewShell(cmdStr, self.c.UserConfig().OS.ShellFunctionsFile) - if customCommand.Subprocess != nil && *customCommand.Subprocess { + if customCommand.Output == "terminal" { return self.c.RunSubprocessAndRefresh(cmdObj) } @@ -274,9 +274,12 @@ 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 != nil && *customCommand.Stream { + if customCommand.Output == "log" || customCommand.Output == "logWithPty" { cmdObj.StreamOutput() } + if customCommand.Output == "logWithPty" { + cmdObj.UsePty() + } output, err := cmdObj.RunWithOutput() if refreshErr := self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); err != nil { @@ -291,7 +294,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses return err } - if customCommand.ShowOutput != nil && *customCommand.ShowOutput { + if customCommand.Output == "popup" { if strings.TrimSpace(output) == "" { output = self.c.Tr.EmptyOutput } diff --git a/pkg/integration/tests/custom_commands/show_output_in_panel.go b/pkg/integration/tests/custom_commands/show_output_in_panel.go index 7f00d8a5b..9fcab1be3 100644 --- a/pkg/integration/tests/custom_commands/show_output_in_panel.go +++ b/pkg/integration/tests/custom_commands/show_output_in_panel.go @@ -15,19 +15,18 @@ var ShowOutputInPanel = NewIntegrationTest(NewIntegrationTestArgs{ shell.EmptyCommit("my change") }, SetupConfig: func(cfg *config.AppConfig) { - trueVal := true cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "X", - Context: "commits", - Command: "printf '%s' '{{ .SelectedLocalCommit.Name }}'", - ShowOutput: &trueVal, + Key: "X", + Context: "commits", + Command: "printf '%s' '{{ .SelectedLocalCommit.Name }}'", + Output: "popup", }, { Key: "Y", Context: "commits", Command: "printf '%s' '{{ .SelectedLocalCommit.Name }}'", - ShowOutput: &trueVal, + Output: "popup", OutputTitle: "Subject of commit {{ .SelectedLocalCommit.Hash }}", }, } diff --git a/schema/config.json b/schema/config.json index 96a968e4d..86391465f 100644 --- a/schema/config.json +++ b/schema/config.json @@ -96,10 +96,6 @@ "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": { "$ref": "#/$defs/CustomCommandPrompt" @@ -118,17 +114,20 @@ "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" + "output": { + "type": "string", + "enum": [ + "none", + "terminal", + "log", + "logWithPty", + "popup" + ], + "description": "Where the output of the command should go. 'none' discards it, 'terminal' suspends lazygit and runs the command in the terminal (useful for commands that require user input), 'log' streams it to the command log, 'logWithPty' is like 'log' but runs the command in a pseudo terminal (can be useful for commands that produce colored output when the output is a terminal), and 'popup' shows it in a popup." }, "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." + "description": "The title to display in the popup panel if output is set to 'popup'. If left unset, the command will be used as the title." }, "after": { "$ref": "#/$defs/CustomCommandAfterHook",