diff options
author | Jesse Duffield <jessedduffield@gmail.com> | 2022-02-24 13:29:48 +1100 |
---|---|---|
committer | Jesse Duffield <jessedduffield@gmail.com> | 2022-03-17 19:13:40 +1100 |
commit | ef7c4c9ca93ec15db4886d8d6f78c85a1db7edef (patch) | |
tree | 57c2cff341b03d12b69ac57226376563e9089f75 /pkg/gui/services | |
parent | 952a4f3f2388da4ab88005b02f264aca0172afe7 (diff) |
refactor custom commands
more custom command refactoring
Diffstat (limited to 'pkg/gui/services')
-rw-r--r-- | pkg/gui/services/custom_commands/client.go | 52 | ||||
-rw-r--r-- | pkg/gui/services/custom_commands/handler_creator.go | 187 | ||||
-rw-r--r-- | pkg/gui/services/custom_commands/keybinding_creator.go | 91 | ||||
-rw-r--r-- | pkg/gui/services/custom_commands/menu_generator.go | 138 | ||||
-rw-r--r-- | pkg/gui/services/custom_commands/menu_generator_test.go | 65 | ||||
-rw-r--r-- | pkg/gui/services/custom_commands/resolver.go | 98 | ||||
-rw-r--r-- | pkg/gui/services/custom_commands/session_state_loader.go | 56 |
7 files changed, 687 insertions, 0 deletions
diff --git a/pkg/gui/services/custom_commands/client.go b/pkg/gui/services/custom_commands/client.go new file mode 100644 index 000000000..fc36405c1 --- /dev/null +++ b/pkg/gui/services/custom_commands/client.go @@ -0,0 +1,52 @@ +package custom_commands + +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/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +// Client is the entry point to this package. It reutrns a list of keybindings based on the config's user-defined custom commands. +// See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Command_Keybindings.md for more info. +type Client struct { + customCommands []config.CustomCommand + handlerCreator *HandlerCreator + keybindingCreator *KeybindingCreator +} + +func NewClient( + c *types.HelperCommon, + os *oscommands.OSCommand, + git *commands.GitCommand, + contexts *context.ContextTree, + helpers *helpers.Helpers, + getKey func(string) interface{}, +) *Client { + sessionStateLoader := NewSessionStateLoader(contexts, helpers) + handlerCreator := NewHandlerCreator(c, os, git, sessionStateLoader) + keybindingCreator := NewKeybindingCreator(contexts, getKey) + customCommands := c.UserConfig.CustomCommands + + return &Client{ + customCommands: customCommands, + keybindingCreator: keybindingCreator, + handlerCreator: handlerCreator, + } +} + +func (self *Client) GetCustomCommandKeybindings() ([]*types.Binding, error) { + bindings := []*types.Binding{} + for _, customCommand := range self.customCommands { + handler := self.handlerCreator.call(customCommand) + binding, err := self.keybindingCreator.call(customCommand, handler) + if err != nil { + return nil, err + } + bindings = append(bindings, binding) + } + + return bindings, nil +} diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go new file mode 100644 index 000000000..04e6cb644 --- /dev/null +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -0,0 +1,187 @@ +package custom_commands + +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/gui/style" + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" +) + +// takes a custom command and returns a function that will be called when the corresponding user-defined keybinding is pressed +type HandlerCreator struct { + c *types.HelperCommon + os *oscommands.OSCommand + git *commands.GitCommand + sessionStateLoader *SessionStateLoader + resolver *Resolver + menuGenerator *MenuGenerator +} + +func NewHandlerCreator( + c *types.HelperCommon, + os *oscommands.OSCommand, + git *commands.GitCommand, + sessionStateLoader *SessionStateLoader, +) *HandlerCreator { + resolver := NewResolver(c.Common) + menuGenerator := NewMenuGenerator(c.Common) + + return &HandlerCreator{ + c: c, + os: os, + git: git, + sessionStateLoader: sessionStateLoader, + resolver: resolver, + menuGenerator: menuGenerator, + } +} + +func (self *HandlerCreator) call(customCommand config.CustomCommand) func() error { + return func() error { + sessionState := self.sessionStateLoader.call() + promptResponses := make([]string, len(customCommand.Prompts)) + + f := func() error { return self.finalHandler(customCommand, sessionState, promptResponses) } + + // if we have prompts we'll recursively wrap our confirm handlers with more prompts + // until we reach the actual command + for reverseIdx := range customCommand.Prompts { + // reassigning so that we don't end up with an infinite recursion + g := f + idx := len(customCommand.Prompts) - 1 - reverseIdx + + // going backwards so the outermost prompt is the first one + prompt := customCommand.Prompts[idx] + + wrappedF := func(response string) error { + promptResponses[idx] = response + return g() + } + + resolveTemplate := self.getResolveTemplateFn(promptResponses, sessionState) + resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate) + if err != nil { + return self.c.Error(err) + } + + switch prompt.Type { + case "input": + f = func() error { + return self.inputPrompt(resolvedPrompt, wrappedF) + } + case "menu": + f = func() error { + return self.menuPrompt(resolvedPrompt, wrappedF) + } + case "menuFromCommand": + f = func() error { + return self.menuPromptFromCommand(resolvedPrompt, wrappedF) + } + default: + return self.c.ErrorMsg("custom command prompt must have a type of 'input', 'menu' or 'menuFromCommand'") + } + } + + return f() + } +} + +func (self *HandlerCreator) inputPrompt(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error { + return self.c.Prompt(types.PromptOpts{ + Title: prompt.Title, + InitialContent: prompt.InitialValue, + HandleConfirm: func(str string) error { + return wrappedF(str) + }, + }) +} + +func (self *HandlerCreator) menuPrompt(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error { + menuItems := make([]*types.MenuItem, len(prompt.Options)) + for i, option := range prompt.Options { + option := option + menuItems[i] = &types.MenuItem{ + DisplayStrings: []string{option.Name, style.FgYellow.Sprint(option.Description)}, + OnPress: func() error { + return wrappedF(option.Value) + }, + } + } + + return self.c.Menu(types.CreateMenuOptions{Title: prompt.Title, Items: menuItems}) +} + +func (self *HandlerCreator) menuPromptFromCommand(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error { + // Run and save output + message, err := self.git.Custom.RunWithOutput(prompt.Command) + if err != nil { + return self.c.Error(err) + } + + // Need to make a menu out of what the cmd has displayed + candidates, err := self.menuGenerator.call(message, prompt.Filter, prompt.ValueFormat, prompt.LabelFormat) + if err != nil { + return self.c.Error(err) + } + + menuItems := make([]*types.MenuItem, len(candidates)) + for i := range candidates { + i := i + menuItems[i] = &types.MenuItem{ + DisplayStrings: []string{candidates[i].label}, + OnPress: func() error { + return wrappedF(candidates[i].value) + }, + } + } + + return self.c.Menu(types.CreateMenuOptions{Title: prompt.Title, Items: menuItems}) +} + +type CustomCommandObjects struct { + *SessionState + PromptResponses []string +} + +func (self *HandlerCreator) getResolveTemplateFn(promptResponses []string, sessionState *SessionState) func(string) (string, error) { + objects := CustomCommandObjects{ + SessionState: sessionState, + PromptResponses: promptResponses, + } + + return func(templateStr string) (string, error) { return utils.ResolveTemplate(templateStr, objects) } +} + +func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, sessionState *SessionState, promptResponses []string) error { + resolveTemplate := self.getResolveTemplateFn(promptResponses, sessionState) + cmdStr, err := resolveTemplate(customCommand.Command) + if err != nil { + return self.c.Error(err) + } + + cmdObj := self.os.Cmd.NewShell(cmdStr) + + if customCommand.Subprocess { + return self.c.RunSubprocessAndRefresh(cmdObj) + } + + loadingText := customCommand.LoadingText + if loadingText == "" { + loadingText = self.c.Tr.LcRunningCustomCommandStatus + } + + return self.c.WithWaitingStatus(loadingText, func() error { + self.c.LogAction(self.c.Tr.Actions.CustomCommand) + + if customCommand.Stream { + cmdObj.StreamOutput() + } + err := cmdObj.Run() + if err != nil { + return self.c.Error(err) + } + return self.c.Refresh(types.RefreshOptions{}) + }) +} diff --git a/pkg/gui/services/custom_commands/keybinding_creator.go b/pkg/gui/services/custom_commands/keybinding_creator.go new file mode 100644 index 000000000..e3c233951 --- /dev/null +++ b/pkg/gui/services/custom_commands/keybinding_creator.go @@ -0,0 +1,91 @@ +package custom_commands + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +// KeybindingCreator takes a custom command along with its handler and returns a corresponding keybinding +type KeybindingCreator struct { + contexts *context.ContextTree + getKey func(string) interface{} +} + +func NewKeybindingCreator(contexts *context.ContextTree, getKey func(string) interface{}) *KeybindingCreator { + return &KeybindingCreator{ + contexts: contexts, + getKey: getKey, + } +} + +func (self *KeybindingCreator) call(customCommand config.CustomCommand, handler func() error) (*types.Binding, error) { + if customCommand.Context == "" { + return nil, formatContextNotProvidedError(customCommand) + } + + viewName, contexts, err := self.getViewNameAndContexts(customCommand) + if err != nil { + return nil, err + } + + description := customCommand.Description + if description == "" { + description = customCommand.Command + } + + return &types.Binding{ + ViewName: viewName, + Contexts: contexts, + Key: self.getKey(customCommand.Key), + Modifier: gocui.ModNone, + Handler: handler, + Description: description, + }, nil +} + +func (self *KeybindingCreator) getViewNameAndContexts(customCommand config.CustomCommand) (string, []string, error) { + if customCommand.Context == "global" { + return "", nil, nil + } + + ctx, ok := self.contextForContextKey(types.ContextKey(customCommand.Context)) + if !ok { + return "", nil, formatUnknownContextError(customCommand) + } + + // here we assume that a given context will always belong to the same view. + // Currently this is a safe bet but it's by no means guaranteed in the long term + // and we might need to make some changes in the future to support it. + viewName := ctx.GetViewName() + contexts := []string{customCommand.Context} + return viewName, contexts, nil +} + +func (self *KeybindingCreator) contextForContextKey(contextKey types.ContextKey) (types.Context, bool) { + for _, context := range self.contexts.Flatten() { + if context.GetKey() == contextKey { + return context, true + } + } + + return nil, false +} + +func formatUnknownContextError(customCommand config.CustomCommand) error { + // stupid golang making me build an array of strings for this. + allContextKeyStrings := make([]string, len(context.AllContextKeys)) + for i := range context.AllContextKeys { + allContextKeyStrings[i] = string(context.AllContextKeys[i]) + } + + return fmt.Errorf("Error when setting custom command keybindings: unknown context: %s. Key: %s, Command: %s.\nPermitted contexts: %s", customCommand.Context, customCommand.Key, customCommand.Command, strings.Join(allContextKeyStrings, ", ")) +} + +func formatContextNotProvidedError(customCommand config.CustomCommand) error { + return fmt.Errorf("Error parsing custom command keybindings: context not provided (use context: 'global' for the global context). Key: %s, Command: %s", customCommand.Key, customCommand.Command) +} diff --git a/pkg/gui/services/custom_commands/menu_generator.go b/pkg/gui/services/custom_commands/menu_generator.go new file mode 100644 index 000000000..5bec1db91 --- /dev/null +++ b/pkg/gui/services/custom_commands/menu_generator.go @@ -0,0 +1,138 @@ +package custom_commands + +import ( + "bytes" + "errors" + "regexp" + "strconv" + "strings" + "text/template" + + "github.com/jesseduffield/lazygit/pkg/common" + "github.com/jesseduffield/lazygit/pkg/gui/style" +) + +type MenuGenerator struct { + c *common.Common +} + +// takes the output of a command and returns a list of menu entries based on a filter +// and value/label format templates provided by the user +func NewMenuGenerator(c *common.Common) *MenuGenerator { + return &MenuGenerator{c: c} +} + +type commandMenuEntry struct { + label string + value string +} + +func (self *MenuGenerator) call(commandOutput, filter, valueFormat, labelFormat string) ([]*commandMenuEntry, error) { + regex, err := regexp.Compile(filter) + if err != nil { + return nil, errors.New("unable to parse filter regex, error: " + err.Error()) + } + + valueTemplateAux, err := template.New("format").Parse(valueFormat) + if err != nil { + return nil, errors.New("unable to parse value format, error: " + err.Error()) + } + valueTemplate := NewTrimmerTemplate(valueTemplateAux) + + var labelTemplate *TrimmerTemplate + if labelFormat != "" { + colorFuncMap := style.TemplateFuncMapAddColors(template.FuncMap{}) + labelTemplateAux, err := template.New("format").Funcs(colorFuncMap).Parse(labelFormat) + if err != nil { + return nil, errors.New("unable to parse label format, error: " + err.Error()) + } + labelTemplate = NewTrimmerTemplate(labelTemplateAux) + } else { + labelTemplate = valueTemplate + } + + candidates := []*commandMenuEntry{} + for _, line := range strings.Split(commandOutput, "\n") { + if line == "" { + continue + } + + candidate, err := self.generateMenuCandidate( + line, + regex, + valueTemplate, + labelTemplate, + ) + if err != nil { + return nil, err + } + + candidates = append(candidates, candidate) + } + + return candidates, err +} + +func (self *MenuGenerator) generateMenuCandidate( + line string, + regex *regexp.Regexp, + valueTemplate *TrimmerTemplate, + labelTemplate *TrimmerTemplate, +) (*commandMenuEntry, error) { + tmplData := self.parseLine(line, regex) + + entry := &commandMenuEntry{} + + var err error + entry.value, err = valueTemplate.execute(tmplData) + if err != nil { + return nil, err + } + + entry.label, err = labelTemplate.execute(tmplData) + if err != nil { + return nil, err + } + + return entry, nil +} + +func (self *MenuGenerator) parseLine(line string, regex *regexp.Regexp) map[string]string { + tmplData := map[string]string{} + out := regex.FindAllStringSubmatch(line, -1) + if len(out) > 0 { + for groupIdx, group := range regex.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] + } + } + } + + return tmplData +} + +// wrapper around a template which trims the output +type TrimmerTemplate struct { + template *template.Template + buffer *bytes.Buffer +} + +func NewTrimmerTemplate(template *template.Template) *TrimmerTemplate { + return &TrimmerTemplate{ + template: template, + buffer: bytes.NewBuffer(nil), + } +} + +func (self *TrimmerTemplate) execute(tmplData map[string]string) (string, error) { + self.buffer.Reset() + err := self.template.Execute(self.buffer, tmplData) + if err != nil { + return "", err + } + return strings.TrimSpace(self.buffer.String()), nil +} diff --git a/pkg/gui/services/custom_commands/menu_generator_test.go b/pkg/gui/services/custom_commands/menu_generator_test.go new file mode 100644 index 000000000..7dd3e58e8 --- /dev/null +++ b/pkg/gui/services/custom_commands/menu_generator_test.go @@ -0,0 +1,65 @@ +package custom_commands + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/stretchr/testify/assert" +) + +func TestMenuGenerator(t *testing.T) { + type scenario struct { + testName string + cmdOut string + filter string + valueFormat string + labelFormat string + test func([]*commandMenuEntry, error) + } + + scenarios := []scenario{ + { + "Extract remote branch name", + "upstream/pr-1", + "(?P<remote>[a-z_]+)/(?P<branch>.*)", + "{{ .branch }}", + "Remote: {{ .remote }}", + func(actualEntry []*commandMenuEntry, err error) { + assert.NoError(t, err) + assert.EqualValues(t, "pr-1", actualEntry[0].value) + assert.EqualValues(t, "Remote: upstream", actualEntry[0].label) + }, + }, + { + "Multiple named groups with empty labelFormat", + "upstream/pr-1", + "(?P<remote>[a-z]*)/(?P<branch>.*)", + "{{ .branch }}|{{ .remote }}", + "", + func(actualEntry []*commandMenuEntry, err error) { + assert.NoError(t, err) + assert.EqualValues(t, "pr-1|upstream", actualEntry[0].value) + assert.EqualValues(t, "pr-1|upstream", actualEntry[0].label) + }, + }, + { + "Multiple named groups with group ids", + "upstream/pr-1", + "(?P<remote>[a-z]*)/(?P<branch>.*)", + "{{ .group_2 }}|{{ .group_1 }}", + "Remote: {{ .group_1 }}", + func(actualEntry []*commandMenuEntry, err error) { + assert.NoError(t, err) + assert.EqualValues(t, "pr-1|upstream", actualEntry[0].value) + assert.EqualValues(t, "Remote: upstream", actualEntry[0].label) + }, + }, + } + + for _, s := range scenarios { + s := s + t.Run(s.testName, func(t *testing.T) { + s.test(NewMenuGenerator(utils.NewDummyCommon()).call(s.cmdOut, s.filter, s.valueFormat, s.labelFormat)) + }) + } +} diff --git a/pkg/gui/services/custom_commands/resolver.go b/pkg/gui/services/custom_commands/resolver.go new file mode 100644 index 000000000..ee965e5cd --- /dev/null +++ b/pkg/gui/services/custom_commands/resolver.go @@ -0,0 +1,98 @@ +package custom_commands + +import ( + "github.com/jesseduffield/lazygit/pkg/common" + "github.com/jesseduffield/lazygit/pkg/config" +) + +// takes a prompt that is defined in terms of template strings and resolves the templates to contain actual values +type Resolver struct { + c *common.Common +} + +func NewResolver(c *common.Common) *Resolver { + return &Resolver{c: c} +} + +func (self *Resolver) resolvePrompt( + prompt *config.CustomCommandPrompt, + resolveTemplate func(string) (string, error), +) (*config.CustomCommandPrompt, error) { + var err error + result := &config.CustomCommandPrompt{ + ValueFormat: prompt.ValueFormat, + LabelFormat: prompt.LabelFormat, + } + + result.Title, err = resolveTemplate(prompt.Title) + if err != nil { + return nil, err + } + + result.InitialValue, err = resolveTemplate(prompt.InitialValue) + if err != nil { + return nil, err + } + + result.Command, err = resolveTemplate(prompt.Command) + if err != nil { + return nil, err + } + + result.Filter, err = resolveTemplate(prompt.Filter) + if err != nil { + return nil, err + } + + if prompt.Type == "menu" { + result.Options, err = self.resolveMenuOptions(prompt, resolveTemplate) + if err != nil { + return nil, err + } + } + + return result, nil +} + +func (self *Resolver) resolveMenuOptions(prompt *config.CustomCommandPrompt, resolveTemplate func(string) (string, error)) ([]config.CustomCommandMenuOption, error) { + newOptions := make([]config.CustomCommandMenuOption, 0, len(prompt.Options)) + for _, option := range prompt.Options { + option := option + newOption, err := self.resolveMenuOption(&option, resolveTemplate) + if err != nil { + return nil, err + } + newOptions = append(newOptions, *newOption) + } + + return newOptions, nil +} + +func (self *Resolver) resolveMenuOption(option *config.CustomCommandMenuOption, resolveTemplate func(string) (string, error)) (*config.CustomCommandMenuOption, error) { + nameTemplate := option.Name + if nameTemplate == "" { + // this allows you to only pass values rather than bother with names/descriptions + nameTemplate = option.Value + } + + name, err := resolveTemplate(nameTemplate) + if err != nil { + return nil, err + } + + description, err := resolveTemplate(option.Description) + if err != nil { + return nil, err + } + + value, err := resolveTemplate(option.Value) + if err != nil { + return nil, err + } + + return &config.CustomCommandMenuOption{ + Name: name, + Description: description, + Value: value, + }, nil +} diff --git a/pkg/gui/services/custom_commands/session_state_loader.go b/pkg/gui/services/custom_commands/session_state_loader.go new file mode 100644 index 000000000..42f3403ec --- /dev/null +++ b/pkg/gui/services/custom_commands/session_state_loader.go @@ -0,0 +1,56 @@ +package custom_commands + +import ( + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" +) + +// loads the session state at the time that a custom command is invoked, for use +// in the custom command's template strings +type SessionStateLoader struct { + contexts *context.ContextTree + helpers *helpers.Helpers +} + +func NewSessionStateLoader(contexts *context.ContextTree, helpers *helpers.Helpers) *SessionStateLoader { + return &SessionStateLoader{ + contexts: contexts, + helpers: helpers, + } +} + +// SessionState captures the current state of the application for use in custom commands +type SessionState struct { + SelectedLocalCommit *models.Commit + SelectedReflogCommit *models.Commit + SelectedSubCommit *models.Commit + SelectedFile *models.File + SelectedPath string + SelectedLocalBranch *models.Branch + SelectedRemoteBranch *models.RemoteBranch + SelectedRemote *models.Remote + SelectedTag *models.Tag + SelectedStashEntry *models.StashEntry + SelectedCommitFile *models.CommitFile + SelectedCommitFilePath string + CheckedOutBranch *models.Branch +} + +func (self *SessionStateLoader) call() *SessionState { + return &SessionState{ + SelectedFile: self.contexts.Files.GetSelectedFile(), + SelectedPath: self.contexts.Files.GetSelectedPath(), + SelectedLocalCommit: self.contexts.LocalCommits.GetSelected(), + SelectedReflogCommit: self.contexts.ReflogCommits.GetSelected(), + SelectedLocalBranch: self.contexts.Branches.GetSelected(), + SelectedRemoteBranch: self.contexts.RemoteBranches.GetSelected(), + SelectedRemote: self.contexts.Remotes.GetSelected(), + SelectedTag: self.contexts.Tags.GetSelected(), + SelectedStashEntry: self.contexts.Stash.GetSelected(), + SelectedCommitFile: self.contexts.CommitFiles.GetSelectedFile(), + SelectedCommitFilePath: self.contexts.CommitFiles.GetSelectedPath(), + SelectedSubCommit: self.contexts.SubCommits.GetSelected(), + CheckedOutBranch: self.helpers.Refs.GetCheckedOutRef(), + } +} |