From 4cb6fafb087b338a599844d2b05d654cc611b61c Mon Sep 17 00:00:00 2001 From: "Martin T." Date: Thu, 12 Mar 2026 13:42:12 +0100 Subject: [PATCH] Add condition field to custom command prompts 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. --- docs-master/Custom_Command_Keybindings.md | 36 +++++++ pkg/config/user_config.go | 3 + .../custom_commands/handler_creator.go | 29 ++++++ .../conditional_prompt_false_string.go | 71 ++++++++++++++ .../conditional_prompt_false_value.go | 55 +++++++++++ .../custom_commands/conditional_prompts.go | 96 +++++++++++++++++++ pkg/integration/tests/test_list.go | 3 + schema-master/config.json | 7 ++ 8 files changed, 300 insertions(+) create mode 100644 pkg/integration/tests/custom_commands/conditional_prompt_false_string.go create mode 100644 pkg/integration/tests/custom_commands/conditional_prompt_false_value.go create mode 100644 pkg/integration/tests/custom_commands/conditional_prompts.go 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,