1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-05-27 23:08:02 +02:00

Show mode-specific keybinding suggestions

As part of making lazygit more discoverable, there are certain keys which you almost certainly
need to press when you're in a given mode e.g. 'v' to paste commits when cherry-picking. This
commit prominently shows these keybinding suggestions alongside the others in the option view.

I'm using the same colours for these keybindings as is associated with the mode elsewhere e.g.
yellow for rebasing and cyan for cherry-picking. The cherry-picking one is a bit weird because
we also use cyan text to show loaders and app status at the bottom left so it may be confusing,
but I haven't personally found it awkward from having tested it out myself.

Previously we would render these options whenever a new context was activated, but now that we
need to re-render options whenever a mode changes, I'm instead rendering them on each screen
re-render (i.e. in the layout function). Given how cheap it is to render this text, I think
it's fine performance-wise.
This commit is contained in:
Jesse Duffield 2024-01-02 12:19:31 +11:00
parent c07b3fad64
commit 0f9d9e13d1
14 changed files with 325 additions and 104 deletions

View File

@ -12,3 +12,11 @@ const (
REBASE_MODE_REBASING
REBASE_MODE_MERGING
)
func (self RebaseMode) IsMerging() bool {
return self == REBASE_MODE_MERGING
}
func (self RebaseMode) IsRebasing() bool {
return self == REBASE_MODE_INTERACTIVE || self == REBASE_MODE_NORMAL || self == REBASE_MODE_REBASING
}

View File

@ -245,8 +245,6 @@ func (self *ContextMgr) ActivateContext(c types.Context, opts types.OnFocusOpts)
self.gui.c.GocuiGui().Cursor = v.Editable
self.gui.renderContextOptionsMap(c)
if err := c.HandleFocus(opts); err != nil {
return err
}

View File

@ -27,13 +27,13 @@ func (self *ConfirmationController) GetKeybindings(opts types.KeybindingsOpts) [
Key: opts.GetKey(opts.Config.Universal.Confirm),
Handler: func() error { return self.context().State.OnConfirm() },
Description: self.c.Tr.Confirm,
Display: true,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.Return),
Handler: func() error { return self.context().State.OnClose() },
Description: self.c.Tr.CloseCancel,
Display: true,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.TogglePanel),

View File

@ -72,6 +72,7 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type
Description: self.c.Tr.OpenKeybindingsMenu,
Handler: self.createOptionsMenu,
ShortDescription: self.c.Tr.Keybindings,
DisplayOnScreen: true,
},
{
ViewName: "",
@ -116,6 +117,7 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type
Modifier: gocui.ModNone,
Handler: self.escape,
Description: self.c.Tr.Cancel,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.ToggleWhitespaceInDiffView),

View File

@ -42,13 +42,13 @@ func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.
Handler: self.withItem(self.press),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.Execute,
Display: true,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.Return),
Handler: self.close,
Description: self.c.Tr.Close,
Display: true,
DisplayOnScreen: true,
},
}

View File

@ -51,27 +51,27 @@ func (self *MergeConflictsController) GetKeybindings(opts types.KeybindingsOpts)
Key: opts.GetKey(opts.Config.Universal.PrevBlock),
Handler: self.withRenderAndFocus(self.PrevConflict),
Description: self.c.Tr.PrevConflict,
Display: true,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.NextBlock),
Handler: self.withRenderAndFocus(self.NextConflict),
Description: self.c.Tr.NextConflict,
Display: true,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.Undo),
Handler: self.withRenderAndFocus(self.HandleUndo),
Description: self.c.Tr.Undo,
Tooltip: self.c.Tr.UndoMergeResolveTooltip,
Display: true,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.Edit),
Handler: self.HandleEditFile,
Description: self.c.Tr.EditFile,
Tooltip: self.c.Tr.EditFileTooltip,
Display: true,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.OpenFile),
@ -112,7 +112,7 @@ func (self *MergeConflictsController) GetKeybindings(opts types.KeybindingsOpts)
Handler: self.c.Helpers().WorkingTree.OpenMergeTool,
Description: self.c.Tr.OpenMergeTool,
Tooltip: self.c.Tr.OpenMergeToolTooltip,
Display: true,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.Return),

View File

@ -165,6 +165,8 @@ func (gui *Gui) layout(g *gocui.Gui) error {
return err
}
gui.renderContextOptionsMap()
outer:
for {
select {

View File

@ -4,9 +4,13 @@ import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"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"
)
@ -14,80 +18,131 @@ type OptionsMapMgr struct {
c *helpers.HelperCommon
}
func (gui *Gui) renderContextOptionsMap(c types.Context) {
func (gui *Gui) renderContextOptionsMap() {
// In demos, we render our own content to this view
if gui.integrationTest != nil && gui.integrationTest.IsDemo() {
return
}
mgr := OptionsMapMgr{c: gui.c}
mgr.renderContextOptionsMap(c)
mgr.renderContextOptionsMap()
}
// render the options available for the current context at the bottom of the screen
func (self *OptionsMapMgr) renderContextOptionsMap(c types.Context) {
bindingsToDisplay := lo.Filter(c.GetKeybindings(self.c.KeybindingsOpts()), func(binding *types.Binding, _ int) bool {
return binding.Display
// Render the options available for the current context at the bottom of the screen
// STYLE GUIDE: we use the default options fg color for most keybindings. We can
// only use a different color if we're in a specific mode where the user is likely
// to want to press that key. For example, when in cherry-picking mode, we
// want to prominently show the keybinding for pasting commits.
func (self *OptionsMapMgr) renderContextOptionsMap() {
currentContext := self.c.CurrentContext()
currentContextBindings := currentContext.GetKeybindings(self.c.KeybindingsOpts())
globalBindings := self.c.Contexts().Global.GetKeybindings(self.c.KeybindingsOpts())
allBindings := append(currentContextBindings, globalBindings...)
bindingsToDisplay := lo.Filter(allBindings, func(binding *types.Binding, _ int) bool {
return binding.DisplayOnScreen && !binding.IsDisabled()
})
var optionsMap []bindingInfo
if len(bindingsToDisplay) == 0 {
optionsMap = self.globalOptions()
} else {
optionsMap = lo.Map(bindingsToDisplay, func(binding *types.Binding, _ int) bindingInfo {
optionsMap := lo.Map(bindingsToDisplay, func(binding *types.Binding, _ int) bindingInfo {
displayStyle := theme.OptionsFgColor
if binding.DisplayStyle != nil {
displayStyle = *binding.DisplayStyle
}
description := binding.Description
if binding.ShortDescription != "" {
description = binding.ShortDescription
}
return bindingInfo{
key: keybindings.LabelFromKey(binding.Key),
description: binding.Description,
description: description,
style: displayStyle,
}
})
// Mode-specific local keybindings
if currentContext.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY {
if self.c.Modes().CherryPicking.Active() {
optionsMap = utils.Prepend(optionsMap, bindingInfo{
key: keybindings.Label(self.c.KeybindingsOpts().Config.Commits.PasteCommits),
description: self.c.Tr.PasteCommits,
style: style.FgCyan,
})
}
if self.c.Model().BisectInfo.Started() {
optionsMap = utils.Prepend(optionsMap, bindingInfo{
key: keybindings.Label(self.c.KeybindingsOpts().Config.Commits.ViewBisectOptions),
description: self.c.Tr.ViewBisectOptions,
style: style.FgGreen,
})
}
}
// Mode-specific global keybindings
if self.c.Model().WorkingTreeStateAtLastCommitRefresh.IsRebasing() {
optionsMap = utils.Prepend(optionsMap, bindingInfo{
key: keybindings.Label(self.c.KeybindingsOpts().Config.Universal.CreateRebaseOptionsMenu),
description: self.c.Tr.ViewRebaseOptions,
style: style.FgYellow,
})
} else if self.c.Model().WorkingTreeStateAtLastCommitRefresh.IsMerging() {
optionsMap = utils.Prepend(optionsMap, bindingInfo{
key: keybindings.Label(self.c.KeybindingsOpts().Config.Universal.CreateRebaseOptionsMenu),
description: self.c.Tr.ViewMergeOptions,
style: style.FgYellow,
})
}
if self.c.Git().Patch.PatchBuilder.Active() {
optionsMap = utils.Prepend(optionsMap, bindingInfo{
key: keybindings.Label(self.c.KeybindingsOpts().Config.Universal.CreatePatchOptionsMenu),
description: self.c.Tr.ViewPatchOptions,
style: style.FgYellow,
})
}
self.renderOptions(self.formatBindingInfos(optionsMap))
}
func (self *OptionsMapMgr) formatBindingInfos(bindingInfos []bindingInfo) string {
return strings.Join(
lo.Map(bindingInfos, func(bindingInfo bindingInfo, _ int) string {
return fmt.Sprintf("%s: %s", bindingInfo.key, bindingInfo.description)
}),
", ")
width := self.c.Views().Options.Width() - 4 // -4 for the padding
var builder strings.Builder
ellipsis := "…"
separator := " | "
length := 0
for i, info := range bindingInfos {
plainText := fmt.Sprintf("%s: %s", info.description, info.key)
// Check if adding the next formatted string exceeds the available width
if i > 0 && length+len(separator)+len(plainText) > width {
builder.WriteString(theme.OptionsFgColor.Sprint(separator + ellipsis))
break
}
formatted := info.style.Sprintf(plainText)
if i > 0 {
builder.WriteString(theme.OptionsFgColor.Sprint(separator))
length += len(separator)
}
builder.WriteString(formatted)
length += len(plainText)
}
return builder.String()
}
func (self *OptionsMapMgr) renderOptions(options string) {
self.c.SetViewContent(self.c.Views().Options, options)
}
func (self *OptionsMapMgr) globalOptions() []bindingInfo {
keybindingConfig := self.c.UserConfig.Keybinding
return []bindingInfo{
{
key: fmt.Sprintf("%s/%s", keybindings.Label(keybindingConfig.Universal.ScrollUpMain), keybindings.Label(keybindingConfig.Universal.ScrollDownMain)),
description: self.c.Tr.Scroll,
},
{
key: keybindings.Label(keybindingConfig.Universal.Return),
description: self.c.Tr.Cancel,
},
{
key: keybindings.Label(keybindingConfig.Universal.Quit),
description: self.c.Tr.Quit,
},
{
key: keybindings.Label(keybindingConfig.Universal.OptionMenuAlt1),
description: self.c.Tr.Keybindings,
},
{
key: fmt.Sprintf("%s-%s", keybindings.Label(keybindingConfig.Universal.JumpToBlock[0]), keybindings.Label(keybindingConfig.Universal.JumpToBlock[len(keybindingConfig.Universal.JumpToBlock)-1])),
description: self.c.Tr.Jump,
},
{
key: fmt.Sprintf("%s/%s", keybindings.Label(keybindingConfig.Universal.ScrollLeft), keybindings.Label(keybindingConfig.Universal.ScrollRight)),
description: self.c.Tr.ScrollLeftRight,
},
}
}
type bindingInfo struct {
key string
description string
style style.TextStyle
}

View File

@ -16,16 +16,20 @@ type Binding struct {
Key Key
Modifier gocui.Modifier
Description string
// If defined, this is used in place of Description when showing the keybinding
// in the options view at the bottom left of the screen.
ShortDescription string
Alternative string
Tag string // e.g. 'navigation'. Used for grouping things in the cheatsheet
OpensMenu bool
// If true, the keybinding will appear at the bottom of the screen. If
// the given view has no bindings with Display: true, the default keybindings
// will be displayed instead.
// TODO: implement this
Display bool
// If true, the keybinding will appear at the bottom of the screen.
// Even if set to true, the keybinding will not be displayed if it is currently
// disabled. We could instead display it with a strikethrough, but there's
// limited realestate to show all the keybindings we want, so we're hiding it instead.
DisplayOnScreen bool
// if unset, the binding will be displayed in the default color. Only applies to the keybinding
// on-screen, not in the keybindings menu.
DisplayStyle *style.TextStyle
// to be displayed if the keybinding is highlighted from within a menu
@ -39,6 +43,10 @@ type Binding struct {
GetDisabledReason func() *DisabledReason
}
func (Binding *Binding) IsDisabled() bool {
return Binding.GetDisabledReason != nil && Binding.GetDisabledReason() != nil
}
// A guard is a decorator which checks something before executing a handler
// and potentially early-exits if some precondition hasn't been met.
type Guard func(func() error) func() error

View File

@ -94,7 +94,6 @@ func (gui *Gui) createAllViews() error {
(*mapping.viewPtr).SelBgColor = theme.GocuiSelectedLineBgColor
}
gui.Views.Options.FgColor = theme.OptionsColor
gui.Views.Options.Frame = false
gui.Views.SearchPrefix.BgColor = gocui.ColorDefault

View File

@ -62,3 +62,29 @@ func (self *Common) SelectPatchOption(matcher *TextMatcher) {
self.t.ExpectPopup().Menu().Title(Equals("Patch options")).Select(matcher).Confirm()
}
func (self *Common) ResetBisect() {
self.t.Views().Commits().
Focus().
Press(self.t.keys.Commits.ViewBisectOptions).
Tap(func() {
self.t.ExpectPopup().Menu().
Title(Equals("Bisect")).
Select(Contains("Reset bisect")).
Confirm()
self.t.ExpectPopup().Confirmation().
Title(Equals("Reset 'git bisect'")).
Content(Contains("Are you sure you want to reset 'git bisect'?")).
Confirm()
})
}
func (self *Common) ResetCustomPatch() {
self.t.GlobalPress(self.t.keys.Universal.CreatePatchOptionsMenu)
self.t.ExpectPopup().Menu().
Title(Equals("Patch options")).
Select(Contains("Reset patch")).
Confirm()
}

View File

@ -147,3 +147,7 @@ func (self *Views) Search() *ViewDriver {
func (self *Views) Tooltip() *ViewDriver {
return self.regularView("tooltip")
}
func (self *Views) Options() *ViewDriver {
return self.regularView("options")
}

View File

@ -269,6 +269,7 @@ var tests = []*components.IntegrationTest{
ui.Accordion,
ui.DoublePopup,
ui.EmptyMenu,
ui.ModeSpecificKeybindingSuggestions,
ui.OpenLinkFailure,
ui.RangeSelect,
ui.SwitchTabFromMenu,

View File

@ -0,0 +1,118 @@
package ui
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
)
var ModeSpecificKeybindingSuggestions = NewIntegrationTest(NewIntegrationTestArgs{
Description: "When in various modes, we should corresponding keybinding suggestions onscreen",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(2)
shell.NewBranch("base-branch")
shared.MergeConflictsSetup(shell)
shell.Checkout("base-branch")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
rebaseSuggestion := "View rebase options: m"
cherryPickSuggestion := "Paste (cherry-pick): V"
bisectSuggestion := "View bisect options: b"
customPatchSuggestion := "View custom patch options: <c-p>"
mergeSuggestion := "View merge options: m"
t.Views().Commits().
Focus().
Lines(
Contains("commit 02").IsSelected(),
Contains("commit 01"),
).
Tap(func() {
// These suggestions are mode-specific so are not shown by default
t.Views().Options().Content(
DoesNotContain(rebaseSuggestion).
DoesNotContain(mergeSuggestion).
DoesNotContain(cherryPickSuggestion).
DoesNotContain(bisectSuggestion).
DoesNotContain(customPatchSuggestion),
)
}).
// Start an interactive rebase
Press(keys.Universal.Edit).
Tap(func() {
// Confirm the rebase suggestion now appears
t.Views().Options().Content(Contains(rebaseSuggestion))
}).
Press(keys.Commits.CherryPickCopy).
Tap(func() {
// Confirm the cherry pick suggestion now appears
t.Views().Options().Content(Contains(cherryPickSuggestion))
// Importantly, we show multiple of these suggestions at once
t.Views().Options().Content(Contains(rebaseSuggestion))
}).
// Cancel the cherry pick
PressEscape().
Tap(func() {
t.Views().Options().Content(DoesNotContain(cherryPickSuggestion))
}).
// Cancel the rebase
Tap(func() {
t.Common().AbortRebase()
t.Views().Options().Content(DoesNotContain(rebaseSuggestion))
}).
Press(keys.Commits.ViewBisectOptions).
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("Bisect")).
Select(MatchesRegexp("Mark.* as bad")).
Confirm()
t.Views().Options().Content(Contains(bisectSuggestion))
// Cancel bisect
t.Common().ResetBisect()
t.Views().Options().Content(DoesNotContain(bisectSuggestion))
}).
// Enter commit files view
PressEnter()
t.Views().CommitFiles().
IsFocused().
// Add a commit file to the patch
Press(keys.Universal.Select).
Tap(func() {
t.Views().Options().Content(Contains(customPatchSuggestion))
t.Common().ResetCustomPatch()
t.Views().Options().Content(DoesNotContain(customPatchSuggestion))
})
// Test merge options suggestion
t.Views().Branches().
Focus().
NavigateToLine(Contains("first-change-branch")).
Press(keys.Universal.Select).
NavigateToLine(Contains("second-change-branch")).
Press(keys.Branches.MergeIntoCurrentBranch).
Tap(func() {
t.ExpectPopup().Confirmation().
Title(Equals("Merge")).
Content(Contains("Are you sure you want to merge")).
Confirm()
t.Common().AcknowledgeConflicts()
t.Views().Options().Content(Contains(mergeSuggestion))
t.Common().AbortMerge()
t.Views().Options().Content(DoesNotContain(mergeSuggestion))
})
},
})