mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-13 09:06:27 -04:00
When building multi-step custom command forms, some prompts are only
relevant depending on earlier answers. Without conditional logic,
users must dismiss irrelevant prompts manually.
Prompts now accept a `condition` field with a template expression
evaluated against prior form values. Skipped prompts default to
an empty string.
The template expression is a string pre- and suffixed with double curly
braces - {{}}.
Form keys can be reused, a guard ensures that skipped prompts do not
reset already set form keys with an empty string. This allows the
conditional flow to remind a user to set a key that was left empty
because additional conditions want that key to be set. This removes the
need to have additional if checks in the command that uses the form
keys.
346 lines
10 KiB
Go
346 lines
10 KiB
Go
package custom_commands
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"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/style"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
// takes a custom command and returns a function that will be called when the corresponding user-defined keybinding is pressed
|
|
type HandlerCreator struct {
|
|
c *helpers.HelperCommon
|
|
sessionStateLoader *SessionStateLoader
|
|
resolver *Resolver
|
|
menuGenerator *MenuGenerator
|
|
suggestionsHelper *helpers.SuggestionsHelper
|
|
mergeAndRebaseHelper *helpers.MergeAndRebaseHelper
|
|
}
|
|
|
|
func NewHandlerCreator(
|
|
c *helpers.HelperCommon,
|
|
sessionStateLoader *SessionStateLoader,
|
|
suggestionsHelper *helpers.SuggestionsHelper,
|
|
mergeAndRebaseHelper *helpers.MergeAndRebaseHelper,
|
|
) *HandlerCreator {
|
|
resolver := NewResolver(c.Common)
|
|
menuGenerator := NewMenuGenerator(c.Common)
|
|
|
|
return &HandlerCreator{
|
|
c: c,
|
|
sessionStateLoader: sessionStateLoader,
|
|
resolver: resolver,
|
|
menuGenerator: menuGenerator,
|
|
suggestionsHelper: suggestionsHelper,
|
|
mergeAndRebaseHelper: mergeAndRebaseHelper,
|
|
}
|
|
}
|
|
|
|
func (self *HandlerCreator) call(customCommand config.CustomCommand) func() error {
|
|
return func() error {
|
|
sessionState := self.sessionStateLoader.call()
|
|
promptResponses := make([]string, len(customCommand.Prompts))
|
|
form := make(map[string]string)
|
|
|
|
f := func() error { return self.finalHandler(customCommand, sessionState, promptResponses, form) }
|
|
|
|
// if we have prompts we'll recursively wrap our confirm handlers with more prompts
|
|
// until we reach the actual command
|
|
for reverseIdx := range customCommand.Prompts {
|
|
// reassigning so that we don't end up with an infinite recursion
|
|
g := f
|
|
idx := len(customCommand.Prompts) - 1 - reverseIdx
|
|
|
|
// going backwards so the outermost prompt is the first one
|
|
prompt := customCommand.Prompts[idx]
|
|
|
|
wrappedF := func(response string) error {
|
|
promptResponses[idx] = response
|
|
form[prompt.Key] = response
|
|
return g()
|
|
}
|
|
|
|
resolveTemplate := self.getResolveTemplateFn(form, promptResponses, sessionState)
|
|
|
|
switch prompt.Type {
|
|
case "input":
|
|
f = func() error {
|
|
resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return self.inputPrompt(resolvedPrompt, wrappedF)
|
|
}
|
|
case "menu":
|
|
f = func() error {
|
|
resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return self.menuPrompt(resolvedPrompt, wrappedF)
|
|
}
|
|
case "menuFromCommand":
|
|
f = func() error {
|
|
resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return self.menuPromptFromCommand(resolvedPrompt, wrappedF)
|
|
}
|
|
case "confirm":
|
|
f = func() error {
|
|
resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return self.confirmPrompt(resolvedPrompt, g)
|
|
}
|
|
default:
|
|
return errors.New("custom command prompt must have a type of 'input', 'menu', 'menuFromCommand', or 'confirm'")
|
|
}
|
|
|
|
if prompt.Condition != "" {
|
|
showPrompt := f
|
|
conditionTemplate := prompt.Condition
|
|
f = func() error {
|
|
resolved, err := resolveCondition(conditionTemplate, resolveTemplate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resolved {
|
|
return showPrompt()
|
|
}
|
|
if _, exists := form[prompt.Key]; !exists {
|
|
form[prompt.Key] = ""
|
|
}
|
|
return g()
|
|
}
|
|
}
|
|
}
|
|
|
|
return f()
|
|
}
|
|
}
|
|
|
|
func resolveCondition(condition string, resolveTemplate func(string) (string, error)) (bool, error) {
|
|
if strings.TrimSpace(condition) == "" {
|
|
return false, nil
|
|
}
|
|
resolved, err := resolveTemplate(condition)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return strings.TrimSpace(resolved) != "" && strings.TrimSpace(resolved) != "false", nil
|
|
}
|
|
|
|
func (self *HandlerCreator) inputPrompt(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error {
|
|
findSuggestionsFn, err := self.generateFindSuggestionsFunc(prompt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.Prompt(types.PromptOpts{
|
|
Title: prompt.Title,
|
|
InitialContent: prompt.InitialValue,
|
|
FindSuggestionsFunc: findSuggestionsFn,
|
|
HandleConfirm: func(str string) error {
|
|
return wrappedF(str)
|
|
},
|
|
AllowEmptyInput: true,
|
|
PreserveWhitespace: true,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *HandlerCreator) generateFindSuggestionsFunc(prompt *config.CustomCommandPrompt) (func(string) []*types.Suggestion, error) {
|
|
if prompt.Suggestions.Preset != "" && prompt.Suggestions.Command != "" {
|
|
return nil, fmt.Errorf(
|
|
"Custom command prompt cannot have both a preset and a command for suggestions. Preset: '%s', Command: '%s'",
|
|
prompt.Suggestions.Preset,
|
|
prompt.Suggestions.Command,
|
|
)
|
|
} else if prompt.Suggestions.Preset != "" {
|
|
return self.getPresetSuggestionsFn(prompt.Suggestions.Preset)
|
|
} else if prompt.Suggestions.Command != "" {
|
|
return self.getCommandSuggestionsFn(prompt.Suggestions.Command)
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func (self *HandlerCreator) getCommandSuggestionsFn(command string) (func(string) []*types.Suggestion, error) {
|
|
lines := []*types.Suggestion{}
|
|
err := self.c.OS().Cmd.NewShell(command, self.c.UserConfig().OS.ShellFunctionsFile).RunAndProcessLines(func(line string) (bool, error) {
|
|
lines = append(lines, &types.Suggestion{Value: line, Label: line})
|
|
return false, nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return func(currentWord string) []*types.Suggestion {
|
|
return lo.Filter(lines, func(suggestion *types.Suggestion, _ int) bool {
|
|
return strings.Contains(strings.ToLower(suggestion.Value), strings.ToLower(currentWord))
|
|
})
|
|
}, nil
|
|
}
|
|
|
|
func (self *HandlerCreator) getPresetSuggestionsFn(preset string) (func(string) []*types.Suggestion, error) {
|
|
switch preset {
|
|
case "authors":
|
|
return self.suggestionsHelper.GetAuthorsSuggestionsFunc(), nil
|
|
case "branches":
|
|
return self.suggestionsHelper.GetBranchNameSuggestionsFunc(), nil
|
|
case "files":
|
|
return self.suggestionsHelper.GetFilePathSuggestionsFunc(), nil
|
|
case "refs":
|
|
return self.suggestionsHelper.GetRefsSuggestionsFunc(), nil
|
|
case "remotes":
|
|
return self.suggestionsHelper.GetRemoteSuggestionsFunc(), nil
|
|
case "remoteBranches":
|
|
return self.suggestionsHelper.GetRemoteBranchesSuggestionsFunc("/"), nil
|
|
case "tags":
|
|
return self.suggestionsHelper.GetTagsSuggestionsFunc(), nil
|
|
default:
|
|
return nil, fmt.Errorf("Unknown value for suggestionsPreset in custom command: %s. Valid values: files, branches, remotes, remoteBranches, refs", preset)
|
|
}
|
|
}
|
|
|
|
func (self *HandlerCreator) confirmPrompt(prompt *config.CustomCommandPrompt, handleConfirm func() error) error {
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: prompt.Title,
|
|
Prompt: prompt.Body,
|
|
HandleConfirm: handleConfirm,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *HandlerCreator) menuPrompt(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error {
|
|
menuItems := lo.Map(prompt.Options, func(option config.CustomCommandMenuOption, _ int) *types.MenuItem {
|
|
return &types.MenuItem{
|
|
LabelColumns: []string{option.Name, style.FgYellow.Sprint(option.Description)},
|
|
OnPress: func() error {
|
|
return wrappedF(option.Value)
|
|
},
|
|
Key: keybindings.GetKey(option.Key),
|
|
}
|
|
})
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{Title: prompt.Title, Items: menuItems})
|
|
}
|
|
|
|
func (self *HandlerCreator) menuPromptFromCommand(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error {
|
|
// Run and save output
|
|
message, err := self.c.Git().Custom.RunWithOutput(prompt.Command)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Need to make a menu out of what the cmd has displayed
|
|
candidates, err := self.menuGenerator.call(message, prompt.Filter, prompt.ValueFormat, prompt.LabelFormat)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
menuItems := lo.Map(candidates, func(candidate *commandMenuItem, _ int) *types.MenuItem {
|
|
return &types.MenuItem{
|
|
LabelColumns: []string{candidate.label},
|
|
OnPress: func() error {
|
|
return wrappedF(candidate.value)
|
|
},
|
|
}
|
|
})
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{Title: prompt.Title, Items: menuItems})
|
|
}
|
|
|
|
type CustomCommandObjects struct {
|
|
*SessionState
|
|
PromptResponses []string
|
|
Form map[string]string
|
|
}
|
|
|
|
func (self *HandlerCreator) getResolveTemplateFn(form map[string]string, promptResponses []string, sessionState *SessionState) func(string) (string, error) {
|
|
objects := CustomCommandObjects{
|
|
SessionState: sessionState,
|
|
PromptResponses: promptResponses,
|
|
Form: form,
|
|
}
|
|
|
|
funcs := template.FuncMap{
|
|
"quote": self.c.OS().Quote,
|
|
"runCommand": self.c.Git().Custom.TemplateFunctionRunCommand,
|
|
}
|
|
|
|
return func(templateStr string) (string, error) { return utils.ResolveTemplate(templateStr, objects, funcs) }
|
|
}
|
|
|
|
func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, sessionState *SessionState, promptResponses []string, form map[string]string) error {
|
|
resolveTemplate := self.getResolveTemplateFn(form, promptResponses, sessionState)
|
|
cmdStr, err := resolveTemplate(customCommand.Command)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cmdObj := self.c.OS().Cmd.NewShell(cmdStr, self.c.UserConfig().OS.ShellFunctionsFile)
|
|
|
|
if customCommand.Output == "terminal" {
|
|
return self.c.RunSubprocessAndRefresh(cmdObj)
|
|
}
|
|
|
|
loadingText := customCommand.LoadingText
|
|
if loadingText == "" {
|
|
loadingText = self.c.Tr.RunningCustomCommandStatus
|
|
}
|
|
|
|
return self.c.WithWaitingStatus(loadingText, func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.CustomCommand)
|
|
|
|
if customCommand.Output == "log" || customCommand.Output == "logWithPty" {
|
|
cmdObj.StreamOutput()
|
|
}
|
|
if customCommand.Output == "logWithPty" {
|
|
cmdObj.UsePty()
|
|
}
|
|
output, err := cmdObj.RunWithOutput()
|
|
|
|
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
|
|
|
|
if err != nil {
|
|
if customCommand.After != nil && customCommand.After.CheckForConflicts {
|
|
return self.mergeAndRebaseHelper.CheckForConflicts(err)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
if customCommand.Output == "popup" {
|
|
if strings.TrimSpace(output) == "" {
|
|
output = self.c.Tr.EmptyOutput
|
|
}
|
|
|
|
title := cmdStr
|
|
if customCommand.OutputTitle != "" {
|
|
title, err = resolveTemplate(customCommand.OutputTitle)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
self.c.Alert(title, output)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|