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' - key: 'C'
context: 'global' context: 'global'
command: "git commit" command: "git commit"
subprocess: true output: terminal
- key: 'n' - key: 'n'
context: 'localBranches' context: 'localBranches'
prompts: 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 | | 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 | | 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 | | 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 | | 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 | | loadingText | Text to display while waiting for command to finish | no |
| description | Label for the custom command when displayed in the keybindings menu | 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 | | 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 |
| 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 output is set to 'popup'. If left unset, the command will be used as the title. | 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 |
| after | Actions to take after the command has completed | no | | after | Actions to take after the command has completed | no |
Here are the options for the `after` key: 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 ## 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 ## 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) 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... // Add more migrations here...
if !reflect.DeepEqual(rootNode, originalCopy) { 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 { func (c *AppConfig) GetDebug() bool {
return c.debug 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(` var largeConfiguration = []byte(`
# Config relating to the Lazygit UI # Config relating to the Lazygit UI
gui: 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"` 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) // 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"` 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 // A list of prompts that will request user input before running the final command
Prompts []CustomCommandPrompt `yaml:"prompts"` Prompts []CustomCommandPrompt `yaml:"prompts"`
// Text to display while waiting for command to finish // Text to display while waiting for command to finish
LoadingText string `yaml:"loadingText" jsonschema:"example=Loading..."` LoadingText string `yaml:"loadingText" jsonschema:"example=Loading..."`
// Label for the custom command when displayed in the keybindings menu // Label for the custom command when displayed in the keybindings menu
Description string `yaml:"description"` Description string `yaml:"description"`
// If true, stream the command's output to the Command Log panel // 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.
// [dev] Pointer to bool so that we can distinguish unset (nil) from false. Output string `yaml:"output" jsonschema:"enum=none,enum=terminal,enum=log,enum=logWithPty,enum=popup"`
Stream *bool `yaml:"stream"` // 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.
// 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.
OutputTitle string `yaml:"outputTitle"` OutputTitle string `yaml:"outputTitle"`
// Actions to take after the command has completed // Actions to take after the command has completed
// [dev] Pointer so that we can tell whether it appears in the config file // [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.CommandMenu) > 0 {
if len(customCommand.Context) > 0 || if len(customCommand.Context) > 0 ||
len(customCommand.Command) > 0 || len(customCommand.Command) > 0 ||
customCommand.Subprocess != nil ||
len(customCommand.Prompts) > 0 || len(customCommand.Prompts) > 0 ||
len(customCommand.LoadingText) > 0 || len(customCommand.LoadingText) > 0 ||
customCommand.Stream != nil || len(customCommand.Output) > 0 ||
customCommand.ShowOutput != nil ||
len(customCommand.OutputTitle) > 0 || len(customCommand.OutputTitle) > 0 ||
customCommand.After != nil { customCommand.After != nil {
commandRef := "" commandRef := ""
@ -121,6 +119,11 @@ func validateCustomCommands(customCommands []CustomCommand) error {
if err := validateCustomCommands(customCommand.CommandMenu); err != nil { if err := validateCustomCommands(customCommand.CommandMenu); err != nil {
return err return err
} }
} else {
if err := validateEnum("customCommand.output", customCommand.Output,
[]string{"", "none", "terminal", "log", "logWithPty", "popup"}); err != nil {
return err
}
} }
} }
return nil return nil

View file

@ -95,6 +95,25 @@ func TestUserConfigValidate_enums(t *testing.T) {
{value: "invalid_value", valid: false}, {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", name: "Custom command sub menu",
setup: func(config *UserConfig, _ string) { setup: func(config *UserConfig, _ string) {
@ -132,11 +151,10 @@ func TestUserConfigValidate_enums(t *testing.T) {
{ {
name: "Custom command sub menu", name: "Custom command sub menu",
setup: func(config *UserConfig, _ string) { setup: func(config *UserConfig, _ string) {
falseVal := false
config.CustomCommands = []CustomCommand{ config.CustomCommands = []CustomCommand{
{ {
Key: "X", Key: "X",
Subprocess: &falseVal, // other properties are not allowed for submenus (using subprocess as an example) LoadingText: "loading", // other properties are not allowed for submenus (using loadingText as an example)
CommandMenu: []CustomCommand{ CommandMenu: []CustomCommand{
{Key: "1", Command: "echo 'hello'", Context: "global"}, {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) 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) 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 { return self.c.WithWaitingStatus(loadingText, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.CustomCommand) self.c.LogAction(self.c.Tr.Actions.CustomCommand)
if customCommand.Stream != nil && *customCommand.Stream { if customCommand.Output == "log" || customCommand.Output == "logWithPty" {
cmdObj.StreamOutput() cmdObj.StreamOutput()
} }
if customCommand.Output == "logWithPty" {
cmdObj.UsePty()
}
output, err := cmdObj.RunWithOutput() output, err := cmdObj.RunWithOutput()
if refreshErr := self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); err != nil { 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 return err
} }
if customCommand.ShowOutput != nil && *customCommand.ShowOutput { if customCommand.Output == "popup" {
if strings.TrimSpace(output) == "" { if strings.TrimSpace(output) == "" {
output = self.c.Tr.EmptyOutput output = self.c.Tr.EmptyOutput
} }

View file

@ -15,19 +15,18 @@ var ShowOutputInPanel = NewIntegrationTest(NewIntegrationTestArgs{
shell.EmptyCommit("my change") shell.EmptyCommit("my change")
}, },
SetupConfig: func(cfg *config.AppConfig) { SetupConfig: func(cfg *config.AppConfig) {
trueVal := true
cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
{ {
Key: "X", Key: "X",
Context: "commits", Context: "commits",
Command: "printf '%s' '{{ .SelectedLocalCommit.Name }}'", Command: "printf '%s' '{{ .SelectedLocalCommit.Name }}'",
ShowOutput: &trueVal, Output: "popup",
}, },
{ {
Key: "Y", Key: "Y",
Context: "commits", Context: "commits",
Command: "printf '%s' '{{ .SelectedLocalCommit.Name }}'", Command: "printf '%s' '{{ .SelectedLocalCommit.Name }}'",
ShowOutput: &trueVal, Output: "popup",
OutputTitle: "Subject of commit {{ .SelectedLocalCommit.Hash }}", OutputTitle: "Subject of commit {{ .SelectedLocalCommit.Hash }}",
}, },
} }

View file

@ -96,10 +96,6 @@
"git fetch {{.Form.Remote}} {{.Form.Branch}} \u0026\u0026 git checkout FETCH_HEAD" "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": { "prompts": {
"items": { "items": {
"$ref": "#/$defs/CustomCommandPrompt" "$ref": "#/$defs/CustomCommandPrompt"
@ -118,17 +114,20 @@
"type": "string", "type": "string",
"description": "Label for the custom command when displayed in the keybindings menu" "description": "Label for the custom command when displayed in the keybindings menu"
}, },
"stream": { "output": {
"type": "boolean", "type": "string",
"description": "If true, stream the command's output to the Command Log panel" "enum": [
}, "none",
"showOutput": { "terminal",
"type": "boolean", "log",
"description": "If true, show the command's output in a popup within Lazygit" "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": { "outputTitle": {
"type": "string", "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": { "after": {
"$ref": "#/$defs/CustomCommandAfterHook", "$ref": "#/$defs/CustomCommandAfterHook",