From 107087b33acfbe0de3a858bcd606c12afcd89fb8 Mon Sep 17 00:00:00 2001 From: evan Date: Sat, 27 Jun 2026 08:48:50 +0800 Subject: [PATCH 1/2] Show loading text while resolving custom command prompts Prompt templates can run user commands before the final custom command starts. When those commands are slow, lazygit previously looked idle because the existing command-level loading text only covered the final command execution. Wrap prompt resolution, suggestion loading, and menuFromCommand generation with the prompt's loading text so users get progress feedback during each synchronous prompt preparation step. --- docs-master/Custom_Command_Keybindings.md | 16 ++ pkg/config/user_config.go | 2 + .../custom_commands/handler_creator.go | 53 ++++- .../custom_commands/handler_creator_test.go | 202 ++++++++++++++++++ pkg/gui/services/custom_commands/resolver.go | 1 + schema-master/config.json | 7 + 6 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 pkg/gui/services/custom_commands/handler_creator_test.go diff --git a/docs-master/Custom_Command_Keybindings.md b/docs-master/Custom_Command_Keybindings.md index 55e14d5f10a..120216cb8d7 100644 --- a/docs-master/Custom_Command_Keybindings.md +++ b/docs-master/Custom_Command_Keybindings.md @@ -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..f37dd54c29b 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -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/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go index 19694c48178..d02ced29219 100644 --- a/pkg/gui/services/custom_commands/handler_creator.go +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -73,7 +73,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 +81,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 +89,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 +97,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 +141,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 +192,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 +264,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 +287,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 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