Combine customCommand's subprocess, stream, and showOutput fields into a single output enum

This commit is contained in:
Stefan Haller 2025-04-30 17:13:33 +02:00
parent 5f4be3bfb7
commit a9f9dee30d
9 changed files with 187 additions and 43 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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"},
},

View file

@ -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
}

View file

@ -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 }}",
},
}

View file

@ -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",