diff --git a/docs-master/Custom_Command_Keybindings.md b/docs-master/Custom_Command_Keybindings.md index 18e37463e..c8036ea41 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 | +| condition | A Go template expression; if it resolves to empty string or `false`, the prompt is skipped. See [Conditional prompts](#conditional-prompts) | no | ### Input @@ -319,6 +320,41 @@ Here's an example using a command but not specifying anything else: so each line command: 'ls' ``` +### Conditional prompts + +Here's an example of a conditional prompt: + +```yml +customCommands: + - key: 'a' + context: 'localBranches' + prompts: + - type: 'menu' + title: 'How do you want to create the branch?' + key: 'Method' + options: + - value: 'simple' + name: 'Simple' + description: 'just a branch name' + - value: 'prefix' + name: 'With prefix' + description: 'with a category prefix' + - type: 'menu' + title: 'Branch prefix' + key: 'Prefix' + condition: '{{ eq .Form.Method "prefix" }}' + options: + - value: 'feature/' + - value: 'hotfix/' + - value: 'release/' + - type: 'input' + title: 'Branch name' + key: 'Name' + command: "git checkout -b '{{.Form.Prefix}}{{.Form.Name}}'" +``` + +In this example the 'Branch prefix' menu only appears if the user chose 'With prefix'. Otherwise it is skipped and `.Form.Prefix` defaults to empty string. + ## Placeholder values Your commands can contain placeholder strings using Go's [template syntax](https://jan.newmarch.name/golang/template/chapter-template.html). The template syntax is pretty powerful, letting you do things like conditionals if you want, but for the most part you'll simply want to be accessing the fields on the following objects: diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index fde680d12..29778d639 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -730,6 +730,9 @@ type CustomCommandPrompt struct { // Like valueFormat but for the labels. If `labelFormat` is not specified, `valueFormat` is shown instead. // Only for menuFromCommand prompts. LabelFormat string `yaml:"labelFormat" jsonschema:"example={{ .branch | green }}"` + + // A Go template expression evaluated against the current form state. If it resolves to empty string or 'false', the prompt is skipped. + Condition string `yaml:"condition" jsonschema:"example={{ eq .Form.Choice \"yes\" }}"` } type CustomCommandSuggestions struct { diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go index a10689f2d..a6634c07b 100644 --- a/pkg/gui/services/custom_commands/handler_creator.go +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -107,12 +107,41 @@ func (self *HandlerCreator) call(customCommand config.CustomCommand) func() erro 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 { diff --git a/pkg/integration/tests/custom_commands/conditional_prompt_false_string.go b/pkg/integration/tests/custom_commands/conditional_prompt_false_string.go new file mode 100644 index 000000000..a60db1036 --- /dev/null +++ b/pkg/integration/tests/custom_commands/conditional_prompt_false_string.go @@ -0,0 +1,71 @@ +package custom_commands + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ConditionalPromptFalseString = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Conditional prompt is skipped when condition is bare false or template false", + ExtraCmdArgs: []string{}, + Skip: false, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("blah") + }, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ + { + Key: "a", + Context: "files", + Command: `echo "{{.Form.Choice}}" > result.txt`, + Prompts: []config.CustomCommandPrompt{ + { + Key: "Choice", + Type: "menu", + Title: "Pick one", + Options: []config.CustomCommandMenuOption{ + { + Name: "foo", + Description: "Foo", + Value: "FOO", + }, + { + Name: "bar", + Description: "Bar", + Value: "BAR", + }, + }, + }, + { + Key: "Skipped1", + Type: "input", + Title: "This is always skipped (false)", + Condition: `false`, + }, + { + Key: "Skipped2", + Type: "input", + Title: "This is always skipped (template false)", + Condition: `{{ eq "a" "b" }}`, + }, + }, + }, + } + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press("a") + + t.ExpectPopup().Menu().Title(Equals("Pick one")).Select(Contains("foo")).Confirm() + + // Both conditional prompts skipped, file created directly + t.Views().Files(). + Focus(). + Lines( + Contains("result.txt").IsSelected(), + ) + + t.FileSystem().FileContent("result.txt", Equals("FOO\n")) + }, +}) diff --git a/pkg/integration/tests/custom_commands/conditional_prompt_false_value.go b/pkg/integration/tests/custom_commands/conditional_prompt_false_value.go new file mode 100644 index 000000000..44378ce22 --- /dev/null +++ b/pkg/integration/tests/custom_commands/conditional_prompt_false_value.go @@ -0,0 +1,55 @@ +package custom_commands + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ConditionalPromptFalseValue = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Entering literal false as form input does not incorrectly skip a conditional prompt", + ExtraCmdArgs: []string{}, + Skip: false, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("blah") + }, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ + { + Key: "a", + Context: "files", + Command: `echo "{{.Form.Word}} {{.Form.Extra}}" > result.txt`, + Prompts: []config.CustomCommandPrompt{ + { + Key: "Word", + Type: "input", + Title: "Enter a word", + }, + { + Key: "Extra", + Type: "input", + Title: "Enter extra", + Condition: `{{ eq .Form.Word "false" }}`, + }, + }, + }, + } + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press("a") + + t.ExpectPopup().Prompt().Title(Equals("Enter a word")).Type("false").Confirm() + + // Condition {{ eq .Form.Word "false" }} evaluates to true, so prompt should appear + t.ExpectPopup().Prompt().Title(Equals("Enter extra")).Type("baz").Confirm() + + t.Views().Files(). + Focus(). + Lines( + Contains("result.txt").IsSelected(), + ) + + t.FileSystem().FileContent("result.txt", Equals("false baz\n")) + }, +}) diff --git a/pkg/integration/tests/custom_commands/conditional_prompts.go b/pkg/integration/tests/custom_commands/conditional_prompts.go new file mode 100644 index 000000000..36aab67df --- /dev/null +++ b/pkg/integration/tests/custom_commands/conditional_prompts.go @@ -0,0 +1,96 @@ +package custom_commands + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ConditionalPrompts = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Using a custom command with conditional prompts that are skipped based on form values", + ExtraCmdArgs: []string{}, + Skip: false, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial commit") + }, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ + { + Key: "a", + Context: "files", + Command: `echo "{{.Form.Choice}}{{if .Form.Detail}} {{.Form.Detail}}{{end}}" > result.txt`, + Prompts: []config.CustomCommandPrompt{ + { + Key: "Choice", + Type: "menu", + Title: "Choose an option", + Options: []config.CustomCommandMenuOption{ + { + Name: "first", + Description: "First option", + Value: "FIRST", + Key: "1", + }, + { + Name: "second", + Description: "Second option", + Value: "SECOND", + Key: "H", + }, + }, + }, + { + Key: "Detail", + Type: "input", + Title: "Enter detail for second option", + Condition: `{{ eq .Form.Choice "SECOND" }}`, + }, + }, + }, + } + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Test 1: Select "first" via key — conditional prompt should be skipped + t.Views().Files(). + IsFocused(). + Press("a") + + t.ExpectPopup().Menu(). + Title(Equals("Choose an option")) + + t.Views().Menu().Press("1") + + // Detail prompt should be skipped, file should be created directly + t.Views().Files(). + Focus(). + Lines( + Contains("result.txt").IsSelected(), + ) + + t.FileSystem().FileContent("result.txt", Equals("FIRST\n")) + + // Test 2: Select "second" via key — conditional prompt should appear + t.Shell().DeleteFile("result.txt") + t.GlobalPress(keys.Files.RefreshFiles) + + t.Views().Files(). + IsEmpty(). + IsFocused(). + Press("a") + + t.ExpectPopup().Menu(). + Title(Equals("Choose an option")) + + t.Views().Menu().Press("H") + + // Detail prompt should appear because Choice == "SECOND" + t.ExpectPopup().Prompt().Title(Equals("Enter detail for second option")).Type("extra").Confirm() + + t.Views().Files(). + Focus(). + Lines( + Contains("result.txt").IsSelected(), + ) + + t.FileSystem().FileContent("result.txt", Equals("SECOND extra\n")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 7f228635a..04c12e600 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -172,6 +172,9 @@ var tests = []*components.IntegrationTest{ custom_commands.AccessCommitProperties, custom_commands.BasicCommand, custom_commands.CheckForConflicts, + custom_commands.ConditionalPromptFalseString, + custom_commands.ConditionalPromptFalseValue, + custom_commands.ConditionalPrompts, custom_commands.CustomCommandsSubmenu, custom_commands.CustomCommandsSubmenuWithSpecialKeybindings, custom_commands.FormPrompts, diff --git a/schema-master/config.json b/schema-master/config.json index 4f60000d2..c4312981f 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -235,6 +235,13 @@ "examples": [ "{{ .branch | green }}" ] + }, + "condition": { + "type": "string", + "description": "A Go template expression evaluated against the current form state. If it resolves to empty string or 'false', the prompt is skipped.", + "examples": [ + "{{ eq .Form.Choice \"yes\" }}" + ] } }, "additionalProperties": false,