mirror of
https://github.com/jesseduffield/lazygit.git
synced 2024-11-24 08:52:21 +02:00
Merge pull request #1390 from FoamScience/menu_from_cmd
Generate menu options from a Git Command with a filter
This commit is contained in:
commit
c005b0d92b
@ -35,6 +35,19 @@ customCommands:
|
||||
command: "git flow {{index .PromptResponses 0}} start {{index .PromptResponses 1}}"
|
||||
context: 'localBranches'
|
||||
loadingText: 'creating branch'
|
||||
- key : 'r'
|
||||
description: 'Checkout a remote branch as FETCH_HEAD'
|
||||
command: "git fetch {{index .PromptResponses 0}} {{index .PromptResponses 1}} && git checkout FETCH_HEAD"
|
||||
context: 'remotes'
|
||||
prompts:
|
||||
- type: 'input'
|
||||
title: 'Remote:'
|
||||
initialValue: "{{index .SelectedRemote.Name }}"
|
||||
- type: 'menuFromCommand'
|
||||
title: 'Remote branch:'
|
||||
command: 'git branch -r --list {{index .PromptResponses 0}}/*'
|
||||
filter: '.*{{index .PromptResponses 0}}/(?P<branch>.*)'
|
||||
format: '{{ .branch }}'
|
||||
```
|
||||
|
||||
Looking at the command assigned to the 'n' key, here's what the result looks like:
|
||||
@ -85,6 +98,13 @@ The permitted prompt fields are:
|
||||
| title | the title to display in the popup panel | no |
|
||||
| initialValue | (only applicable to 'input' prompts) the initial value to appear in the text box | no |
|
||||
| options | (only applicable to 'menu' prompts) the options to display in the menu | no |
|
||||
| command | (only applicable to 'menuFromCommand' prompts) the command to run to generate | yes |
|
||||
| | menu options | |
|
||||
| filter | (only applicable to 'menuFromCommand' prompts) the regexp to run specifying | yes |
|
||||
| | groups which are going to be kept from the command's output | |
|
||||
| format | (only applicable to 'menuFromCommand' prompts) how to format matched groups from | yes |
|
||||
| | the filter. You can use named groups, or `{{ .group_GROUPID }}`. | yes |
|
||||
| | PS: named groups keep first match only | yes |
|
||||
|
||||
The permitted option fields are:
|
||||
| _field_ | _description_ | _required_ |
|
||||
|
@ -280,6 +280,11 @@ type CustomCommandPrompt struct {
|
||||
|
||||
// this only applies to menus
|
||||
Options []CustomCommandMenuOption
|
||||
|
||||
// this only applies to menuFromCommand
|
||||
Command string `yaml:"command"`
|
||||
Filter string `yaml:"filter"`
|
||||
Format string `yaml:"format"`
|
||||
}
|
||||
|
||||
type CustomCommandMenuOption struct {
|
||||
|
@ -1,8 +1,13 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/jesseduffield/gocui"
|
||||
@ -49,6 +54,153 @@ func (gui *Gui) resolveTemplate(templateStr string, promptResponses []string) (s
|
||||
return utils.ResolveTemplate(templateStr, objects)
|
||||
}
|
||||
|
||||
func (gui *Gui) inputPrompt(prompt config.CustomCommandPrompt, promptResponses []string, responseIdx int, wrappedF func() error) error {
|
||||
title, err := gui.resolveTemplate(prompt.Title, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
initialValue, err := gui.resolveTemplate(prompt.InitialValue, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
return gui.prompt(promptOpts{
|
||||
title: title,
|
||||
initialContent: initialValue,
|
||||
handleConfirm: func(str string) error {
|
||||
promptResponses[responseIdx] = str
|
||||
return wrappedF()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) menuPrompt(prompt config.CustomCommandPrompt, promptResponses []string, responseIdx int, wrappedF func() error) error {
|
||||
// need to make a menu here some how
|
||||
menuItems := make([]*menuItem, len(prompt.Options))
|
||||
for i, option := range prompt.Options {
|
||||
option := option
|
||||
|
||||
nameTemplate := option.Name
|
||||
if nameTemplate == "" {
|
||||
// this allows you to only pass values rather than bother with names/descriptions
|
||||
nameTemplate = option.Value
|
||||
}
|
||||
name, err := gui.resolveTemplate(nameTemplate, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
description, err := gui.resolveTemplate(option.Description, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
value, err := gui.resolveTemplate(option.Value, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
menuItems[i] = &menuItem{
|
||||
displayStrings: []string{name, utils.ColoredString(description, color.FgYellow)},
|
||||
onPress: func() error {
|
||||
promptResponses[responseIdx] = value
|
||||
return wrappedF()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
title, err := gui.resolveTemplate(prompt.Title, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
return gui.createMenu(title, menuItems, createMenuOptions{showCancel: true})
|
||||
}
|
||||
|
||||
func (gui *Gui) GenerateMenuCandidates(commandOutput string, filter string, format string) ([]string, error) {
|
||||
candidates := []string{}
|
||||
reg, err := regexp.Compile(filter)
|
||||
if err != nil {
|
||||
return candidates, gui.surfaceError(errors.New("unable to parse filter regex, error: " + err.Error()))
|
||||
}
|
||||
buff := bytes.NewBuffer(nil)
|
||||
temp, err := template.New("format").Parse(format)
|
||||
if err != nil {
|
||||
return candidates, gui.surfaceError(errors.New("unable to parse format, error: " + err.Error()))
|
||||
}
|
||||
for _, str := range strings.Split(string(commandOutput), "\n") {
|
||||
if str == "" {
|
||||
continue
|
||||
}
|
||||
tmplData := map[string]string{}
|
||||
out := reg.FindAllStringSubmatch(str, -1)
|
||||
if len(out) > 0 {
|
||||
for groupIdx, group := range reg.SubexpNames() {
|
||||
// Record matched group with group ids
|
||||
matchName := "group_" + strconv.Itoa(groupIdx)
|
||||
tmplData[matchName] = out[0][groupIdx]
|
||||
// Record last named group non-empty matches as group matches
|
||||
if group != "" {
|
||||
tmplData[group] = out[0][groupIdx]
|
||||
}
|
||||
}
|
||||
}
|
||||
err = temp.Execute(buff, tmplData)
|
||||
if err != nil {
|
||||
return candidates, gui.surfaceError(err)
|
||||
}
|
||||
|
||||
candidates = append(candidates, strings.TrimSpace(buff.String()))
|
||||
buff.Reset()
|
||||
}
|
||||
return candidates, err
|
||||
}
|
||||
|
||||
func (gui *Gui) menuPromptFromCommand(prompt config.CustomCommandPrompt, promptResponses []string, responseIdx int, wrappedF func() error) error {
|
||||
// Collect cmd to run from config
|
||||
cmdStr, err := gui.resolveTemplate(prompt.Command, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
// Collect Filter regexp
|
||||
filter, err := gui.resolveTemplate(prompt.Filter, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
// Run and save output
|
||||
message, err := gui.GitCommand.RunCommandWithOutput(cmdStr)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
// Need to make a menu out of what the cmd has displayed
|
||||
candidates, err := gui.GenerateMenuCandidates(message, filter, prompt.Format)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
menuItems := make([]*menuItem, len(candidates))
|
||||
for i := range candidates {
|
||||
menuItems[i] = &menuItem{
|
||||
displayStrings: []string{candidates[i]},
|
||||
onPress: func() error {
|
||||
promptResponses[responseIdx] = candidates[i]
|
||||
return wrappedF()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
title, err := gui.resolveTemplate(prompt.Title, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
return gui.createMenu(title, menuItems, createMenuOptions{showCancel: true})
|
||||
}
|
||||
|
||||
func (gui *Gui) handleCustomCommandKeybinding(customCommand config.CustomCommand) func() error {
|
||||
return func() error {
|
||||
promptResponses := make([]string, len(customCommand.Prompts))
|
||||
@ -89,72 +241,18 @@ func (gui *Gui) handleCustomCommandKeybinding(customCommand config.CustomCommand
|
||||
switch prompt.Type {
|
||||
case "input":
|
||||
f = func() error {
|
||||
title, err := gui.resolveTemplate(prompt.Title, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
initialValue, err := gui.resolveTemplate(prompt.InitialValue, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
return gui.prompt(promptOpts{
|
||||
title: title,
|
||||
initialContent: initialValue,
|
||||
handleConfirm: func(str string) error {
|
||||
promptResponses[idx] = str
|
||||
|
||||
return wrappedF()
|
||||
},
|
||||
})
|
||||
return gui.inputPrompt(prompt, promptResponses, idx, wrappedF)
|
||||
}
|
||||
case "menu":
|
||||
f = func() error {
|
||||
// need to make a menu here some how
|
||||
menuItems := make([]*menuItem, len(prompt.Options))
|
||||
for i, option := range prompt.Options {
|
||||
option := option
|
||||
|
||||
nameTemplate := option.Name
|
||||
if nameTemplate == "" {
|
||||
// this allows you to only pass values rather than bother with names/descriptions
|
||||
nameTemplate = option.Value
|
||||
}
|
||||
name, err := gui.resolveTemplate(nameTemplate, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
description, err := gui.resolveTemplate(option.Description, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
value, err := gui.resolveTemplate(option.Value, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
menuItems[i] = &menuItem{
|
||||
displayStrings: []string{name, utils.ColoredString(description, color.FgYellow)},
|
||||
onPress: func() error {
|
||||
promptResponses[idx] = value
|
||||
|
||||
return wrappedF()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
title, err := gui.resolveTemplate(prompt.Title, promptResponses)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
return gui.createMenu(title, menuItems, createMenuOptions{showCancel: true})
|
||||
return gui.menuPrompt(prompt, promptResponses, idx, wrappedF)
|
||||
}
|
||||
case "menuFromCommand":
|
||||
f = func() error {
|
||||
return gui.menuPromptFromCommand(prompt, promptResponses, idx, wrappedF)
|
||||
}
|
||||
default:
|
||||
return gui.createErrorPanel("custom command prompt must have a type of 'input' or 'menu'")
|
||||
return gui.createErrorPanel("custom command prompt must have a type of 'input', 'menu' or 'menuFromCommand'")
|
||||
}
|
||||
|
||||
}
|
||||
|
21
pkg/gui/dummies.go
Normal file
21
pkg/gui/dummies.go
Normal file
@ -0,0 +1,21 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
"github.com/jesseduffield/lazygit/pkg/updates"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
// NewDummyGui creates a new dummy GUI for testing
|
||||
func NewDummyUpdater() *updates.Updater {
|
||||
DummyUpdater, _ := updates.NewUpdater(utils.NewDummyLog(), config.NewDummyAppConfig(), oscommands.NewDummyOSCommand(), i18n.NewTranslationSet(utils.NewDummyLog()))
|
||||
return DummyUpdater
|
||||
}
|
||||
|
||||
func NewDummyGui() *Gui {
|
||||
DummyGui, _ := NewGui(utils.NewDummyLog(), commands.NewDummyGitCommand(), oscommands.NewDummyOSCommand(), i18n.NewTranslationSet(utils.NewDummyLog()), config.NewDummyAppConfig(), NewDummyUpdater(), "", false)
|
||||
return DummyGui
|
||||
}
|
@ -80,3 +80,52 @@ func runCmdHeadless(cmd *exec.Cmd) error {
|
||||
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func TestGuiGenerateMenuCandidates(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
cmdOut string
|
||||
filter string
|
||||
format string
|
||||
test func([]string, error)
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
"Extract remote branch name",
|
||||
"upstream/pr-1",
|
||||
"upstream/(?P<branch>.*)",
|
||||
"{{ .branch }}",
|
||||
func(actual []string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, "pr-1", actual[0])
|
||||
},
|
||||
},
|
||||
{
|
||||
"Multiple named groups",
|
||||
"upstream/pr-1",
|
||||
"(?P<remote>[a-z]*)/(?P<branch>.*)",
|
||||
"{{ .branch }}|{{ .remote }}",
|
||||
func(actual []string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, "pr-1|upstream", actual[0])
|
||||
},
|
||||
},
|
||||
{
|
||||
"Multiple named groups with group ids",
|
||||
"upstream/pr-1",
|
||||
"(?P<remote>[a-z]*)/(?P<branch>.*)",
|
||||
"{{ .group_2 }}|{{ .group_1 }}",
|
||||
func(actual []string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, "pr-1|upstream", actual[0])
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
s.test(NewDummyGui().GenerateMenuCandidates(s.cmdOut, s.filter, s.format))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user