summaryrefslogtreecommitdiffstats
path: root/pkg/gui
diff options
context:
space:
mode:
authorJesse Duffield <jessedduffield@gmail.com>2022-02-24 13:29:48 +1100
committerJesse Duffield <jessedduffield@gmail.com>2022-03-17 19:13:40 +1100
commitef7c4c9ca93ec15db4886d8d6f78c85a1db7edef (patch)
tree57c2cff341b03d12b69ac57226376563e9089f75 /pkg/gui
parent952a4f3f2388da4ab88005b02f264aca0172afe7 (diff)
refactor custom commands
more custom command refactoring
Diffstat (limited to 'pkg/gui')
-rw-r--r--pkg/gui/custom_commands.go371
-rw-r--r--pkg/gui/filetree/commit_file_tree_view_model.go18
-rw-r--r--pkg/gui/filetree/file_tree_view_model.go18
-rw-r--r--pkg/gui/gui.go35
-rw-r--r--pkg/gui/keybindings.go6
-rw-r--r--pkg/gui/options_menu_panel.go7
-rw-r--r--pkg/gui/services/custom_commands/client.go52
-rw-r--r--pkg/gui/services/custom_commands/handler_creator.go187
-rw-r--r--pkg/gui/services/custom_commands/keybinding_creator.go91
-rw-r--r--pkg/gui/services/custom_commands/menu_generator.go138
-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.go98
-rw-r--r--pkg/gui/services/custom_commands/session_state_loader.go56
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 *Keybin