diff --git a/docs/Custom_Command_Keybindings.md b/docs/Custom_Command_Keybindings.md index 5a63e2241..9dc42a9da 100644 --- a/docs/Custom_Command_Keybindings.md +++ b/docs/Custom_Command_Keybindings.md @@ -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.*)' + 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_ | diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 4855bf816..d373b74f1 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -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 { diff --git a/pkg/gui/custom_commands.go b/pkg/gui/custom_commands.go index 06be97da0..752587e69 100644 --- a/pkg/gui/custom_commands.go +++ b/pkg/gui/custom_commands.go @@ -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'") } } diff --git a/pkg/gui/dummies.go b/pkg/gui/dummies.go new file mode 100644 index 000000000..d8a7cba29 --- /dev/null +++ b/pkg/gui/dummies.go @@ -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 +} diff --git a/pkg/gui/gui_test.go b/pkg/gui/gui_test.go index 8e37cecfe..ec1279608 100644 --- a/pkg/gui/gui_test.go +++ b/pkg/gui/gui_test.go @@ -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 }}", + func(actual []string, err error) { + assert.NoError(t, err) + assert.EqualValues(t, "pr-1", actual[0]) + }, + }, + { + "Multiple named groups", + "upstream/pr-1", + "(?P[a-z]*)/(?P.*)", + "{{ .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[a-z]*)/(?P.*)", + "{{ .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)) + }) + } +}