Skip to content

Commit cfd9c66

Browse files
author
Martin T.
committed
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.
1 parent 2f6d202 commit cfd9c66

File tree

9 files changed

+308
-0
lines changed

9 files changed

+308
-0
lines changed

docs/Custom_Command_Keybindings.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,41 @@ Here's an example using a command but not specifying anything else: so each line
319319
command: 'ls'
320320
```
321321

322+
### Conditional prompts
323+
324+
Any prompt can include a `condition` field — a Go template expression evaluated against the current form state. If it resolves to empty string or `false`, the prompt is skipped and its form variable defaults to empty string.
325+
326+
```yml
327+
customCommands:
328+
- key: 'a'
329+
context: 'localBranches'
330+
prompts:
331+
- type: 'menu'
332+
title: 'How do you want to create the branch?'
333+
key: 'Method'
334+
options:
335+
- value: 'simple'
336+
name: 'Simple'
337+
description: 'just a branch name'
338+
- value: 'prefix'
339+
name: 'With prefix'
340+
description: 'with a category prefix'
341+
- type: 'menu'
342+
title: 'Branch prefix'
343+
key: 'Prefix'
344+
condition: '{{ eq .Form.Method "prefix" }}'
345+
options:
346+
- value: 'feature/'
347+
- value: 'hotfix/'
348+
- value: 'release/'
349+
- type: 'input'
350+
title: 'Branch name'
351+
key: 'Name'
352+
command: "git checkout -b '{{.Form.Prefix}}{{.Form.Name}}'"
353+
```
354+
355+
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.
356+
322357
## Placeholder values
323358

324359
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:

pkg/config/user_config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,10 @@ type CustomCommandPrompt struct {
719719
// Like valueFormat but for the labels. If `labelFormat` is not specified, `valueFormat` is shown instead.
720720
// Only for menuFromCommand prompts.
721721
LabelFormat string `yaml:"labelFormat" jsonschema:"example={{ .branch | green }}"`
722+
723+
// A Go template expression evaluated against the current form state. If it resolves to empty string or 'false', the prompt is skipped.
724+
// E.g. '{{ eq .Form.Breaking "!" }}' will only show the prompt if the Breaking field equals "!".
725+
Condition string `yaml:"condition"`
722726
}
723727

724728
type CustomCommandSuggestions struct {

pkg/gui/services/custom_commands/handler_creator.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,45 @@ func (self *HandlerCreator) call(customCommand config.CustomCommand) func() erro
107107
default:
108108
return errors.New("custom command prompt must have a type of 'input', 'menu', 'menuFromCommand', or 'confirm'")
109109
}
110+
111+
if prompt.Condition != "" {
112+
showPrompt := f
113+
conditionTemplate := prompt.Condition
114+
f = func() error {
115+
resolved, err := resolveCondition(conditionTemplate, resolveTemplate)
116+
if err != nil {
117+
return err
118+
}
119+
if resolved {
120+
return showPrompt()
121+
}
122+
form[prompt.Key] = ""
123+
return g()
124+
}
125+
}
110126
}
111127

112128
return f()
113129
}
114130
}
115131

132+
func resolveCondition(condition string, resolveTemplate func(string) (string, error)) (bool, error) {
133+
expr := strings.TrimSpace(condition)
134+
if expr == "" {
135+
return false, nil
136+
}
137+
if strings.HasPrefix(expr, "{{") && strings.HasSuffix(expr, "}}") {
138+
expr = strings.TrimPrefix(expr, "{{")
139+
expr = strings.TrimSuffix(expr, "}}")
140+
}
141+
ifTemplate := fmt.Sprintf("{{ if %s }}1{{ end }}", expr)
142+
resolved, err := resolveTemplate(ifTemplate)
143+
if err != nil {
144+
return false, err
145+
}
146+
return strings.TrimSpace(resolved) != "", nil
147+
}
148+
116149
func (self *HandlerCreator) inputPrompt(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error {
117150
findSuggestionsFn, err := self.generateFindSuggestionsFunc(prompt)
118151
if err != nil {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package custom_commands
2+
3+
import (
4+
"github.com/jesseduffield/lazygit/pkg/config"
5+
. "github.com/jesseduffield/lazygit/pkg/integration/components"
6+
)
7+
8+
var ConditionalPromptFalseString = NewIntegrationTest(NewIntegrationTestArgs{
9+
Description: "Conditional prompt is skipped when condition is bare false or template false",
10+
ExtraCmdArgs: []string{},
11+
Skip: false,
12+
SetupRepo: func(shell *Shell) {
13+
shell.EmptyCommit("blah")
14+
},
15+
SetupConfig: func(cfg *config.AppConfig) {
16+
cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
17+
{
18+
Key: "a",
19+
Context: "files",
20+
Command: `echo "{{.Form.Choice}}" > result.txt`,
21+
Prompts: []config.CustomCommandPrompt{
22+
{
23+
Key: "Choice",
24+
Type: "menu",
25+
Title: "Pick one",
26+
Options: []config.CustomCommandMenuOption{
27+
{
28+
Name: "foo",
29+
Description: "Foo",
30+
Value: "FOO",
31+
},
32+
{
33+
Name: "bar",
34+
Description: "Bar",
35+
Value: "BAR",
36+
},
37+
},
38+
},
39+
{
40+
Key: "Skipped1",
41+
Type: "input",
42+
Title: "This is always skipped (false)",
43+
Condition: `false`,
44+
},
45+
{
46+
Key: "Skipped2",
47+
Type: "input",
48+
Title: "This is always skipped (template false)",
49+
Condition: `{{ eq "a" "b" }}`,
50+
},
51+
},
52+
},
53+
}
54+
},
55+
Run: func(t *TestDriver, keys config.KeybindingConfig) {
56+
t.Views().Files().
57+
IsFocused().
58+
Press("a")
59+
60+
t.ExpectPopup().Menu().Title(Equals("Pick one")).Select(Contains("foo")).Confirm()
61+
62+
// Both conditional prompts skipped, file created directly
63+
t.Views().Files().
64+
Focus().
65+
Lines(
66+
Contains("result.txt").IsSelected(),
67+
)
68+
69+
t.FileSystem().FileContent("result.txt", Equals("FOO\n"))
70+
},
71+
})
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package custom_commands
2+
3+
import (
4+
"github.com/jesseduffield/lazygit/pkg/config"
5+
. "github.com/jesseduffield/lazygit/pkg/integration/components"
6+
)
7+
8+
var ConditionalPromptFalseValue = NewIntegrationTest(NewIntegrationTestArgs{
9+
Description: "Entering literal false as form input does not incorrectly skip a conditional prompt",
10+
ExtraCmdArgs: []string{},
11+
Skip: false,
12+
SetupRepo: func(shell *Shell) {
13+
shell.EmptyCommit("blah")
14+
},
15+
SetupConfig: func(cfg *config.AppConfig) {
16+
cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
17+
{
18+
Key: "a",
19+
Context: "files",
20+
Command: `echo "{{.Form.Word}} {{.Form.Extra}}" > result.txt`,
21+
Prompts: []config.CustomCommandPrompt{
22+
{
23+
Key: "Word",
24+
Type: "input",
25+
Title: "Enter a word",
26+
},
27+
{
28+
Key: "Extra",
29+
Type: "input",
30+
Title: "Enter extra",
31+
Condition: `{{ eq .Form.Word "false" }}`,
32+
},
33+
},
34+
},
35+
}
36+
},
37+
Run: func(t *TestDriver, keys config.KeybindingConfig) {
38+
t.Views().Files().
39+
IsFocused().
40+
Press("a")
41+
42+
t.ExpectPopup().Prompt().Title(Equals("Enter a word")).Type("false").Confirm()
43+
44+
// Condition {{ eq .Form.Word "false" }} evaluates to true, so prompt should appear
45+
t.ExpectPopup().Prompt().Title(Equals("Enter extra")).Type("baz").Confirm()
46+
47+
t.Views().Files().
48+
Focus().
49+
Lines(
50+
Contains("result.txt").IsSelected(),
51+
)
52+
53+
t.FileSystem().FileContent("result.txt", Equals("false baz\n"))
54+
},
55+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package custom_commands
2+
3+
import (
4+
"github.com/jesseduffield/lazygit/pkg/config"
5+
. "github.com/jesseduffield/lazygit/pkg/integration/components"
6+
)
7+
8+
var ConditionalPrompts = NewIntegrationTest(NewIntegrationTestArgs{
9+
Description: "Using a custom command with conditional prompts that are skipped based on form values",
10+
ExtraCmdArgs: []string{},
11+
Skip: false,
12+
SetupRepo: func(shell *Shell) {
13+
shell.EmptyCommit("initial commit")
14+
},
15+
SetupConfig: func(cfg *config.AppConfig) {
16+
cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
17+
{
18+
Key: "a",
19+
Context: "files",
20+
Command: `echo "{{.Form.Choice}}{{if .Form.Detail}} {{.Form.Detail}}{{end}}" > result.txt`,
21+
Prompts: []config.CustomCommandPrompt{
22+
{
23+
Key: "Choice",
24+
Type: "menu",
25+
Title: "Choose an option",
26+
Options: []config.CustomCommandMenuOption{
27+
{
28+
Name: "first",
29+
Description: "First option",
30+
Value: "FIRST",
31+
Key: "1",
32+
},
33+
{
34+
Name: "second",
35+
Description: "Second option",
36+
Value: "SECOND",
37+
Key: "H",
38+
},
39+
},
40+
},
41+
{
42+
Key: "Detail",
43+
Type: "input",
44+
Title: "Enter detail for second option",
45+
Condition: `{{ eq .Form.Choice "SECOND" }}`,
46+
},
47+
},
48+
},
49+
}
50+
},
51+
Run: func(t *TestDriver, keys config.KeybindingConfig) {
52+
// Test 1: Select "first" via key — conditional prompt should be skipped
53+
t.Views().Files().
54+
IsFocused().
55+
Press("a")
56+
57+
t.ExpectPopup().Menu().
58+
Title(Equals("Choose an option"))
59+
60+
t.Views().Menu().Press("1")
61+
62+
// Detail prompt should be skipped, file should be created directly
63+
t.Views().Files().
64+
Focus().
65+
Lines(
66+
Contains("result.txt").IsSelected(),
67+
)
68+
69+
t.FileSystem().FileContent("result.txt", Equals("FIRST\n"))
70+
71+
// Test 2: Select "second" via key — conditional prompt should appear
72+
t.Shell().DeleteFile("result.txt")
73+
t.GlobalPress(keys.Files.RefreshFiles)
74+
75+
t.Views().Files().
76+
IsEmpty().
77+
IsFocused().
78+
Press("a")
79+
80+
t.ExpectPopup().Menu().
81+
Title(Equals("Choose an option"))
82+
83+
t.Views().Menu().Press("H")
84+
85+
// Detail prompt should appear because Choice == "SECOND"
86+
t.ExpectPopup().Prompt().Title(Equals("Enter detail for second option")).Type("extra").Confirm()
87+
88+
t.Views().Files().
89+
Focus().
90+
Lines(
91+
Contains("result.txt").IsSelected(),
92+
)
93+
94+
t.FileSystem().FileContent("result.txt", Equals("SECOND extra\n"))
95+
},
96+
})

pkg/integration/tests/test_list.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ var tests = []*components.IntegrationTest{
171171
custom_commands.AccessCommitProperties,
172172
custom_commands.BasicCommand,
173173
custom_commands.CheckForConflicts,
174+
custom_commands.ConditionalPromptFalseString,
175+
custom_commands.ConditionalPromptFalseValue,
176+
custom_commands.ConditionalPrompts,
174177
custom_commands.CustomCommandsSubmenu,
175178
custom_commands.CustomCommandsSubmenuWithSpecialKeybindings,
176179
custom_commands.FormPrompts,

schema-master/config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,10 @@
235235
"examples": [
236236
"{{ .branch | green }}"
237237
]
238+
},
239+
"condition": {
240+
"type": "string",
241+
"description": "A Go template expression evaluated against the current form state. If it resolves to empty string or 'false', the prompt is skipped.\nE.g. '{{ eq .Form.Breaking \"!\" }}' will only show the prompt if the Breaking field equals \"!\"."
238242
}
239243
},
240244
"additionalProperties": false,

schema/config.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,13 @@
235235
"examples": [
236236
"{{ .branch | green }}"
237237
]
238+
},
239+
"condition": {
240+
"type": "string",
241+
"description": "A Go template expression evaluated against the current form state. If it resolves to empty string or 'false', the prompt is skipped.",
242+
"examples": [
243+
"{{ eq .Form.Choice \"yes\" }}"
244+
]
238245
}
239246
},
240247
"additionalProperties": false,

0 commit comments

Comments
 (0)