Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add textbox custom command prompt #3548

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
25 changes: 24 additions & 1 deletion docs/Custom_Command_Keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ These fields are applicable to all prompts.

| _field_ | _description_ | _required_ |
| ------------ | -----------------------------------------------------------------------------------------------| ---------- |
| type | One of 'input', 'confirm', 'menu', 'menuFromCommand' | yes |
| type | One of 'input', 'confirm', 'menu', 'menuFromCommand', 'textbox | yes |
| title | The title to display in the popup panel | no |
| key | Used to reference the entered value from within the custom command. E.g. a prompt with `key: 'Branch'` can be referred to as `{{.Form.Branch}}` in the command | yes |

Expand Down Expand Up @@ -285,6 +285,29 @@ Here's an example using a command but not specifying anything else: so each line
command: 'ls'
```

### Textbox

| _field_ | _description_ | _required_ |
| ------------ | -----------------------------------------------------------------------------------------------| ---------- |
| initialValue | The initial value to appear in the text box | no |

Here's an example using textbox prompt.

```yml
- key : 'a'
description: 'Create new commit'
command: "git commit --message '{{.Form.Message}}' --message '{{.Form.Description}}'"
context: 'global'
prompts:
- type: 'input'
title: 'Commit Message'
key: 'Message'
- type: 'textbox'
title: 'Commit Description'
key: 'Description'
initialValue: 'resolves #'
```

## Placeholder values

Your commands can contain placeholder strings using Go's [template syntax](https://jan.newmarch.name/golang/template/chapter-template.html). The template syntax is pretty powerful, letting you do things like conditionals if you want, but for the most part you'll simply want to be accessing the fields on the following objects:
Expand Down
2 changes: 1 addition & 1 deletion pkg/config/user_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ type CustomCommand struct {
}

type CustomCommandPrompt struct {
// One of: 'input' | 'menu' | 'confirm' | 'menuFromCommand'
// One of: 'input' | 'menu' | 'confirm' | 'menuFromCommand' | 'textbox'
Type string `yaml:"type"`
// Used to reference the entered value from within the custom command. E.g. a prompt with `key: 'Branch'` can be referred to as `{{.Form.Branch}}` in the command
Key string `yaml:"key"`
Expand Down
3 changes: 3 additions & 0 deletions pkg/gui/context/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const (

MENU_CONTEXT_KEY types.ContextKey = "menu"
CONFIRMATION_CONTEXT_KEY types.ContextKey = "confirmation"
TEXTBOX_CONTEXT_KEY types.ContextKey = "textbox"
SEARCH_CONTEXT_KEY types.ContextKey = "search"
COMMIT_MESSAGE_CONTEXT_KEY types.ContextKey = "commitMessage"
COMMIT_DESCRIPTION_CONTEXT_KEY types.ContextKey = "commitDescription"
Expand Down Expand Up @@ -106,6 +107,7 @@ type ContextTree struct {
CustomPatchBuilderSecondary types.Context
MergeConflicts *MergeConflictsContext
Confirmation *ConfirmationContext
Textbox *TextboxContext
CommitMessage *CommitMessageContext
CommitDescription types.Context
CommandLog types.Context
Expand Down Expand Up @@ -141,6 +143,7 @@ func (self *ContextTree) Flatten() []types.Context {
self.Stash,
self.Menu,
self.Confirmation,
self.Textbox,
self.CommitMessage,
self.CommitDescription,

Expand Down
1 change: 1 addition & 0 deletions pkg/gui/context/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ func NewContextTree(c *ContextCommon) *ContextTree {
c,
),
Confirmation: NewConfirmationContext(c),
Textbox: NewTextboxContext(c),
CommitMessage: NewCommitMessageContext(c),
CommitDescription: NewSimpleContext(
NewBaseContext(NewBaseContextOpts{
Expand Down
35 changes: 35 additions & 0 deletions pkg/gui/context/textbox_context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package context

import (
"github.com/jesseduffield/lazygit/pkg/gui/types"
)

type TextboxContext struct {
*SimpleContext
c *ContextCommon

State TextboxContextState
}

type TextboxContextState struct {
OnConfirm func() error
OnClose func() error
}

var _ types.Context = (*TextboxContext)(nil)

func NewTextboxContext(
c *ContextCommon,
) *TextboxContext {
return &TextboxContext{
c: c,
SimpleContext: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().Textbox,
WindowName: "textbox",
Key: TEXTBOX_CONTEXT_KEY,
Kind: types.TEMPORARY_POPUP,
Focusable: true,
HasUncontrolledBounds: true,
})),
}
}
6 changes: 6 additions & 0 deletions pkg/gui/controllers.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ func (gui *Gui) resetHelpersAndControllers() {
View: viewHelper,
Refresh: refreshHelper,
Confirmation: helpers.NewConfirmationHelper(helperCommon),
Textbox: helpers.NewTextboxHelper(helperCommon),
Mode: modeHelper,
AppStatus: appStatusHelper,
InlineStatus: helpers.NewInlineStatusHelper(helperCommon, windowHelper),
Expand Down Expand Up @@ -191,6 +192,7 @@ func (gui *Gui) resetHelpersAndControllers() {
statusController := controllers.NewStatusController(common)
commandLogController := controllers.NewCommandLogController(common)
confirmationController := controllers.NewConfirmationController(common)
textboxController := controllers.NewTextboxController(common)
suggestionsController := controllers.NewSuggestionsController(common)
jumpToSideWindowController := controllers.NewJumpToSideWindowController(common)

Expand Down Expand Up @@ -367,6 +369,10 @@ func (gui *Gui) resetHelpersAndControllers() {
confirmationController,
)

controllers.AttachControllers(gui.State.Contexts.Textbox,
textboxController,
)

controllers.AttachControllers(gui.State.Contexts.Suggestions,
suggestionsController,
)
Expand Down
2 changes: 2 additions & 0 deletions pkg/gui/controllers/helpers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type Helpers struct {
View *ViewHelper
Refresh *RefreshHelper
Confirmation *ConfirmationHelper
Textbox *TextboxHelper
Mode *ModeHelper
AppStatus *AppStatusHelper
InlineStatus *InlineStatusHelper
Expand Down Expand Up @@ -82,6 +83,7 @@ func NewStubHelpers() *Helpers {
View: &ViewHelper{},
Refresh: &RefreshHelper{},
Confirmation: &ConfirmationHelper{},
Textbox: &TextboxHelper{},
Mode: &ModeHelper{},
AppStatus: &AppStatusHelper{},
InlineStatus: &InlineStatusHelper{},
Expand Down
151 changes: 151 additions & 0 deletions pkg/gui/controllers/helpers/textbox_helper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package helpers
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copy and paste lots from confirmation_helper.go
It has many functionalities about input, menu, and commit_message.
Please tell me some ideas on how to organize functions in confirmation_helpers.go and textbox_helpers.go. 🙇


import (
goContext "context"

"github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
)

type TextboxHelper struct {
c *HelperCommon
}

func NewTextboxHelper(c *HelperCommon) *TextboxHelper {
return &TextboxHelper{
c: c,
}
}

func (self *TextboxHelper) DeactivateTextboxPrompt() {
self.c.Mutexes().PopupMutex.Lock()
self.c.State().GetRepoState().SetCurrentPopupOpts(nil)
self.c.Mutexes().PopupMutex.Unlock()

self.c.Views().Textbox.Visible = false
self.clearTextboxViewKeyBindings()
}

func (self *TextboxHelper) clearTextboxViewKeyBindings() {
noop := func() error { return nil }
self.c.Contexts().Textbox.State.OnConfirm = noop
self.c.Contexts().Textbox.State.OnClose = noop
}

func (self *TextboxHelper) CreatePopupPanel(ctx goContext.Context, opts types.CreatePopupPanelOpts) error {
self.c.Mutexes().PopupMutex.Lock()
defer self.c.Mutexes().PopupMutex.Unlock()

_, cancel := goContext.WithCancel(ctx)

// we don't allow interruptions of non-loader popups in case we get stuck somehow
// e.g. a credentials popup never gets its required user input so a process hangs
// forever.
// The proper solution is to have a queue of popup options
currentPopupOpts := self.c.State().GetRepoState().GetCurrentPopupOpts()
if currentPopupOpts != nil && !currentPopupOpts.HasLoader {
self.c.Log.Error("ignoring create popup panel because a popup panel is already open")
cancel()
return nil
}

textboxView := self.c.Views().Textbox

textboxView.Title = opts.Title
// Introduce confirm key bindings of textbox to users
textboxView.Subtitle = utils.ResolvePlaceholderString(self.c.Tr.TextboxSubTitle,
map[string]string{
"textboxConfirmBinding": keybindings.Label(self.c.UserConfig.Keybinding.Universal.ConfirmInEditor),
})

textboxView.Wrap = !opts.Editable
textboxView.FgColor = theme.GocuiDefaultTextColor
textboxView.Mask = runeForMask(opts.Mask)

// Set view position
width := self.getPopupPanelWidth()
height := self.getPopupPanelHeight()
x0, y0, x1, y1 := self.getPosition(width, height)
self.c.GocuiGui().SetView(textboxView.Name(), x0, y0, x1, y1, 0)

// Render text in textbox
textboxView.Editable = opts.Editable
textArea := textboxView.TextArea
textArea.Clear()
textArea.TypeString(opts.Prompt)
textboxView.RenderTextArea()

// Setting Handlers
self.c.Contexts().Textbox.State.OnConfirm = self.wrappedPromptTextboxFunction(cancel, opts.HandleConfirmPrompt, func() string { return self.c.Views().Textbox.TextArea.GetContent() })
self.c.Contexts().Textbox.State.OnClose = self.wrappedTextboxFunction(cancel, opts.HandleClose)

// Set text box to current popup
self.c.State().GetRepoState().SetCurrentPopupOpts(&opts)

return self.c.PushContext(self.c.Contexts().Textbox)
}

func (self *TextboxHelper) wrappedPromptTextboxFunction(cancel goContext.CancelFunc, function func(string) error, getResponse func() string) func() error {
return self.wrappedTextboxFunction(cancel, func() error {
return function(getResponse())
})
}

func (self *TextboxHelper) wrappedTextboxFunction(cancel goContext.CancelFunc, function func() error) func() error {
return func() error {
cancel()

if err := self.c.PopContext(); err != nil {
return err
}

if function != nil {
if err := function(); err != nil {
return err
}
}

return nil
}
}

func (self *TextboxHelper) getPosition(panelWidth int, panelHeight int) (int, int, int, int) {
width, height := self.c.GocuiGui().Size()
if panelHeight > height * 3 / 4 {
panelHeight = height * 3 / 4
}
return width / 2 - panelWidth / 2,
height / 2 - panelHeight / 2 - panelHeight % 2 - 1,
width / 2 + panelWidth / 2,
height / 2 + panelHeight / 2
}

func (self *TextboxHelper) getPopupPanelWidth() int {
width, _ := self.c.GocuiGui().Size()
panelWidth := 4 * width / 7
minWidth := 80
if panelWidth < minWidth {
if width - 2 < minWidth {
panelWidth = width - 2
} else {
panelWidth = minWidth
}
}

return panelWidth
}

func (self *TextboxHelper) getPopupPanelHeight() int {
_, height := self.c.GocuiGui().Size()
var panelHeight int
maxHeight := 11
if height - 2 > maxHeight {
panelHeight = maxHeight
} else {
panelHeight = height - 2
}

return panelHeight
}
55 changes: 55 additions & 0 deletions pkg/gui/controllers/textbox_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package controllers

import (
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)

type TextboxController struct {
baseController
c *ControllerCommon
}

var _ types.IController = &TextboxController{}

func NewTextboxController(
c *ControllerCommon,
) *TextboxController {
return &TextboxController{
baseController: baseController{},
c: c,
}
}

func (self *TextboxController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{
{
Key: opts.GetKey(opts.Config.Universal.ConfirmInEditor),
Handler: func() error { return self.context().State.OnConfirm() },
Description: self.c.Tr.Confirm,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.Return),
Handler: func() error { return self.context().State.OnClose() },
Description: self.c.Tr.CloseCancel,
DisplayOnScreen: true,
},
}
return bindings
}

func (self *TextboxController) GetOnFocusLost() func(types.OnFocusLostOpts) error {
return func(types.OnFocusLostOpts) error {
self.c.Helpers().Textbox.DeactivateTextboxPrompt()
return nil
}
}

func (self *TextboxController) Context() types.Context {
return self.context()
}

func (self *TextboxController) context() *context.TextboxContext {
return self.c.Contexts().Textbox
}
7 changes: 7 additions & 0 deletions pkg/gui/editors.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Mo
return matched
}


func (gui *Gui) textboxEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
matched := gui.handleEditorKeypress(v.TextArea, key, ch, mod, true)
v.RenderTextArea()
return matched
}

func (gui *Gui) searchEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
matched := gui.handleEditorKeypress(v.TextArea, key, ch, mod, false)
v.RenderTextArea()
Expand Down
3 changes: 3 additions & 0 deletions pkg/gui/gui.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,9 @@ func NewGui(
gui.PopupHandler = popup.NewPopupHandler(
cmn,
func(ctx goContext.Context, opts types.CreatePopupPanelOpts) error {
if opts.Multiline {
return gui.helpers.Textbox.CreatePopupPanel(ctx, opts)
}
return gui.helpers.Confirmation.CreatePopupPanel(ctx, opts)
},
func() error { return gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) },
Expand Down