diff options
-rw-r--r-- | pkg/gui/custom_commands.go | 371 | ||||
-rw-r--r-- | pkg/gui/filetree/commit_file_tree_view_model.go | 18 | ||||
-rw-r--r-- | pkg/gui/filetree/file_tree_view_model.go | 18 | ||||
-rw-r--r-- | pkg/gui/gui.go | 35 | ||||
-rw-r--r-- | pkg/gui/keybindings.go | 6 | ||||
-rw-r--r-- | pkg/gui/options_menu_panel.go | 7 | ||||
-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 (renamed from pkg/gui/custom_commands_test.go) | 15 | ||||
-rw-r--r-- | pkg/gui/services/custom_commands/resolver.go | 98 | ||||
-rw-r--r-- | pkg/gui/services/custom_commands/session_state_loader.go | 56 |
13 files changed, 701 insertions, 391 deletions
diff --git a/pkg/gui/custom_commands.go b/pkg/gui/custom_commands.go deleted file mode 100644 index b4c977f3b..000000000 --- a/pkg/gui/custom_commands.go +++ /dev/null @@ -1,371 +0,0 @@ -package gui - -import ( - "bytes" - "errors" - "log" - "regexp" - "strconv" - "strings" - "text/template" - - "github.com/jesseduffield/gocui" - "github.com/jesseduffield/lazygit/pkg/commands/models" - "github.com/jesseduffield/lazygit/pkg/config" - "github.com/jesseduffield/lazygit/pkg/gui/context" - "github.com/jesseduffield/lazygit/pkg/gui/style" - "github.com/jesseduffield/lazygit/pkg/gui/types" - "github.com/jesseduffield/lazygit/pkg/utils" -) - -type CustomCommandObjects 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 - PromptResponses []string -} - -type commandMenuEntry struct { - label string - value string -} - -func (gui *Gui) getResolveTemplateFn(promptResponses []string) func(string) (string, error) { - objects := CustomCommandObjects{ - SelectedFile: gui.getSelectedFile(), - SelectedPath: gui.getSelectedPath(), - SelectedLocalCommit: gui.State.Contexts.LocalCommits.GetSelected(), - SelectedReflogCommit: gui.State.Contexts.ReflogCommits.GetSelected(), - SelectedLocalBranch: gui.State.Contexts.Branches.GetSelected(), - SelectedRemoteBranch: gui.State.Contexts.RemoteBranches.GetSelected(), - SelectedRemote: gui.State.Contexts.Remotes.GetSelected(), - SelectedTag: gui.State.Contexts.Tags.GetSelected(), - SelectedStashEntry: gui.State.Contexts.Stash.GetSelected(), - SelectedCommitFile: gui.getSelectedCommitFile(), - SelectedCommitFilePath: gui.getSelectedCommitFilePath(), - SelectedSubCommit: gui.State.Contexts.SubCommits.GetSelected(), - CheckedOutBranch: gui.helpers.Refs.GetCheckedOutRef(), - PromptResponses: promptResponses, - } - - return func(templateStr string) (string, error) { return utils.ResolveTemplate(templateStr, objects) } -} - -func resolveCustomCommandPrompt(prompt *config.CustomCommandPrompt, resolveTemplate func(string) (string, error)) (*config.CustomCommandPrompt, error) { - var err error - result := &config.CustomCommandPrompt{} - - 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 len(prompt.Options) > 0 { - newOptions := make([]config.CustomCommandMenuOption, len(prompt.Options)) - for _, option := range prompt.Options { - option := option - newOption, err := resolveMenuOption(&option, resolveTemplate) - if err != nil { - return nil, err - } - newOptions = append(newOptions, *newOption) - } - prompt.Options = newOptions - } - - return result, nil -} - -func 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 -} - -func (gui *Gui) inputPrompt(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error { - return gui.c.Prompt(types.PromptOpts{ - Title: prompt.Title, - InitialContent: prompt.InitialValue, - HandleConfirm: func(str string) error { - return wrappedF(str) - }, - }) -} - -func (gui *Gui) 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 gui.c.Menu(types.CreateMenuOptions{Title: prompt.Title, Items: menuItems}) -} - -func (gui *Gui) GenerateMenuCandidates(commandOutput, filter, valueFormat, labelFormat string) ([]commandMenuEntry, error) { - reg, err := regexp.Compile(filter) - if err != nil { - return nil, gui.c.Error(errors.New("unable to parse filter regex, error: " + err.Error())) - } - - buff := bytes.NewBuffer(nil) - - valueTemp, err := template.New("format").Parse(valueFormat) - if err != nil { - return nil, gui.c.Error(errors.New("unable to parse value format, error: " + err.Error())) - } - - colorFuncMap := style.TemplateFuncMapAddColors(template.FuncMap{}) - - descTemp, err := template.New("format").Funcs(colorFuncMap).Parse(labelFormat) - if err != nil { - return nil, gui.c.Error(errors.New("unable to parse label format, error: " + err.Error())) - } - - candidates := []commandMenuEntry{} - for _, str := range strings.Split(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 = valueTemp.Execute(buff, tmplData) - if err != nil { - return candidates, gui.c.Error(err) - } - entry := commandMenuEntry{ - value: strings.TrimSpace(buff.String()), - } - - if labelFormat != "" { - buff.Reset() - err = descTemp.Execute(buff, tmplData) - if err != nil { - return candidates, gui.c.Error(err) - } - entry.label = strings.TrimSpace(buff.String()) - } else { - entry.label = entry.value - } - - candidates = append(candidates, entry) - - buff.Reset() - } - return candidates, err -} - -func (gui *Gui) menuPromptFromCommand(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error { - // Run and save output - message, err := gui.git.Custom.RunWithOutput(prompt.Command) - if err != nil { - return gui.c.Error(err) - } - - // Need to make a menu out of what the cmd has displayed - candidates, err := gui.GenerateMenuCandidates(message, prompt.Filter, prompt.ValueFormat, prompt.LabelFormat) - if err != nil { - return gui.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 gui.c.Menu(types.CreateMenuOptions{Title: prompt.Title, Items: menuItems}) -} - -func (gui *Gui) handleCustomCommandKeybinding(customCommand config.CustomCommand) func() error { - return func() error { - promptResponses := make([]string, len(customCommand.Prompts)) - - f := func() error { - resolveTemplate := gui.getResolveTemplateFn(promptResponses) - cmdStr, err := resolveTemplate(customCommand.Command) - if err != nil { - return gui.c.Error(err) - } - - if customCommand.Subprocess { - return gui.runSubprocessWithSuspenseAndRefresh(gui.os.Cmd.NewShell(cmdStr)) - } - - loadingText := customCommand.LoadingText - if loadingText == "" { - loadingText = gui.c.Tr.LcRunningCustomCommandStatus - } - - return gui.c.WithWaitingStatus(loadingText, func() error { - gui.c.LogAction(gui.c.Tr.Actions.CustomCommand) - cmdObj := gui.os.Cmd.NewShell(cmdStr) - if customCommand.Stream { - cmdObj.StreamOutput() - } - err := cmdObj.Run() - if err != nil { - return gui.c.Error(err) - } - return gui.c.Refresh(types.RefreshOptions{}) - }) - } - - // 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 { - 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 f() - } - - resolveTemplate := gui.getResolveTemplateFn(promptResponses) - resolvedPrompt, err := resolveCustomCommandPrompt(&prompt, resolveTemplate) - if err != nil { - return gui.c.Error(err) - } - - switch prompt.Type { - case "input": - f = func() error { - return gui.inputPrompt(resolvedPrompt, wrappedF) - } - case "menu": - f = func() error { - return gui.menuPrompt(resolvedPrompt, wrappedF) - } - case "menuFromCommand": - f = func() error { - return gui.menuPromptFromCommand(resolvedPrompt, wrappedF) - } - default: - return gui.c.ErrorMsg("custom command prompt must have a type of 'input', 'menu' or 'menuFromCommand'") - } - } - - return f() - } -} - -func (gui *Gui) GetCustomCommandKeybindings() []*types.Binding { - bindings := []*types.Binding{} - customCommands := gui.c.UserConfig.CustomCommands - - for _, customCommand := range customCommands { - var viewName string - var contexts []string - switch customCommand.Context { - case "global": - viewName = "" - case "": - log.Fatalf("Error parsing custom command keybindings: context not provided (use context: 'global' for the global context). Key: %s, Command: %s", customCommand.Key, customCommand.Command) - default: - ctx, ok := gui.contextForContextKey(types.ContextKey(customCommand.Context)) - // 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]) - } - if !ok { - log.Fatalf("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, ", ")) - } - // 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} - } - - description := customCommand.Description - if description == "" { - description = customCommand.Command - } - - bindings = append(bindings, &types.Binding{ - ViewName: viewName, - Contexts: contexts, - Key: gui.getKey(customCommand.Key), - Modifier: gocui.ModNone, - Handler: gui.handleCustomCommandKeybinding(customCommand), - Description: description, - }) - } - - return bindings -} diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go index 86e7e864e..e80003d28 100644 --- a/pkg/gui/filetree/commit_file_tree_view_model.go +++ b/pkg/gui/filetree/commit_file_tree_view_model.go @@ -69,6 +69,24 @@ func (self *CommitFileTreeViewModel) GetSelectedFileNode() *CommitFileNode { return self.GetItemAtIndex(self.GetSelectedLineIdx()) } +func (self *CommitFileTreeViewModel) GetSelectedFile() *models.CommitFile { + node := self.GetSelectedFileNode() + if node == nil { + return nil + } + + return node.File +} + +func (self *CommitFileTreeViewModel) GetSelectedPath() string { + node := self.GetSelectedFileNode() + if node == nil { + return "" + } + + return node.GetPath() +} + // duplicated from file_tree_view_model.go. Generics will help here func (self *CommitFileTreeViewModel) ToggleShowTree() { selectedNode := self.GetSelectedFileNode() diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index 814d6eaac..9adb04cf1 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -43,6 +43,24 @@ func (self *FileTreeViewModel) GetSelectedFileNode() *FileNode { return self.GetItemAtIndex(self.GetSelectedLineIdx()) } +func (self *FileTreeViewModel) GetSelectedFile() *models.File { + node := self.GetSelectedFileNode() + if node == nil { + return nil + } + + return node.File +} + +func (self *FileTreeViewModel) GetSelectedPath() string { + node := self.GetSelectedFileNode() + if node == nil { + return "" + } + + return node.GetPath() +} + func (self *FileTreeViewModel) SetTree() { newFiles := self.GetAllFiles() selectedNode := self.GetSelectedFileNode() diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 6b371699a..edcabba7f 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -30,6 +30,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/presentation/authors" "github.com/jesseduffield/lazygit/pkg/gui/presentation/graph" + "github.com/jesseduffield/lazygit/pkg/gui/services/custom_commands" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/tasks" @@ -80,6 +81,8 @@ type Gui struct { // this is the state of the GUI for the current repo State *GuiRepoState + CustomCommandsClient *custom_commands.Client + // this is a mapping of repos to gui states, so that we can restore the original // gui state when returning from a subrepo RepoStateMap map[Repo]*GuiRepoState @@ -496,28 +499,29 @@ func NewGui( } func (gui *Gui) resetControllers() { - controllerCommon := gui.c + helperCommon := gui.c osCommand := gui.os model := gui.State.Model refsHelper := helpers.NewRefsHelper( - controllerCommon, + helperCommon, gui.git, gui.State.Contexts, model, ) - rebaseHelper := helpers.NewMergeAndRebaseHelper(controllerCommon, gui.State.Contexts, gui.git, gui.takeOverMergeConflictScrolling, refsHelper) + + rebaseHelper := helpers.NewMergeAndRebaseHelper(helperCommon, gui.State.Contexts, gui.git, gui.takeOverMergeConflictScrolling, refsHelper) gui.helpers = &helpers.Helpers{ Refs: refsHelper, - PatchBuilding: helpers.NewPatchBuildingHelper(controllerCommon, gui.git), - Bisect: helpers.NewBisectHelper(controllerCommon, gui.git), - Suggestions: helpers.NewSuggestionsHelper(controllerCommon, model, gui.refreshSuggestions), - Files: helpers.NewFilesHelper(controllerCommon, gui.git, osCommand), + PatchBuilding: helpers.NewPatchBuildingHelper(helperCommon, gui.git), + Bisect: helpers.NewBisectHelper(helperCommon, gui.git), + Suggestions: helpers.NewSuggestionsHelper(helperCommon, model, gui.refreshSuggestions), + Files: helpers.NewFilesHelper(helperCommon, gui.git, osCommand), WorkingTree: helpers.NewWorkingTreeHelper(model), - Tags: helpers.NewTagsHelper(controllerCommon, gui.git), - GPG: helpers.NewGpgHelper(controllerCommon, gui.os, gui.git), + Tags: helpers.NewTagsHelper(helperCommon, gui.git), + GPG: helpers.NewGpgHelper(helperCommon, gui.os, gui.git), MergeAndRebase: rebaseHelper, CherryPick: helpers.NewCherryPickHelper( - controllerCommon, + helperCommon, gui.git, gui.State.Contexts, func() *cherrypicking.CherryPicking { return gui.State.Modes.CherryPicking }, @@ -525,8 +529,17 @@ func (gui *Gui) resetControllers() { ), } + gui.CustomCommandsClient = custom_commands.NewClient( + helperCommon, + gui.os, + gui.git, + gui.State.Contexts, + gui.helpers, + gui.getKey, + ) + common := controllers.NewControllerCommon( - controllerCommon, + helperCommon, osCommand, gui.git, gui.helpers, diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 7401f3416..7c5e0be25 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -1116,7 +1116,11 @@ func (gui *Gui) resetKeybindings() error { bindings, mouseBindings := gui.GetInitialKeybindings() // prepending because we want to give our custom keybindings precedence over default keybindings - bindings = append(gui.GetCustomCommandKeybindings(), bindings...) + customBindings, err := gui.CustomCommandsClient.GetCustomCommandKeybindings() + if err != nil { + log.Fatal(err) + } + bindings = append(customBindings, bindings...) for _, binding := range bindings { if err := gui.SetKeybinding(binding); err != nil { diff --git a/pkg/gui/options_menu_panel.go b/pkg/gui/options_menu_panel.go index 3ca6ea388..17ced988e 100644 --- a/pkg/gui/options_menu_panel.go +++ b/pkg/gui/options_menu_panel.go @@ -1,6 +1,7 @@ package gui import ( + "log" "strings" "github.com/jesseduffield/lazygit/pkg/gui/presentation" @@ -15,7 +16,11 @@ func (gui *Gui) getBindings(context types.Context) []*types.Binding { ) bindings, _ := gui.GetInitialKeybindings() - bindings = append(gui.GetCustomCommandKeybindings(), bindings...) + customBindings, err := gui.CustomCommandsClient.GetCustomCommandKeybindings() + if err != nil { + log.Fatal(err) + } + bindings = append(customBindings, bindings...) for _, binding := range bindings { if GetKeyDisplay(binding.Key) != "" && binding.Description != "" { 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/ |