mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-04-23 12:18:51 +02:00
We forgot to handle the "suggestions" and "commitDescription" view names. Instead of listing all the names of views that can appear in popups though, let's use the context kind for this, which feels more robust. This is a change in behavior: previously, clicking outside of the search or filter prompt would close the prompt, now it no longer does (because search has a persistent popup kind, but it wasn't listed in the list of view names before).
475 lines
15 KiB
Go
475 lines
15 KiB
Go
package helpers
|
|
|
|
import (
|
|
goContext "context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/jesseduffield/gocui"
|
|
|
|
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/jesseduffield/lazygit/pkg/theme"
|
|
"github.com/mattn/go-runewidth"
|
|
)
|
|
|
|
type ConfirmationHelper struct {
|
|
c *HelperCommon
|
|
}
|
|
|
|
func NewConfirmationHelper(c *HelperCommon) *ConfirmationHelper {
|
|
return &ConfirmationHelper{
|
|
c: c,
|
|
}
|
|
}
|
|
|
|
// This file is for the rendering of confirmation panels along with setting and handling associated
|
|
// keybindings.
|
|
|
|
func (self *ConfirmationHelper) wrappedConfirmationFunction(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 *ConfirmationHelper) wrappedPromptConfirmationFunction(cancel goContext.CancelFunc, function func(string) error, getResponse func() string) func() error {
|
|
return self.wrappedConfirmationFunction(cancel, func() error {
|
|
return function(getResponse())
|
|
})
|
|
}
|
|
|
|
func (self *ConfirmationHelper) DeactivateConfirmationPrompt() {
|
|
self.c.Mutexes().PopupMutex.Lock()
|
|
self.c.State().GetRepoState().SetCurrentPopupOpts(nil)
|
|
self.c.Mutexes().PopupMutex.Unlock()
|
|
|
|
self.c.Views().Confirmation.Visible = false
|
|
self.c.Views().Suggestions.Visible = false
|
|
|
|
self.clearConfirmationViewKeyBindings()
|
|
}
|
|
|
|
// Temporary hack: we're just duplicating the logic in `gocui.lineWrap`
|
|
func getMessageHeight(wrap bool, message string, width int) int {
|
|
return len(wrapMessageToWidth(wrap, message, width))
|
|
}
|
|
|
|
func wrapMessageToWidth(wrap bool, message string, width int) []string {
|
|
lines := strings.Split(message, "\n")
|
|
if !wrap {
|
|
return lines
|
|
}
|
|
|
|
wrappedLines := make([]string, 0, len(lines))
|
|
|
|
for _, line := range lines {
|
|
n := 0
|
|
offset := 0
|
|
lastWhitespaceIndex := -1
|
|
for i, currChr := range line {
|
|
rw := runewidth.RuneWidth(currChr)
|
|
n += rw
|
|
|
|
if n > width {
|
|
if currChr == ' ' {
|
|
wrappedLines = append(wrappedLines, line[offset:i])
|
|
offset = i + 1
|
|
n = 0
|
|
} else if currChr == '-' {
|
|
wrappedLines = append(wrappedLines, line[offset:i])
|
|
offset = i
|
|
n = rw
|
|
} else if lastWhitespaceIndex != -1 && lastWhitespaceIndex+1 != i {
|
|
if line[lastWhitespaceIndex] == '-' {
|
|
wrappedLines = append(wrappedLines, line[offset:lastWhitespaceIndex+1])
|
|
offset = lastWhitespaceIndex + 1
|
|
n = i - lastWhitespaceIndex
|
|
} else {
|
|
wrappedLines = append(wrappedLines, line[offset:lastWhitespaceIndex])
|
|
offset = lastWhitespaceIndex + 1
|
|
n = i - lastWhitespaceIndex + 1
|
|
}
|
|
} else {
|
|
wrappedLines = append(wrappedLines, line[offset:i])
|
|
offset = i
|
|
n = rw
|
|
}
|
|
lastWhitespaceIndex = -1
|
|
} else if currChr == ' ' || currChr == '-' {
|
|
lastWhitespaceIndex = i
|
|
}
|
|
}
|
|
|
|
wrappedLines = append(wrappedLines, line[offset:])
|
|
}
|
|
|
|
return wrappedLines
|
|
}
|
|
|
|
func (self *ConfirmationHelper) getPopupPanelDimensions(wrap bool, prompt string) (int, int, int, int) {
|
|
panelWidth := self.getPopupPanelWidth()
|
|
panelHeight := getMessageHeight(wrap, prompt, panelWidth)
|
|
return self.getPopupPanelDimensionsAux(panelWidth, panelHeight)
|
|
}
|
|
|
|
func (self *ConfirmationHelper) getPopupPanelDimensionsForContentHeight(panelWidth, contentHeight int) (int, int, int, int) {
|
|
return self.getPopupPanelDimensionsAux(panelWidth, contentHeight)
|
|
}
|
|
|
|
func (self *ConfirmationHelper) getPopupPanelDimensionsAux(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 *ConfirmationHelper) getPopupPanelWidth() int {
|
|
width, _ := self.c.GocuiGui().Size()
|
|
// we want a minimum width up to a point, then we do it based on ratio.
|
|
panelWidth := 4 * width / 7
|
|
minWidth := 80
|
|
if panelWidth < minWidth {
|
|
if width-2 < minWidth {
|
|
panelWidth = width - 2
|
|
} else {
|
|
panelWidth = minWidth
|
|
}
|
|
}
|
|
|
|
return panelWidth
|
|
}
|
|
|
|
func (self *ConfirmationHelper) prepareConfirmationPanel(
|
|
opts types.ConfirmOpts,
|
|
) error {
|
|
self.c.Views().Confirmation.Title = opts.Title
|
|
// for now we do not support wrapping in our editor
|
|
self.c.Views().Confirmation.Wrap = !opts.Editable
|
|
self.c.Views().Confirmation.FgColor = theme.GocuiDefaultTextColor
|
|
self.c.Views().Confirmation.Mask = runeForMask(opts.Mask)
|
|
_ = self.c.Views().Confirmation.SetOrigin(0, 0)
|
|
|
|
suggestionsContext := self.c.Contexts().Suggestions
|
|
suggestionsContext.State.FindSuggestions = opts.FindSuggestionsFunc
|
|
if opts.FindSuggestionsFunc != nil {
|
|
suggestionsView := self.c.Views().Suggestions
|
|
suggestionsView.Wrap = false
|
|
suggestionsView.FgColor = theme.GocuiDefaultTextColor
|
|
suggestionsContext.SetSuggestions(opts.FindSuggestionsFunc(""))
|
|
suggestionsView.Visible = true
|
|
suggestionsView.Title = fmt.Sprintf(self.c.Tr.SuggestionsTitle, self.c.UserConfig.Keybinding.Universal.TogglePanel)
|
|
suggestionsView.Subtitle = ""
|
|
}
|
|
|
|
self.ResizeConfirmationPanel()
|
|
return nil
|
|
}
|
|
|
|
func runeForMask(mask bool) rune {
|
|
if mask {
|
|
return '*'
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (self *ConfirmationHelper) 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
|
|
}
|
|
|
|
// remove any previous keybindings
|
|
self.clearConfirmationViewKeyBindings()
|
|
|
|
err := self.prepareConfirmationPanel(
|
|
types.ConfirmOpts{
|
|
Title: opts.Title,
|
|
Prompt: opts.Prompt,
|
|
FindSuggestionsFunc: opts.FindSuggestionsFunc,
|
|
Editable: opts.Editable,
|
|
Mask: opts.Mask,
|
|
})
|
|
if err != nil {
|
|
cancel()
|
|
return err
|
|
}
|
|
confirmationView := self.c.Views().Confirmation
|
|
confirmationView.Editable = opts.Editable
|
|
|
|
if opts.Editable {
|
|
textArea := confirmationView.TextArea
|
|
textArea.Clear()
|
|
textArea.TypeString(opts.Prompt)
|
|
self.ResizeConfirmationPanel()
|
|
confirmationView.RenderTextArea()
|
|
} else {
|
|
self.c.ResetViewOrigin(confirmationView)
|
|
self.c.SetViewContent(confirmationView, style.AttrBold.Sprint(underlineLinks(opts.Prompt)))
|
|
}
|
|
|
|
if err := self.setKeyBindings(cancel, opts); err != nil {
|
|
cancel()
|
|
return err
|
|
}
|
|
|
|
self.c.Contexts().Suggestions.State.AllowEditSuggestion = opts.AllowEditSuggestion
|
|
|
|
self.c.State().GetRepoState().SetCurrentPopupOpts(&opts)
|
|
|
|
return self.c.PushContext(self.c.Contexts().Confirmation)
|
|
}
|
|
|
|
func underlineLinks(text string) string {
|
|
result := ""
|
|
remaining := text
|
|
for {
|
|
linkStart := strings.Index(remaining, "https://")
|
|
if linkStart == -1 {
|
|
break
|
|
}
|
|
|
|
linkEnd := strings.IndexAny(remaining[linkStart:], " \n>")
|
|
if linkEnd == -1 {
|
|
linkEnd = len(remaining)
|
|
} else {
|
|
linkEnd += linkStart
|
|
}
|
|
underlinedLink := style.AttrUnderline.Sprint(remaining[linkStart:linkEnd])
|
|
if strings.HasSuffix(underlinedLink, "\x1b[0m") {
|
|
// Replace the "all styles off" code with "underline off" code
|
|
underlinedLink = underlinedLink[:len(underlinedLink)-2] + "24m"
|
|
}
|
|
result += remaining[:linkStart] + underlinedLink
|
|
remaining = remaining[linkEnd:]
|
|
}
|
|
return result + remaining
|
|
}
|
|
|
|
func (self *ConfirmationHelper) setKeyBindings(cancel goContext.CancelFunc, opts types.CreatePopupPanelOpts) error {
|
|
var onConfirm func() error
|
|
if opts.HandleConfirmPrompt != nil {
|
|
onConfirm = self.wrappedPromptConfirmationFunction(cancel, opts.HandleConfirmPrompt, func() string { return self.c.Views().Confirmation.TextArea.GetContent() })
|
|
} else {
|
|
onConfirm = self.wrappedConfirmationFunction(cancel, opts.HandleConfirm)
|
|
}
|
|
|
|
onSuggestionConfirm := self.wrappedPromptConfirmationFunction(
|
|
cancel,
|
|
opts.HandleConfirmPrompt,
|
|
self.getSelectedSuggestionValue,
|
|
)
|
|
|
|
onClose := self.wrappedConfirmationFunction(cancel, opts.HandleClose)
|
|
|
|
onDeleteSuggestion := func() error {
|
|
if opts.HandleDeleteSuggestion == nil {
|
|
return nil
|
|
}
|
|
|
|
idx := self.c.Contexts().Suggestions.GetSelectedLineIdx()
|
|
return opts.HandleDeleteSuggestion(idx)
|
|
}
|
|
|
|
self.c.Contexts().Confirmation.State.OnConfirm = onConfirm
|
|
self.c.Contexts().Confirmation.State.OnClose = onClose
|
|
self.c.Contexts().Suggestions.State.OnConfirm = onSuggestionConfirm
|
|
self.c.Contexts().Suggestions.State.OnClose = onClose
|
|
self.c.Contexts().Suggestions.State.OnDeleteSuggestion = onDeleteSuggestion
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *ConfirmationHelper) clearConfirmationViewKeyBindings() {
|
|
noop := func() error { return nil }
|
|
self.c.Contexts().Confirmation.State.OnConfirm = noop
|
|
self.c.Contexts().Confirmation.State.OnClose = noop
|
|
self.c.Contexts().Suggestions.State.OnConfirm = noop
|
|
self.c.Contexts().Suggestions.State.OnClose = noop
|
|
self.c.Contexts().Suggestions.State.OnDeleteSuggestion = noop
|
|
}
|
|
|
|
func (self *ConfirmationHelper) getSelectedSuggestionValue() string {
|
|
selectedSuggestion := self.c.Contexts().Suggestions.GetSelected()
|
|
|
|
if selectedSuggestion != nil {
|
|
return selectedSuggestion.Value
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (self *ConfirmationHelper) ResizeConfirmationPanel() {
|
|
suggestionsViewHeight := 0
|
|
if self.c.Views().Suggestions.Visible {
|
|
suggestionsViewHeight = 11
|
|
}
|
|
panelWidth := self.getPopupPanelWidth()
|
|
prompt := self.c.Views().Confirmation.Buffer()
|
|
wrap := true
|
|
if self.c.Views().Confirmation.Editable {
|
|
prompt = self.c.Views().Confirmation.TextArea.GetContent()
|
|
wrap = false
|
|
}
|
|
panelHeight := getMessageHeight(wrap, prompt, panelWidth) + suggestionsViewHeight
|
|
x0, y0, x1, y1 := self.getPopupPanelDimensionsAux(panelWidth, panelHeight)
|
|
confirmationViewBottom := y1 - suggestionsViewHeight
|
|
_, _ = self.c.GocuiGui().SetView(self.c.Views().Confirmation.Name(), x0, y0, x1, confirmationViewBottom, 0)
|
|
|
|
suggestionsViewTop := confirmationViewBottom + 1
|
|
_, _ = self.c.GocuiGui().SetView(self.c.Views().Suggestions.Name(), x0, suggestionsViewTop, x1, suggestionsViewTop+suggestionsViewHeight, 0)
|
|
}
|
|
|
|
func (self *ConfirmationHelper) ResizeCurrentPopupPanel() error {
|
|
c := self.c.CurrentContext()
|
|
|
|
switch c {
|
|
case self.c.Contexts().Menu:
|
|
self.resizeMenu()
|
|
case self.c.Contexts().Confirmation, self.c.Contexts().Suggestions:
|
|
self.resizeConfirmationPanel()
|
|
case self.c.Contexts().CommitMessage, self.c.Contexts().CommitDescription:
|
|
self.ResizeCommitMessagePanels()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *ConfirmationHelper) ResizePopupPanel(v *gocui.View, content string) error {
|
|
x0, y0, x1, y1 := self.getPopupPanelDimensions(v.Wrap, content)
|
|
_, err := self.c.GocuiGui().SetView(v.Name(), x0, y0, x1, y1, 0)
|
|
return err
|
|
}
|
|
|
|
func (self *ConfirmationHelper) resizeMenu() {
|
|
// we want the unfiltered length here so that if we're filtering we don't
|
|
// resize the window
|
|
itemCount := self.c.Contexts().Menu.UnfilteredLen()
|
|
offset := 3
|
|
panelWidth := self.getPopupPanelWidth()
|
|
contentWidth := panelWidth - 2 // minus 2 for the frame
|
|
promptLinesCount := self.layoutMenuPrompt(contentWidth)
|
|
x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight(panelWidth, itemCount+offset+promptLinesCount)
|
|
menuBottom := y1 - offset
|
|
_, _ = self.c.GocuiGui().SetView(self.c.Views().Menu.Name(), x0, y0, x1, menuBottom, 0)
|
|
|
|
tooltipTop := menuBottom + 1
|
|
tooltip := ""
|
|
selectedItem := self.c.Contexts().Menu.GetSelected()
|
|
if selectedItem != nil {
|
|
tooltip = self.TooltipForMenuItem(selectedItem)
|
|
}
|
|
tooltipHeight := getMessageHeight(true, tooltip, contentWidth) + 2 // plus 2 for the frame
|
|
_, _ = self.c.GocuiGui().SetView(self.c.Views().Tooltip.Name(), x0, tooltipTop, x1, tooltipTop+tooltipHeight-1, 0)
|
|
}
|
|
|
|
// Wraps the lines of the menu prompt to the available width and rerenders the
|
|
// menu if neeeded. Returns the number of lines the prompt takes up.
|
|
func (self *ConfirmationHelper) layoutMenuPrompt(contentWidth int) int {
|
|
oldPromptLines := self.c.Contexts().Menu.GetPromptLines()
|
|
var promptLines []string
|
|
prompt := self.c.Contexts().Menu.GetPrompt()
|
|
if len(prompt) > 0 {
|
|
promptLines = wrapMessageToWidth(true, prompt, contentWidth)
|
|
promptLines = append(promptLines, "")
|
|
}
|
|
self.c.Contexts().Menu.SetPromptLines(promptLines)
|
|
if len(oldPromptLines) != len(promptLines) {
|
|
// The number of lines in the prompt has changed; this happens either
|
|
// because we're now showing a menu that has a prompt, and the previous
|
|
// menu didn't (or vice versa), or because the user is resizing the
|
|
// terminal window while a menu with a prompt is open.
|
|
|
|
// We need to rerender to give the menu context a chance to update its
|
|
// non-model items, and reinitialize the data it uses for converting
|
|
// between view index and model index.
|
|
_ = self.c.Contexts().Menu.HandleRender()
|
|
|
|
// Then we need to refocus to ensure the cursor is in the right place in
|
|
// the view.
|
|
_ = self.c.Contexts().Menu.HandleFocus(types.OnFocusOpts{})
|
|
}
|
|
return len(promptLines)
|
|
}
|
|
|
|
func (self *ConfirmationHelper) resizeConfirmationPanel() {
|
|
suggestionsViewHeight := 0
|
|
if self.c.Views().Suggestions.Visible {
|
|
suggestionsViewHeight = 11
|
|
}
|
|
panelWidth := self.getPopupPanelWidth()
|
|
prompt := self.c.Views().Confirmation.Buffer()
|
|
wrap := true
|
|
if self.c.Views().Confirmation.Editable {
|
|
prompt = self.c.Views().Confirmation.TextArea.GetContent()
|
|
wrap = false
|
|
}
|
|
panelHeight := getMessageHeight(wrap, prompt, panelWidth) + suggestionsViewHeight
|
|
x0, y0, x1, y1 := self.getPopupPanelDimensionsAux(panelWidth, panelHeight)
|
|
confirmationViewBottom := y1 - suggestionsViewHeight
|
|
_, _ = self.c.GocuiGui().SetView(self.c.Views().Confirmation.Name(), x0, y0, x1, confirmationViewBottom, 0)
|
|
|
|
suggestionsViewTop := confirmationViewBottom + 1
|
|
_, _ = self.c.GocuiGui().SetView(self.c.Views().Suggestions.Name(), x0, suggestionsViewTop, x1, suggestionsViewTop+suggestionsViewHeight, 0)
|
|
}
|
|
|
|
func (self *ConfirmationHelper) ResizeCommitMessagePanels() {
|
|
panelWidth := self.getPopupPanelWidth()
|
|
content := self.c.Views().CommitDescription.TextArea.GetContent()
|
|
summaryViewHeight := 3
|
|
panelHeight := getMessageHeight(false, content, panelWidth)
|
|
minHeight := 7
|
|
if panelHeight < minHeight {
|
|
panelHeight = minHeight
|
|
}
|
|
x0, y0, x1, y1 := self.getPopupPanelDimensionsAux(panelWidth, panelHeight)
|
|
|
|
_, _ = self.c.GocuiGui().SetView(self.c.Views().CommitMessage.Name(), x0, y0, x1, y0+summaryViewHeight-1, 0)
|
|
_, _ = self.c.GocuiGui().SetView(self.c.Views().CommitDescription.Name(), x0, y0+summaryViewHeight, x1, y1+summaryViewHeight, 0)
|
|
}
|
|
|
|
func (self *ConfirmationHelper) IsPopupPanel(context types.Context) bool {
|
|
return context.GetKind() == types.PERSISTENT_POPUP || context.GetKind() == types.TEMPORARY_POPUP
|
|
}
|
|
|
|
func (self *ConfirmationHelper) IsPopupPanelFocused() bool {
|
|
return self.IsPopupPanel(self.c.CurrentContext())
|
|
}
|
|
|
|
func (self *ConfirmationHelper) TooltipForMenuItem(menuItem *types.MenuItem) string {
|
|
tooltip := menuItem.Tooltip
|
|
if menuItem.DisabledReason != nil {
|
|
if tooltip != "" {
|
|
tooltip += "\n\n"
|
|
}
|
|
tooltip += style.FgRed.Sprintf(self.c.Tr.DisabledMenuItemPrefix) + menuItem.DisabledReason.Text
|
|
}
|
|
return tooltip
|
|
}
|