diff --git a/docs-master/Custom_Command_Keybindings.md b/docs-master/Custom_Command_Keybindings.md index 55e14d5f10a..a7c6218b6a7 100644 --- a/docs-master/Custom_Command_Keybindings.md +++ b/docs-master/Custom_Command_Keybindings.md @@ -56,7 +56,7 @@ For a given custom command, here are the allowed fields: | 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 | -| 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 | +| 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), 'popup' shows it in a popup, and 'commitMessagePanel' opens the commit message panel with the command output as the initial message. | 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 | @@ -102,6 +102,7 @@ These fields are applicable to all prompts. | type | One of 'input', 'confirm', 'menu', 'menuFromCommand' | yes | | title | The title to display in the popup panel | no | | key | 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 | yes | +| loadingText | Text to display while resolving the prompt, loading suggestions, or generating `menuFromCommand` options | no | | condition | A Go template expression; if it resolves to empty string or `false`, the prompt is skipped. See [Conditional prompts](#conditional-prompts) | no | ### Input @@ -162,6 +163,21 @@ customCommands: initialValue: "{{.SelectedRemote.Name}}" ``` +If the initial value depends on a slow command, you can show a waiting status while the value is generated: + +```yml +customCommands: + - key: 'a' + command: 'git commit -m {{.Form.Message | quote}}' + context: 'files' + prompts: + - type: 'input' + title: 'Commit message' + key: 'Message' + loadingText: 'Generating commit message' + initialValue: '{{ runCommand "ai-commit-message" }}' +``` + ### Confirm | _field_ | _description_ | _required_ | diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 1b582c1a0ee..eb4673213fd 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -724,8 +724,8 @@ type CustomCommand struct { LoadingText string `yaml:"loadingText" jsonschema:"example=Loading..."` // Label for the custom command when displayed in the keybindings menu Description string `yaml:"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. - Output string `yaml:"output" jsonschema:"enum=none,enum=terminal,enum=log,enum=logWithPty,enum=popup"` + // 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), 'popup' shows it in a popup, and 'commitMessagePanel' opens the commit message panel with the command output as the initial message. + Output string `yaml:"output" jsonschema:"enum=none,enum=terminal,enum=log,enum=logWithPty,enum=popup,enum=commitMessagePanel"` // 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 @@ -748,6 +748,8 @@ type CustomCommandPrompt struct { Key string `yaml:"key"` // The title to display in the popup panel Title string `yaml:"title"` + // Text to display while resolving the prompt, loading suggestions, or generating menuFromCommand options. + LoadingText string `yaml:"loadingText" jsonschema:"example=Loading..."` // The initial value to appear in the text box. // Only for input prompts. diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 9550e916064..63dc23b0cdb 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -222,7 +222,7 @@ func validateCustomCommands(customCommands []CustomCommand) error { } if err := validateEnum("customCommand.output", customCommand.Output, - []string{"", "none", "terminal", "log", "logWithPty", "popup"}); err != nil { + []string{"", "none", "terminal", "log", "logWithPty", "popup", "commitMessagePanel"}); err != nil { return err } } diff --git a/pkg/gui/services/custom_commands/client.go b/pkg/gui/services/custom_commands/client.go index 3f16b8ba85d..afff7bcf3e2 100644 --- a/pkg/gui/services/custom_commands/client.go +++ b/pkg/gui/services/custom_commands/client.go @@ -26,6 +26,7 @@ func NewClient( sessionStateLoader, helpers.Suggestions, helpers.MergeAndRebase, + helpers.WorkingTree, ) keybindingCreator := NewKeybindingCreator(c) diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go index 19694c48178..bf58c7299e7 100644 --- a/pkg/gui/services/custom_commands/handler_creator.go +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -23,6 +23,7 @@ type HandlerCreator struct { menuGenerator *MenuGenerator suggestionsHelper *helpers.SuggestionsHelper mergeAndRebaseHelper *helpers.MergeAndRebaseHelper + workingTreeHelper *helpers.WorkingTreeHelper } func NewHandlerCreator( @@ -30,6 +31,7 @@ func NewHandlerCreator( sessionStateLoader *SessionStateLoader, suggestionsHelper *helpers.SuggestionsHelper, mergeAndRebaseHelper *helpers.MergeAndRebaseHelper, + workingTreeHelper *helpers.WorkingTreeHelper, ) *HandlerCreator { resolver := NewResolver(c.Common) menuGenerator := NewMenuGenerator(c.Common) @@ -41,6 +43,7 @@ func NewHandlerCreator( menuGenerator: menuGenerator, suggestionsHelper: suggestionsHelper, mergeAndRebaseHelper: mergeAndRebaseHelper, + workingTreeHelper: workingTreeHelper, } } @@ -73,7 +76,7 @@ func (self *HandlerCreator) call(customCommand config.CustomCommand) func() erro switch prompt.Type { case "input": f = func() error { - resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate) + resolvedPrompt, err := self.resolvePrompt(&prompt, resolveTemplate) if err != nil { return err } @@ -81,7 +84,7 @@ func (self *HandlerCreator) call(customCommand config.CustomCommand) func() erro } case "menu": f = func() error { - resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate) + resolvedPrompt, err := self.resolvePrompt(&prompt, resolveTemplate) if err != nil { return err } @@ -89,7 +92,7 @@ func (self *HandlerCreator) call(customCommand config.CustomCommand) func() erro } case "menuFromCommand": f = func() error { - resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate) + resolvedPrompt, err := self.resolvePrompt(&prompt, resolveTemplate) if err != nil { return err } @@ -97,7 +100,7 @@ func (self *HandlerCreator) call(customCommand config.CustomCommand) func() erro } case "confirm": f = func() error { - resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate) + resolvedPrompt, err := self.resolvePrompt(&prompt, resolveTemplate) if err != nil { return err } @@ -141,6 +144,27 @@ func resolveCondition(condition string, resolveTemplate func(string) (string, er return strings.TrimSpace(resolved) != "" && strings.TrimSpace(resolved) != "false", nil } +func (self *HandlerCreator) resolvePrompt( + prompt *config.CustomCommandPrompt, + resolveTemplate func(string) (string, error), +) (*config.CustomCommandPrompt, error) { + var resolvedPrompt *config.CustomCommandPrompt + err := self.withPromptLoading(prompt, func() error { + var err error + resolvedPrompt, err = self.resolver.resolvePrompt(prompt, resolveTemplate) + return err + }) + return resolvedPrompt, err +} + +func (self *HandlerCreator) withPromptLoading(prompt *config.CustomCommandPrompt, f func() error) error { + if prompt.LoadingText == "" { + return f() + } + + return self.c.WithWaitingStatusSync(prompt.LoadingText, f) +} + func (self *HandlerCreator) inputPrompt(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error { findSuggestionsFn, err := self.generateFindSuggestionsFunc(prompt) if err != nil { @@ -171,17 +195,19 @@ func (self *HandlerCreator) generateFindSuggestionsFunc(prompt *config.CustomCom } else if prompt.Suggestions.Preset != "" { return self.getPresetSuggestionsFn(prompt.Suggestions.Preset) } else if prompt.Suggestions.Command != "" { - return self.getCommandSuggestionsFn(prompt.Suggestions.Command) + return self.getCommandSuggestionsFn(prompt) } return nil, nil } -func (self *HandlerCreator) getCommandSuggestionsFn(command string) (func(string) []*types.Suggestion, error) { +func (self *HandlerCreator) getCommandSuggestionsFn(prompt *config.CustomCommandPrompt) (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 + err := self.withPromptLoading(prompt, func() error { + return self.c.OS().Cmd.NewShell(prompt.Suggestions.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 @@ -241,7 +267,7 @@ func (self *HandlerCreator) menuPrompt(prompt *config.CustomCommandPrompt, wrapp 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) + message, err := self.runMenuPromptCommand(prompt) if err != nil { return err } @@ -264,6 +290,16 @@ func (self *HandlerCreator) menuPromptFromCommand(prompt *config.CustomCommandPr return self.c.Menu(types.CreateMenuOptions{Title: prompt.Title, Items: menuItems}) } +func (self *HandlerCreator) runMenuPromptCommand(prompt *config.CustomCommandPrompt) (string, error) { + var message string + err := self.withPromptLoading(prompt, func() error { + var err error + message, err = self.c.Git().Custom.RunWithOutput(prompt.Command) + return err + }) + return message, err +} + type CustomCommandObjects struct { *SessionState PromptResponses []string @@ -303,6 +339,26 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses loadingText = self.c.Tr.RunningCustomCommandStatus } + if customCommand.Output == "commitMessagePanel" { + if self.workingTreeHelper == nil { + return errors.New("custom command output 'commitMessagePanel' requires the working tree helper") + } + + var output string + err := self.c.WithWaitingStatusSync(loadingText, func() error { + self.c.LogAction(self.c.Tr.Actions.CustomCommand) + + var err error + output, err = cmdObj.RunWithOutput() + return err + }) + if err != nil { + return self.handleCommandError(customCommand, err) + } + + return self.workingTreeHelper.HandleCommitPressWithMessage(strings.TrimRight(output, "\r\n"), false) + } + return self.c.WithWaitingStatus(loadingText, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.CustomCommand) @@ -317,11 +373,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses 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 + return self.handleCommandError(customCommand, err) } if customCommand.Output == "popup" { @@ -342,3 +394,11 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses return nil }) } + +func (self *HandlerCreator) handleCommandError(customCommand config.CustomCommand, err error) error { + if customCommand.After != nil && customCommand.After.CheckForConflicts { + return self.mergeAndRebaseHelper.CheckForConflicts(err) + } + + return err +} diff --git a/pkg/gui/services/custom_commands/handler_creator_test.go b/pkg/gui/services/custom_commands/handler_creator_test.go new file mode 100644 index 00000000000..f08648d875e --- /dev/null +++ b/pkg/gui/services/custom_commands/handler_creator_test.go @@ -0,0 +1,202 @@ +package custom_commands + +import ( + "strings" + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands" + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" + "github.com/jesseduffield/lazygit/pkg/commands/git_config" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/common" + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/stretchr/testify/assert" +) + +type promptLoadingGui struct { + types.IGuiCommon + + t *testing.T + + loadingActive bool + loadingMessages []string + git *commands.GitCommand + os *oscommands.OSCommand + menuOpts *types.CreateMenuOptions +} + +func (self *promptLoadingGui) WithWaitingStatusSync(message string, f func() error) error { + assert.False(self.t, self.loadingActive) + + self.loadingMessages = append(self.loadingMessages, message) + self.loadingActive = true + defer func() { self.loadingActive = false }() + + return f() +} + +func (self *promptLoadingGui) Git() *commands.GitCommand { + return self.git +} + +func (self *promptLoadingGui) OS() *oscommands.OSCommand { + return self.os +} + +func (self *promptLoadingGui) Menu(opts types.CreateMenuOptions) error { + assert.False(self.t, self.loadingActive) + + self.menuOpts = &opts + return nil +} + +func TestResolvePromptRunsTemplateResolutionWithPromptLoadingText(t *testing.T) { + t.Parallel() + + gui := &promptLoadingGui{t: t} + handler := newHandlerCreatorForPromptLoadingTest(gui) + + prompt := &config.CustomCommandPrompt{ + Type: "input", + Title: "Pick value", + LoadingText: "Generating value", + InitialValue: "{{ runCommand \"generate-value\" }}", + } + + resolvedPrompt, err := handler.resolvePrompt(prompt, func(template string) (string, error) { + assert.True(t, gui.loadingActive) + return "resolved: " + template, nil + }) + + if !assert.NoError(t, err) || !assert.NotNil(t, resolvedPrompt) { + return + } + assert.Equal(t, []string{"Generating value"}, gui.loadingMessages) + assert.Equal(t, "Generating value", resolvedPrompt.LoadingText) + assert.Equal(t, "resolved: {{ runCommand \"generate-value\" }}", resolvedPrompt.InitialValue) +} + +func TestResolvePromptSkipsPromptLoadingWhenLoadingTextIsEmpty(t *testing.T) { + t.Parallel() + + gui := &promptLoadingGui{t: t} + handler := newHandlerCreatorForPromptLoadingTest(gui) + + prompt := &config.CustomCommandPrompt{ + Type: "input", + Title: "Pick value", + InitialValue: "static value", + } + + resolvedPrompt, err := handler.resolvePrompt(prompt, func(template string) (string, error) { + assert.False(t, gui.loadingActive) + return template, nil + }) + + if !assert.NoError(t, err) || !assert.NotNil(t, resolvedPrompt) { + return + } + assert.Empty(t, gui.loadingMessages) + assert.Equal(t, "static value", resolvedPrompt.InitialValue) +} + +func TestInputPromptRunsSuggestionsCommandWithPromptLoadingText(t *testing.T) { + t.Parallel() + + gui := &promptLoadingGui{t: t} + runner := oscommands.NewFakeRunner(t).ExpectFunc( + "runs the suggestions command while the prompt loading status is active", + func(cmdObj *oscommands.CmdObj) bool { + return gui.loadingActive && strings.Contains(cmdObj.ToString(), "list-suggestions") + }, + "one\ntwo", + nil, + ) + gui.os = oscommands.NewDummyOSCommandWithRunner(runner) + + handler := newHandlerCreatorForPromptLoadingTest(gui) + findSuggestionsFn, err := handler.generateFindSuggestionsFunc(&config.CustomCommandPrompt{ + Type: "input", + Title: "Pick value", + LoadingText: "Loading suggestions", + Suggestions: config.CustomCommandSuggestions{ + Command: "list-suggestions", + }, + }) + + if !assert.NoError(t, err) || !assert.NotNil(t, findSuggestionsFn) { + return + } + runner.CheckForMissingCalls() + assert.Equal(t, []string{"Loading suggestions"}, gui.loadingMessages) + assert.Equal(t, []*types.Suggestion{ + {Value: "one", Label: "one"}, + {Value: "two", Label: "two"}, + }, findSuggestionsFn("")) +} + +func TestMenuPromptFromCommandRunsCommandWithPromptLoadingText(t *testing.T) { + t.Parallel() + + cmn := common.NewDummyCommon() + gui := &promptLoadingGui{t: t} + runner := oscommands.NewFakeRunner(t).ExpectFunc( + "runs the menu command while the prompt loading status is active", + func(cmdObj *oscommands.CmdObj) bool { + return gui.loadingActive && strings.Join(cmdObj.GetCmd().Args, " ") == "list-options" + }, + "first\nsecond", + nil, + ) + + gui.git = &commands.GitCommand{ + Custom: git_commands.NewCustomCommands(git_commands.NewGitCommon( + cmn, + &git_commands.GitVersion{Major: 2}, + oscommands.NewDummyCmdObjBuilder(runner), + oscommands.NewDummyOSCommandWithRunner(runner), + git_commands.MockRepoPaths("."), + git_commands.NewConfigCommands(cmn, git_config.NewFakeGitConfig(nil)), + config.NewPagerConfig(cmn.UserConfig), + )), + } + + handler := newHandlerCreatorForPromptLoadingTest(gui) + err := handler.menuPromptFromCommand(&config.CustomCommandPrompt{ + Type: "menuFromCommand", + Title: "Choose an option", + LoadingText: "Loading options", + Command: "list-options", + Filter: "(?P