mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-05-17 22:32:58 +02:00
refactor custom commands
more custom command refactoring
This commit is contained in:
parent
952a4f3f23
commit
ef7c4c9ca9
@ -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
|
|
||||||
}
|
|
@ -69,6 +69,24 @@ func (self *CommitFileTreeViewModel) GetSelectedFileNode() *CommitFileNode {
|
|||||||
return self.GetItemAtIndex(self.GetSelectedLineIdx())
|
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
|
// duplicated from file_tree_view_model.go. Generics will help here
|
||||||
func (self *CommitFileTreeViewModel) ToggleShowTree() {
|
func (self *CommitFileTreeViewModel) ToggleShowTree() {
|
||||||
selectedNode := self.GetSelectedFileNode()
|
selectedNode := self.GetSelectedFileNode()
|
||||||
|
@ -43,6 +43,24 @@ func (self *FileTreeViewModel) GetSelectedFileNode() *FileNode {
|
|||||||
return self.GetItemAtIndex(self.GetSelectedLineIdx())
|
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() {
|
func (self *FileTreeViewModel) SetTree() {
|
||||||
newFiles := self.GetAllFiles()
|
newFiles := self.GetAllFiles()
|
||||||
selectedNode := self.GetSelectedFileNode()
|
selectedNode := self.GetSelectedFileNode()
|
||||||
|
@ -30,6 +30,7 @@ import (
|
|||||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation/authors"
|
"github.com/jesseduffield/lazygit/pkg/gui/presentation/authors"
|
||||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation/graph"
|
"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/style"
|
||||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||||
"github.com/jesseduffield/lazygit/pkg/tasks"
|
"github.com/jesseduffield/lazygit/pkg/tasks"
|
||||||
@ -80,6 +81,8 @@ type Gui struct {
|
|||||||
// this is the state of the GUI for the current repo
|
// this is the state of the GUI for the current repo
|
||||||
State *GuiRepoState
|
State *GuiRepoState
|
||||||
|
|
||||||
|
CustomCommandsClient *custom_commands.Client
|
||||||
|
|
||||||
// this is a mapping of repos to gui states, so that we can restore the original
|
// this is a mapping of repos to gui states, so that we can restore the original
|
||||||
// gui state when returning from a subrepo
|
// gui state when returning from a subrepo
|
||||||
RepoStateMap map[Repo]*GuiRepoState
|
RepoStateMap map[Repo]*GuiRepoState
|
||||||
@ -496,28 +499,29 @@ func NewGui(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (gui *Gui) resetControllers() {
|
func (gui *Gui) resetControllers() {
|
||||||
controllerCommon := gui.c
|
helperCommon := gui.c
|
||||||
osCommand := gui.os
|
osCommand := gui.os
|
||||||
model := gui.State.Model
|
model := gui.State.Model
|
||||||
refsHelper := helpers.NewRefsHelper(
|
refsHelper := helpers.NewRefsHelper(
|
||||||
controllerCommon,
|
helperCommon,
|
||||||
gui.git,
|
gui.git,
|
||||||
gui.State.Contexts,
|
gui.State.Contexts,
|
||||||
model,
|
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{
|
gui.helpers = &helpers.Helpers{
|
||||||
Refs: refsHelper,
|
Refs: refsHelper,
|
||||||
PatchBuilding: helpers.NewPatchBuildingHelper(controllerCommon, gui.git),
|
PatchBuilding: helpers.NewPatchBuildingHelper(helperCommon, gui.git),
|
||||||
Bisect: helpers.NewBisectHelper(controllerCommon, gui.git),
|
Bisect: helpers.NewBisectHelper(helperCommon, gui.git),
|
||||||
Suggestions: helpers.NewSuggestionsHelper(controllerCommon, model, gui.refreshSuggestions),
|
Suggestions: helpers.NewSuggestionsHelper(helperCommon, model, gui.refreshSuggestions),
|
||||||
Files: helpers.NewFilesHelper(controllerCommon, gui.git, osCommand),
|
Files: helpers.NewFilesHelper(helperCommon, gui.git, osCommand),
|
||||||
WorkingTree: helpers.NewWorkingTreeHelper(model),
|
WorkingTree: helpers.NewWorkingTreeHelper(model),
|
||||||
Tags: helpers.NewTagsHelper(controllerCommon, gui.git),
|
Tags: helpers.NewTagsHelper(helperCommon, gui.git),
|
||||||
GPG: helpers.NewGpgHelper(controllerCommon, gui.os, gui.git),
|
GPG: helpers.NewGpgHelper(helperCommon, gui.os, gui.git),
|
||||||
MergeAndRebase: rebaseHelper,
|
MergeAndRebase: rebaseHelper,
|
||||||
CherryPick: helpers.NewCherryPickHelper(
|
CherryPick: helpers.NewCherryPickHelper(
|
||||||
controllerCommon,
|
helperCommon,
|
||||||
gui.git,
|
gui.git,
|
||||||
gui.State.Contexts,
|
gui.State.Contexts,
|
||||||
func() *cherrypicking.CherryPicking { return gui.State.Modes.CherryPicking },
|
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(
|
common := controllers.NewControllerCommon(
|
||||||
controllerCommon,
|
helperCommon,
|
||||||
osCommand,
|
osCommand,
|
||||||
gui.git,
|
gui.git,
|
||||||
gui.helpers,
|
gui.helpers,
|
||||||
|
@ -1116,7 +1116,11 @@ func (gui *Gui) resetKeybindings() error {
|
|||||||
bindings, mouseBindings := gui.GetInitialKeybindings()
|
bindings, mouseBindings := gui.GetInitialKeybindings()
|
||||||
|
|
||||||
// prepending because we want to give our custom keybindings precedence over default keybindings
|
// 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 {
|
for _, binding := range bindings {
|
||||||
if err := gui.SetKeybinding(binding); err != nil {
|
if err := gui.SetKeybinding(binding); err != nil {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package gui
|
package gui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||||
@ -15,7 +16,11 @@ func (gui *Gui) getBindings(context types.Context) []*types.Binding {
|
|||||||
)
|
)
|
||||||
|
|
||||||
bindings, _ := gui.GetInitialKeybindings()
|
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 {
|
for _, binding := range bindings {
|
||||||
if GetKeyDisplay(binding.Key) != "" && binding.Description != "" {
|
if GetKeyDisplay(binding.Key) != "" && binding.Description != "" {
|
||||||
|
52
pkg/gui/services/custom_commands/client.go
Normal file
52
pkg/gui/services/custom_commands/client.go
Normal 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
|
||||||
|
}
|
187
pkg/gui/services/custom_commands/handler_creator.go
Normal file
187
pkg/gui/services/custom_commands/handler_creator.go
Normal 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{})
|
||||||
|
})
|
||||||
|
}
|
91
pkg/gui/services/custom_commands/keybinding_creator.go
Normal file
91
pkg/gui/services/custom_commands/keybinding_creator.go
Normal 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)
|
||||||
|
}
|
138
pkg/gui/services/custom_commands/menu_generator.go
Normal file
138
pkg/gui/services/custom_commands/menu_generator.go
Normal 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
|
||||||
|
}
|
@ -1,19 +1,20 @@
|
|||||||
package gui
|
package custom_commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGuiGenerateMenuCandidates(t *testing.T) {
|
func TestMenuGenerator(t *testing.T) {
|
||||||
type scenario struct {
|
type scenario struct {
|
||||||
testName string
|
testName string
|
||||||
cmdOut string
|
cmdOut string
|
||||||
filter string
|
filter string
|
||||||
valueFormat string
|
valueFormat string
|
||||||
labelFormat string
|
labelFormat string
|
||||||
test func([]commandMenuEntry, error)
|
test func([]*commandMenuEntry, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
scenarios := []scenario{
|
scenarios := []scenario{
|
||||||
@ -23,7 +24,7 @@ func TestGuiGenerateMenuCandidates(t *testing.T) {
|
|||||||
"(?P<remote>[a-z_]+)/(?P<branch>.*)",
|
"(?P<remote>[a-z_]+)/(?P<branch>.*)",
|
||||||
"{{ .branch }}",
|
"{{ .branch }}",
|
||||||
"Remote: {{ .remote }}",
|
"Remote: {{ .remote }}",
|
||||||
func(actualEntry []commandMenuEntry, err error) {
|
func(actualEntry []*commandMenuEntry, err error) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.EqualValues(t, "pr-1", actualEntry[0].value)
|
assert.EqualValues(t, "pr-1", actualEntry[0].value)
|
||||||
assert.EqualValues(t, "Remote: upstream", actualEntry[0].label)
|
assert.EqualValues(t, "Remote: upstream", actualEntry[0].label)
|
||||||
@ -35,7 +36,7 @@ func TestGuiGenerateMenuCandidates(t *testing.T) {
|
|||||||
"(?P<remote>[a-z]*)/(?P<branch>.*)",
|
"(?P<remote>[a-z]*)/(?P<branch>.*)",
|
||||||
"{{ .branch }}|{{ .remote }}",
|
"{{ .branch }}|{{ .remote }}",
|
||||||
"",
|
"",
|
||||||
func(actualEntry []commandMenuEntry, err error) {
|
func(actualEntry []*commandMenuEntry, err error) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.EqualValues(t, "pr-1|upstream", actualEntry[0].value)
|
assert.EqualValues(t, "pr-1|upstream", actualEntry[0].value)
|
||||||
assert.EqualValues(t, "pr-1|upstream", actualEntry[0].label)
|
assert.EqualValues(t, "pr-1|upstream", actualEntry[0].label)
|
||||||
@ -47,7 +48,7 @@ func TestGuiGenerateMenuCandidates(t *testing.T) {
|
|||||||
"(?P<remote>[a-z]*)/(?P<branch>.*)",
|
"(?P<remote>[a-z]*)/(?P<branch>.*)",
|
||||||
"{{ .group_2 }}|{{ .group_1 }}",
|
"{{ .group_2 }}|{{ .group_1 }}",
|
||||||
"Remote: {{ .group_1 }}",
|
"Remote: {{ .group_1 }}",
|
||||||
func(actualEntry []commandMenuEntry, err error) {
|
func(actualEntry []*commandMenuEntry, err error) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.EqualValues(t, "pr-1|upstream", actualEntry[0].value)
|
assert.EqualValues(t, "pr-1|upstream", actualEntry[0].value)
|
||||||
assert.EqualValues(t, "Remote: upstream", actualEntry[0].label)
|
assert.EqualValues(t, "Remote: upstream", actualEntry[0].label)
|
||||||
@ -58,7 +59,7 @@ func TestGuiGenerateMenuCandidates(t *testing.T) {
|
|||||||
for _, s := range scenarios {
|
for _, s := range scenarios {
|
||||||
s := s
|
s := s
|
||||||
t.Run(s.testName, func(t *testing.T) {
|
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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
98
pkg/gui/services/custom_commands/resolver.go
Normal file
98
pkg/gui/services/custom_commands/resolver.go
Normal 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
|
||||||
|
}
|
56
pkg/gui/services/custom_commands/session_state_loader.go
Normal file
56
pkg/gui/services/custom_commands/session_state_loader.go
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user