1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-04-02 22:25:47 +02:00

refactor custom commands

more custom command refactoring
This commit is contained in:
Jesse Duffield 2022-02-24 13:29:48 +11:00
parent 952a4f3f23
commit ef7c4c9ca9
13 changed files with 701 additions and 391 deletions

View File

@ -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
}

View File

@ -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()

View File

@ -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()

View File

@ -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,

View File

@ -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 {

View File

@ -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 != "" {

View File

@ -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
}

View File

@ -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{})
})
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -1,19 +1,20 @@
package gui
package custom_commands
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/stretchr/testify/assert"
)
func TestGuiGenerateMenuCandidates(t *testing.T) {
func TestMenuGenerator(t *testing.T) {
type scenario struct {
testName string
cmdOut string
filter string
valueFormat string
labelFormat string
test func([]commandMenuEntry, error)
test func([]*commandMenuEntry, error)
}
scenarios := []scenario{
@ -23,7 +24,7 @@ func TestGuiGenerateMenuCandidates(t *testing.T) {
"(?P<remote>[a-z_]+)/(?P<branch>.*)",
"{{ .branch }}",
"Remote: {{ .remote }}",
func(actualEntry []commandMenuEntry, err error) {
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)
@ -35,7 +36,7 @@ func TestGuiGenerateMenuCandidates(t *testing.T) {
"(?P<remote>[a-z]*)/(?P<branch>.*)",
"{{ .branch }}|{{ .remote }}",
"",
func(actualEntry []commandMenuEntry, err error) {
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)
@ -47,7 +48,7 @@ func TestGuiGenerateMenuCandidates(t *testing.T) {
"(?P<remote>[a-z]*)/(?P<branch>.*)",
"{{ .group_2 }}|{{ .group_1 }}",
"Remote: {{ .group_1 }}",
func(actualEntry []commandMenuEntry, err error) {
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)
@ -58,7 +59,7 @@ func TestGuiGenerateMenuCandidates(t *testing.T) {
for _, s := range scenarios {
s := s
t.Run(s.testName, func(t *testing.T) {
s.test(NewDummyGui().GenerateMenuCandidates(s.cmdOut, s.filter, s.valueFormat, s.labelFormat))
s.test(NewMenuGenerator(utils.NewDummyCommon()).call(s.cmdOut, s.filter, s.valueFormat, s.labelFormat))
})
}
}

View File

@ -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
}

View File

@ -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(),
}
}