package helpers import ( goContext "context" "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) 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) closeAndCallConfirmationFunction(cancel goContext.CancelFunc, function func() error) error { cancel() self.c.Context().Pop() if function != nil { if err := function(); err != nil { return err } } return nil } func (self *ConfirmationHelper) wrappedConfirmationFunction(cancel goContext.CancelFunc, function func() error) func() error { return func() error { return self.closeAndCallConfirmationFunction(cancel, function) } } func (self *ConfirmationHelper) wrappedPromptConfirmationFunction( cancel goContext.CancelFunc, function func(string) error, getResponse func() string, allowEmptyInput bool, preserveWhitespace bool, ) func() error { return func() error { if self.c.GocuiGui().IsPasting { // The user is pasting multi-line text into a prompt; we don't want to handle the // line feeds as "confirm" keybindings. Simply ignoring them is the best we can do; this // will cause the entire pasted text to appear as a single line in the prompt. Hopefully // the user knows that ctrl-u allows them to delete it again... return nil } response := getResponse() if !preserveWhitespace { response = strings.TrimSpace(response) } if response == "" && !allowEmptyInput { self.c.ErrorToast(self.c.Tr.PromptInputCannotBeEmptyToast) return nil } return self.closeAndCallConfirmationFunction(cancel, func() error { return function(response) }) } } func (self *ConfirmationHelper) DeactivateConfirmation() { self.c.State().GetRepoState().SetCurrentPopupOpts(nil) self.c.Views().Confirmation.Visible = false self.clearConfirmationViewKeyBindings() } func (self *ConfirmationHelper) DeactivatePrompt() { self.c.State().GetRepoState().SetCurrentPopupOpts(nil) self.c.Views().Prompt.Visible = false self.c.Views().Suggestions.Visible = false self.clearPromptViewKeyBindings() } func getMessageHeight(wrap bool, editable bool, message string, width int, tabWidth int) int { wrappedLines, _, _ := utils.WrapViewLinesToWidth(wrap, editable, message, width, tabWidth) return len(wrappedLines) } func (self *ConfirmationHelper) getPopupPanelDimensionsForContentHeight(contentWidth, contentHeight int, parentPopupContext types.Context) (int, int, int, int) { return self.getPopupPanelDimensionsAux(contentWidth, contentHeight, parentPopupContext) } func (self *ConfirmationHelper) getPopupPanelDimensionsAux(contentWidth int, contentHeight int, parentPopupContext types.Context) (int, int, int, int) { panelWidth := contentWidth + 2 // plus 2 for the frame panelHeight := contentHeight + 2 width, height := self.c.GocuiGui().Size() if panelHeight > height*3/4 { panelHeight = height * 3 / 4 } if parentPopupContext != nil { // If there's already a popup on the screen, offset the new one from its // parent so that it's clearly distinguished from the parent x0, y0, _, _ := parentPopupContext.GetView().Dimensions() x0 += 2 y0 += 1 return x0, y0, x0 + panelWidth - 1, y0 + panelHeight - 1 } return width/2 - panelWidth/2, height/2 - panelHeight/2 - panelHeight%2, // Currently, X1/Y1 of a gocui view is one less than you would expect based on its // width/height, so we need to subtract 1 here. See // https://github.com/jesseduffield/lazygit/commit/f6f2a52dee8bba3ebd7e3b34b4b7c7d3e3795f3e width/2 + panelWidth/2 - 1, height/2 + panelHeight/2 - 1 } // Returns the outer width of the view, including its frame. To decide how to wrap text, subtract 2. // Also, note that X1-X0 of the view is one less than this. func (self *ConfirmationHelper) getPopupPanelWidth(maxWidth int) int { width, _ := self.c.GocuiGui().Size() // we want a minimum width up to a point, then we do it based on ratio, but only up to the given max width panelWidth := min(4*width/7, maxWidth) minWidth := 80 if panelWidth < minWidth { panelWidth = min(width-2, minWidth) } return panelWidth } func (self *ConfirmationHelper) prepareConfirmationPanel( opts types.ConfirmOpts, ) { self.c.Views().Confirmation.Title = opts.Title self.c.Views().Confirmation.FgColor = theme.GocuiDefaultTextColor self.c.ResetViewOrigin(self.c.Views().Confirmation) self.c.SetViewContent(self.c.Views().Confirmation, style.AttrBold.Sprint(strings.TrimSpace(opts.Prompt))) } func (self *ConfirmationHelper) preparePromptPanel( opts types.ConfirmOpts, ) { self.c.Views().Prompt.Title = opts.Title self.c.Views().Prompt.FgColor = theme.GocuiDefaultTextColor self.c.Views().Prompt.Mask = characterForMask(opts.Mask) self.c.Views().Prompt.SetOrigin(0, 0) textArea := self.c.Views().Prompt.TextArea textArea.Clear() textArea.TypeString(opts.Prompt) self.c.Views().Prompt.RenderTextArea() if opts.FindSuggestionsFunc != nil { suggestionsContext := self.c.Contexts().Suggestions suggestionsContext.State.FindSuggestions = opts.FindSuggestionsFunc 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 = "" } } func characterForMask(mask bool) string { if mask { return "*" } return "" } func (self *ConfirmationHelper) CreatePopupPanel(ctx goContext.Context, opts types.CreatePopupPanelOpts) { _, 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 } // remove any previous keybindings self.clearConfirmationViewKeyBindings() self.clearPromptViewKeyBindings() var context types.Context if opts.Editable { self.c.Contexts().Suggestions.State.FindSuggestions = opts.FindSuggestionsFunc self.preparePromptPanel( types.ConfirmOpts{ Title: opts.Title, Prompt: opts.Prompt, FindSuggestionsFunc: opts.FindSuggestionsFunc, Mask: opts.Mask, }) context = self.c.Contexts().Prompt self.setPromptKeyBindings(cancel, opts) } else { if opts.FindSuggestionsFunc != nil { panic("non-editable confirmation views do not support suggestions") } self.c.Contexts().Suggestions.State.FindSuggestions = nil self.prepareConfirmationPanel( types.ConfirmOpts{ Title: opts.Title, Prompt: opts.Prompt, }) context = self.c.Contexts().Confirmation self.setConfirmationKeyBindings(cancel, opts) } self.c.Contexts().Suggestions.State.AllowEditSuggestion = opts.AllowEditSuggestion self.c.State().GetRepoState().SetCurrentPopupOpts(&opts) self.c.Context().Push(context, types.OnFocusOpts{}) } func (self *ConfirmationHelper) setConfirmationKeyBindings(cancel goContext.CancelFunc, opts types.CreatePopupPanelOpts) { onConfirm := self.wrappedConfirmationFunction(cancel, opts.HandleConfirm) onClose := self.wrappedConfirmationFunction(cancel, opts.HandleClose) self.c.Contexts().Confirmation.State.OnConfirm = onConfirm self.c.Contexts().Confirmation.State.OnClose = onClose } func (self *ConfirmationHelper) setPromptKeyBindings(cancel goContext.CancelFunc, opts types.CreatePopupPanelOpts) { onConfirm := self.wrappedPromptConfirmationFunction(cancel, opts.HandleConfirmPrompt, func() string { return self.c.Views().Prompt.TextArea.GetContent() }, opts.AllowEmptyInput, opts.PreserveWhitespace) onSuggestionConfirm := self.wrappedPromptConfirmationFunction( cancel, opts.HandleConfirmPrompt, self.getSelectedSuggestionValue, opts.AllowEmptyInput, opts.PreserveWhitespace, ) 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().Prompt.State.OnConfirm = onConfirm self.c.Contexts().Prompt.State.OnClose = onClose self.c.Contexts().Suggestions.State.OnConfirm = onSuggestionConfirm self.c.Contexts().Suggestions.State.OnClose = onClose self.c.Contexts().Suggestions.State.OnDeleteSuggestion = onDeleteSuggestion } func (self *ConfirmationHelper) clearConfirmationViewKeyBindings() { noop := func() error { return nil } self.c.Contexts().Confirmation.State.OnConfirm = noop self.c.Contexts().Confirmation.State.OnClose = noop } func (self *ConfirmationHelper) clearPromptViewKeyBindings() { noop := func() error { return nil } self.c.Contexts().Prompt.State.OnConfirm = noop self.c.Contexts().Prompt.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) ResizeCurrentPopupPanels() { var parentPopupContext types.Context for _, c := range self.c.Context().CurrentPopup() { switch c { case self.c.Contexts().Menu: self.resizeMenu(parentPopupContext) case self.c.Contexts().Confirmation: self.resizeConfirmationPanel(parentPopupContext) case self.c.Contexts().Prompt, self.c.Contexts().Suggestions: self.resizePromptPanel(parentPopupContext) case self.c.Contexts().CommitMessage, self.c.Contexts().CommitDescription: self.ResizeCommitMessagePanels(parentPopupContext) } parentPopupContext = c } } // The rows that a filter row adds to a menu popup: one for the input, and one // for its bottom border. Its top border is the menu's bottom border. const menuFilterRowHeight = 2 // The prompts for the filter row, from the most to the least informative. The // keybindings menu can also filter by keybinding, which is worth spelling out // when there is room for it. func (self *ConfirmationHelper) menuFilterPromptCandidates() []string { if self.c.Contexts().Menu.AllowFilteringKeybindings() { return []string{self.c.Tr.FilterPrefixMenu, self.c.Tr.FilterPrefix} } return []string{self.c.Tr.FilterPrefix} } // Returns the first prompt that still leaves room to type in, or no prompt at // all if the row is too narrow even for the shortest one. func menuFilterPrompt(candidates []string, contentWidth int) string { const minimumInputWidth = 4 for _, candidate := range candidates { if utils.StringWidth(candidate)+minimumInputWidth <= contentWidth { return candidate } } return "" } func (self *ConfirmationHelper) resizeMenu(parentPopupContext types.Context) { menuContext := self.c.Contexts().Menu // we want the unfiltered length here so that if we're filtering we don't // resize the window itemCount := menuContext.UnfilteredLen() offset := 3 panelWidth := self.getPopupPanelWidth(90) contentWidth := panelWidth - 2 // minus 2 for the frame promptLinesCount := self.layoutMenuPrompt(contentWidth) // The row is reserved for the whole time the menu is open, even though it only // becomes visible once the user starts typing, so that revealing it doesn't // move the menu. filterRowHeight := lo.Ternary(menuContext.FilterAsYouType(), menuFilterRowHeight, 0) x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight( contentWidth, itemCount+offset+promptLinesCount+filterRowHeight, parentPopupContext) menuBottom := y1 - offset - filterRowHeight _, _ = self.c.GocuiGui().SetView(self.c.Views().Menu.Name(), x0, y0, x1, menuBottom, 0) tooltipTop := menuBottom + 1 if menuContext.FilterAsYouType() { filterRowBottom := menuBottom + filterRowHeight // The row hangs off the bottom of the menu, sharing its bottom border. _, _ = self.c.GocuiGui().SetView(self.c.Views().MenuFilterFrame.Name(), x0, menuBottom, x1, filterRowBottom, 0) prompt := menuFilterPrompt(self.menuFilterPromptCandidates(), contentWidth) self.c.Views().MenuFilterFrame.SetContent(prompt) // A view's content starts one column inside its bounds, so the input field // starts one column to the left of where its text is to appear. inputLeft := x0 + utils.StringWidth(prompt) _, _ = self.c.GocuiGui().SetView(self.c.Views().MenuFilter.Name(), inputLeft, menuBottom, x1, filterRowBottom, 0) if menuContext.FilterStarted() { tooltipTop = filterRowBottom + 1 } } tooltip := "" selectedItem := self.c.Contexts().Menu.GetSelected() if selectedItem != nil { tooltip = self.TooltipForMenuItem(selectedItem) } tooltipHeight := getMessageHeight(true, false, tooltip, contentWidth, self.c.Views().Menu.TabWidth) + 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 needed. 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, _, _ = utils.WrapViewLinesToWidth(true, false, prompt, contentWidth, self.c.Views().Menu.TabWidth) 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(parentPopupContext types.Context) { panelWidth := self.getPopupPanelWidth(80) contentWidth := panelWidth - 2 // minus 2 for the frame confirmationView := self.c.Views().Confirmation prompt := confirmationView.Buffer() contentHeight := getMessageHeight(true, false, prompt, contentWidth, confirmationView.TabWidth) x0, y0, x1, y1 := self.getPopupPanelDimensionsAux(contentWidth, contentHeight, parentPopupContext) _, _ = self.c.GocuiGui().SetView(confirmationView.Name(), x0, y0, x1, y1, 0) } func (self *ConfirmationHelper) resizePromptPanel(parentPopupContext types.Context) { suggestionsViewHeight := 0 if self.c.Views().Suggestions.Visible { suggestionsViewHeight = 11 } panelWidth := self.getPopupPanelWidth(80) contentWidth := panelWidth - 2 // minus 2 for the frame promptView := self.c.Views().Prompt prompt := promptView.TextArea.GetContent() contentHeight := getMessageHeight(false, true, prompt, contentWidth, promptView.TabWidth) + suggestionsViewHeight x0, y0, x1, y1 := self.getPopupPanelDimensionsAux(contentWidth, contentHeight, parentPopupContext) promptViewBottom := y1 - suggestionsViewHeight _, _ = self.c.GocuiGui().SetView(promptView.Name(), x0, y0, x1, promptViewBottom, 0) suggestionsViewTop := promptViewBottom + 1 _, _ = self.c.GocuiGui().SetView(self.c.Views().Suggestions.Name(), x0, suggestionsViewTop, x1, suggestionsViewTop+suggestionsViewHeight, 0) } func (self *ConfirmationHelper) ResizeCommitMessagePanels(parentPopupContext types.Context) { maxWidth := 100 if self.c.UserConfig().Git.Commit.AutoWrapCommitMessage { // Adding some extra space to make it very obvious that we're wrapping the commit message // for real, not just soft-wrapping for display at the window width. maxWidth = self.c.UserConfig().Git.Commit.AutoWrapWidth + 25 } panelWidth := self.getPopupPanelWidth(maxWidth) contentWidth := panelWidth - 2 // minus 2 for the frame content := self.c.Views().CommitDescription.TextArea.GetContent() summaryViewHeight := 3 // The width we pass to getMessageHeight is irrelevant because we don't wrap the text to the // view width. If we did, we'd have to subtract 2 for the view frame. contentHeight := getMessageHeight(false, true, content, contentWidth, self.c.Views().CommitDescription.TabWidth) minHeight := 7 if contentHeight < minHeight { contentHeight = minHeight } x0, y0, x1, y1 := self.getPopupPanelDimensionsAux(contentWidth, contentHeight, parentPopupContext) _, _ = 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.Context().Current()) } func (self *ConfirmationHelper) TooltipForMenuItem(menuItem *types.MenuItem) string { tooltip := menuItem.Tooltip if menuItem.DisabledReason != nil && menuItem.DisabledReason.Text != "" { if tooltip != "" { tooltip += "\n\n" } tooltip += style.FgRed.Sprintf(self.c.Tr.DisabledMenuItemPrefix) + menuItem.DisabledReason.Text } return tooltip }