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

Show Toast instead of error panel when invoking a disabled command (#3180)

- **PR Description**

Addresses #3116.

I'm not 100% sure I like the behavior, but I put it out there so that
others can test it and form an opinion. It not only affects keybindings,
but also invoking menu items (either with enter or with their key
binding): the menu now stays open in that case, which I think is
actually better.

There's a horrible hack for keeping the integration tests working, I
don't have a good idea how to fix that for real. Suggestions welcome.
This commit is contained in:
Jesse Duffield 2024-01-15 14:45:39 +11:00 committed by GitHub
commit f79305090b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 279 additions and 165 deletions

View File

@ -23,6 +23,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/gui"
"github.com/jesseduffield/lazygit/pkg/i18n"
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
"github.com/jesseduffield/lazygit/pkg/logs"
"github.com/jesseduffield/lazygit/pkg/updates"
)
@ -42,7 +43,7 @@ func Run(
common *common.Common,
startArgs appTypes.StartArgs,
) {
app, err := NewApp(config, common)
app, err := NewApp(config, startArgs.IntegrationTest, common)
if err == nil {
err = app.Run(startArgs)
@ -94,7 +95,7 @@ func newLogger(cfg config.AppConfigurer) *logrus.Entry {
}
// NewApp bootstrap a new application
func NewApp(config config.AppConfigurer, common *common.Common) (*App, error) {
func NewApp(config config.AppConfigurer, test integrationTypes.IntegrationTest, common *common.Common) (*App, error) {
app := &App{
closers: []io.Closer{},
Config: config,
@ -128,7 +129,7 @@ func NewApp(config config.AppConfigurer, common *common.Common) (*App, error) {
showRecentRepos = true
}
app.Gui, err = gui.NewGui(common, config, gitVersion, updater, showRecentRepos, dirName)
app.Gui, err = gui.NewGui(common, config, gitVersion, updater, showRecentRepos, dirName, test)
if err != nil {
return app, err
}

View File

@ -61,7 +61,7 @@ func generateAtDir(cheatsheetDir string) {
if err != nil {
log.Fatal(err)
}
mApp, _ := app.NewApp(mConfig, common)
mApp, _ := app.NewApp(mConfig, nil, common)
path := cheatsheetDir + "/Keybindings_" + lang + ".md"
file, err := os.Create(path)
if err != nil {

View File

@ -90,7 +90,7 @@ func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string {
return lo.Map(menuItems, func(item *types.MenuItem, _ int) []string {
displayStrings := item.LabelColumns
if item.DisabledReason != "" {
if item.DisabledReason != nil {
displayStrings[0] = style.FgDefault.SetStrikethrough().Sprint(displayStrings[0])
}
@ -172,8 +172,13 @@ func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Bin
}
func (self *MenuContext) OnMenuPress(selectedItem *types.MenuItem) error {
if selectedItem != nil && selectedItem.DisabledReason != "" {
return self.c.ErrorMsg(selectedItem.DisabledReason)
if selectedItem != nil && selectedItem.DisabledReason != nil {
if selectedItem.DisabledReason.ShowErrorInPanel {
return self.c.ErrorMsg(selectedItem.DisabledReason.Text)
}
self.c.ErrorToast(self.c.Tr.DisabledMenuItemPrefix + selectedItem.DisabledReason.Text)
return nil
}
if err := self.c.PopContext(); err != nil {

View File

@ -264,13 +264,13 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc
}
if !selectedBranch.IsTrackingRemote() {
unsetUpstreamItem.DisabledReason = self.c.Tr.UpstreamNotSetError
unsetUpstreamItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError}
}
if !selectedBranch.RemoteBranchStoredLocally() {
viewDivergenceItem.DisabledReason = self.c.Tr.UpstreamNotSetError
upstreamResetItem.DisabledReason = self.c.Tr.UpstreamNotSetError
upstreamRebaseItem.DisabledReason = self.c.Tr.UpstreamNotSetError
viewDivergenceItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError}
upstreamResetItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError}
upstreamRebaseItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError}
}
options := []*types.MenuItem{
@ -309,16 +309,16 @@ func (self *BranchesController) press(selectedBranch *models.Branch) error {
return self.c.Helpers().Refs.CheckoutRef(selectedBranch.Name, types.CheckoutRefOptions{})
}
func (self *BranchesController) getDisabledReasonForPress() string {
func (self *BranchesController) getDisabledReasonForPress() *types.DisabledReason {
currentBranch := self.c.Helpers().Refs.GetCheckedOutRef()
if currentBranch != nil {
op := self.c.State().GetItemOperation(currentBranch)
if op == types.ItemOperationFastForwarding || op == types.ItemOperationPulling {
return self.c.Tr.CantCheckoutBranchWhilePulling
return &types.DisabledReason{Text: self.c.Tr.CantCheckoutBranchWhilePulling}
}
}
return ""
return nil
}
func (self *BranchesController) worktreeForBranch(branch *models.Branch) (*models.Worktree, bool) {
@ -525,7 +525,7 @@ func (self *BranchesController) delete(branch *models.Branch) error {
},
}
if checkedOutBranch.Name == branch.Name {
localDeleteItem.DisabledReason = self.c.Tr.CantDeleteCheckOutBranch
localDeleteItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CantDeleteCheckOutBranch}
}
remoteDeleteItem := &types.MenuItem{
@ -536,7 +536,7 @@ func (self *BranchesController) delete(branch *models.Branch) error {
},
}
if !branch.IsTrackingRemote() || branch.UpstreamGone {
remoteDeleteItem.DisabledReason = self.c.Tr.UpstreamNotSetError
remoteDeleteItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError}
}
menuTitle := utils.ResolvePlaceholderString(
@ -562,14 +562,14 @@ func (self *BranchesController) rebase() error {
return self.c.Helpers().MergeAndRebase.RebaseOntoRef(selectedBranchName)
}
func (self *BranchesController) getDisabledReasonForRebase() string {
func (self *BranchesController) getDisabledReasonForRebase() *types.DisabledReason {
selectedBranchName := self.context().GetSelected().Name
checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef().Name
if selectedBranchName == checkedOutBranch {
return self.c.Tr.CantRebaseOntoSelf
return &types.DisabledReason{Text: self.c.Tr.CantRebaseOntoSelf}
}
return ""
return nil
}
func (self *BranchesController) fastForward(branch *models.Branch) error {

View File

@ -848,15 +848,15 @@ func (self *FilesController) openCopyMenu() error {
}
if node == nil {
copyNameItem.DisabledReason = self.c.Tr.NoContentToCopyError
copyPathItem.DisabledReason = self.c.Tr.NoContentToCopyError
copyFileDiffItem.DisabledReason = self.c.Tr.NoContentToCopyError
copyNameItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError}
copyPathItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError}
copyFileDiffItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError}
}
if node != nil && !node.GetHasStagedOrTrackedChanges() {
copyFileDiffItem.DisabledReason = self.c.Tr.NoContentToCopyError
copyFileDiffItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError}
}
if !self.anyStagedOrTrackedFile() {
copyAllDiff.DisabledReason = self.c.Tr.NoContentToCopyError
copyAllDiff.DisabledReason = &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError}
}
return self.c.Menu(types.CreateMenuOptions{

View File

@ -5,6 +5,7 @@ import (
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/status"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
@ -23,7 +24,7 @@ func NewAppStatusHelper(c *HelperCommon, statusMgr func() *status.StatusManager,
}
}
func (self *AppStatusHelper) Toast(message string) {
func (self *AppStatusHelper) Toast(message string, kind types.ToastKind) {
if self.c.RunningIntegrationTest() {
// Don't bother showing toasts in integration tests. You can't check for
// them anyway, and they would only slow down the test unnecessarily by
@ -31,7 +32,7 @@ func (self *AppStatusHelper) Toast(message string) {
return
}
self.statusMgr().AddToastStatus(message)
self.statusMgr().AddToastStatus(message, kind)
self.renderAppStatus()
}
@ -87,7 +88,8 @@ func (self *AppStatusHelper) HasStatus() bool {
}
func (self *AppStatusHelper) GetStatusString() string {
return self.statusMgr().GetStatusString()
appStatus, _ := self.statusMgr().GetStatusString()
return appStatus
}
func (self *AppStatusHelper) renderAppStatus() {
@ -95,7 +97,8 @@ func (self *AppStatusHelper) renderAppStatus() {
ticker := time.NewTicker(time.Millisecond * utils.LoaderAnimationInterval)
defer ticker.Stop()
for range ticker.C {
appStatus := self.statusMgr().GetStatusString()
appStatus, color := self.statusMgr().GetStatusString()
self.c.Views().AppStatus.FgColor = color
self.c.OnUIThread(func() error {
self.c.SetViewContent(self.c.Views().AppStatus, appStatus)
return nil
@ -127,7 +130,8 @@ func (self *AppStatusHelper) renderAppStatusSync(stop chan struct{}) {
for {
select {
case <-ticker.C:
appStatus := self.statusMgr().GetStatusString()
appStatus, color := self.statusMgr().GetStatusString()
self.c.Views().AppStatus.FgColor = color
self.c.SetViewContent(self.c.Views().AppStatus, appStatus)
// Redraw all views of the bottom line:
bottomLineViews := []*gocui.View{

View File

@ -378,11 +378,11 @@ func (self *ConfirmationHelper) IsPopupPanelFocused() bool {
func (self *ConfirmationHelper) TooltipForMenuItem(menuItem *types.MenuItem) string {
tooltip := menuItem.Tooltip
if menuItem.DisabledReason != "" {
if menuItem.DisabledReason != nil {
if tooltip != "" {
tooltip += "\n\n"
}
tooltip += style.FgRed.Sprintf(self.c.Tr.DisabledMenuItemPrefix) + menuItem.DisabledReason
tooltip += style.FgRed.Sprintf(self.c.Tr.DisabledMenuItemPrefix) + menuItem.DisabledReason.Text
}
return tooltip
}

View File

@ -261,9 +261,9 @@ func (self *LocalCommitsController) squashDown(commit *models.Commit) error {
})
}
func (self *LocalCommitsController) getDisabledReasonForSquashDown(commit *models.Commit) string {
func (self *LocalCommitsController) getDisabledReasonForSquashDown(commit *models.Commit) *types.DisabledReason {
if self.context().GetSelectedLineIdx() >= len(self.c.Model().Commits)-1 {
return self.c.Tr.CannotSquashOrFixupFirstCommit
return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupFirstCommit}
}
return self.rebaseCommandEnabled(todo.Squash, commit)
@ -290,9 +290,9 @@ func (self *LocalCommitsController) fixup(commit *models.Commit) error {
})
}
func (self *LocalCommitsController) getDisabledReasonForFixup(commit *models.Commit) string {
func (self *LocalCommitsController) getDisabledReasonForFixup(commit *models.Commit) *types.DisabledReason {
if self.context().GetSelectedLineIdx() >= len(self.c.Model().Commits)-1 {
return self.c.Tr.CannotSquashOrFixupFirstCommit
return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupFirstCommit}
}
return self.rebaseCommandEnabled(todo.Squash, commit)
@ -528,9 +528,9 @@ func (self *LocalCommitsController) handleMidRebaseCommand(action todo.TodoComma
})
}
func (self *LocalCommitsController) rebaseCommandEnabled(action todo.TodoCommand, commit *models.Commit) string {
func (self *LocalCommitsController) rebaseCommandEnabled(action todo.TodoCommand, commit *models.Commit) *types.DisabledReason {
if commit.Action == models.ActionConflict {
return self.c.Tr.ChangingThisActionIsNotAllowed
return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed}
}
if !commit.IsTODO() {
@ -538,11 +538,11 @@ func (self *LocalCommitsController) rebaseCommandEnabled(action todo.TodoCommand
// If we are in a rebase, the only action that is allowed for
// non-todo commits is rewording the current head commit
if !(action == todo.Reword && self.isHeadCommit()) {
return self.c.Tr.AlreadyRebasing
return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing}
}
}
return ""
return nil
}
// for now we do not support setting 'reword' because it requires an editor
@ -550,14 +550,14 @@ func (self *LocalCommitsController) rebaseCommandEnabled(action todo.TodoCommand
// our input or we set a lazygit client as the EDITOR env variable and have it
// request us to edit the commit message when prompted.
if action == todo.Reword {
return self.c.Tr.RewordNotSupported
return &types.DisabledReason{Text: self.c.Tr.RewordNotSupported}
}
if allowed := isChangeOfRebaseTodoAllowed(action); !allowed {
return self.c.Tr.ChangingThisActionIsNotAllowed
return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed}
}
return ""
return nil
}
func (self *LocalCommitsController) moveDown(commit *models.Commit) error {
@ -687,12 +687,12 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error {
})
}
func (self *LocalCommitsController) getDisabledReasonForAmendTo(commit *models.Commit) string {
func (self *LocalCommitsController) getDisabledReasonForAmendTo(commit *models.Commit) *types.DisabledReason {
if !self.isHeadCommit() && self.c.Model().WorkingTreeStateAtLastCommitRefresh != enums.REBASE_MODE_NONE {
return self.c.Tr.AlreadyRebasing
return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing}
}
return ""
return nil
}
func (self *LocalCommitsController) amendAttribute(commit *models.Commit) error {
@ -870,30 +870,30 @@ func (self *LocalCommitsController) squashAllAboveFixupCommits(commit *models.Co
})
}
func (self *LocalCommitsController) getDisabledReasonForSquashAllAboveFixupCommits(commit *models.Commit) string {
func (self *LocalCommitsController) getDisabledReasonForSquashAllAboveFixupCommits(commit *models.Commit) *types.DisabledReason {
if self.c.Model().WorkingTreeStateAtLastCommitRefresh != enums.REBASE_MODE_NONE {
return self.c.Tr.AlreadyRebasing
return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing}
}
return ""
return nil
}
// For getting disabled reason
func (self *LocalCommitsController) notMidRebase() string {
func (self *LocalCommitsController) notMidRebase() *types.DisabledReason {
if self.c.Model().WorkingTreeStateAtLastCommitRefresh != enums.REBASE_MODE_NONE {
return self.c.Tr.AlreadyRebasing
return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing}
}
return ""
return nil
}
// For getting disabled reason
func (self *LocalCommitsController) canFindCommitForQuickStart() string {
func (self *LocalCommitsController) canFindCommitForQuickStart() *types.DisabledReason {
if _, err := self.findCommitForQuickStartInteractiveRebase(); err != nil {
return err.Error()
return &types.DisabledReason{Text: err.Error(), ShowErrorInPanel: true}
}
return ""
return nil
}
func (self *LocalCommitsController) createTag(commit *models.Commit) error {
@ -1028,23 +1028,23 @@ func (self *LocalCommitsController) checkSelected(callback func(*models.Commit)
}
}
func (self *LocalCommitsController) callGetDisabledReasonFuncWithSelectedCommit(callback func(*models.Commit) string) func() string {
return func() string {
func (self *LocalCommitsController) callGetDisabledReasonFuncWithSelectedCommit(callback func(*models.Commit) *types.DisabledReason) func() *types.DisabledReason {
return func() *types.DisabledReason {
commit := self.context().GetSelected()
if commit == nil {
return self.c.Tr.NoCommitSelected
return &types.DisabledReason{Text: self.c.Tr.NoCommitSelected}
}
return callback(commit)
}
}
func (self *LocalCommitsController) disabledIfNoSelectedCommit() func() string {
return self.callGetDisabledReasonFuncWithSelectedCommit(func(*models.Commit) string { return "" })
func (self *LocalCommitsController) disabledIfNoSelectedCommit() func() *types.DisabledReason {
return self.callGetDisabledReasonFuncWithSelectedCommit(func(*models.Commit) *types.DisabledReason { return nil })
}
func (self *LocalCommitsController) getDisabledReasonForRebaseCommandWithSelectedCommit(action todo.TodoCommand) func() string {
return self.callGetDisabledReasonFuncWithSelectedCommit(func(commit *models.Commit) string {
func (self *LocalCommitsController) getDisabledReasonForRebaseCommandWithSelectedCommit(action todo.TodoCommand) func() *types.DisabledReason {
return self.callGetDisabledReasonFuncWithSelectedCommit(func(commit *models.Commit) *types.DisabledReason {
return self.rebaseCommandEnabled(action, commit)
})
}
@ -1077,12 +1077,12 @@ func (self *LocalCommitsController) paste() error {
return self.c.Helpers().CherryPick.Paste()
}
func (self *LocalCommitsController) getDisabledReasonForPaste() string {
func (self *LocalCommitsController) getDisabledReasonForPaste() *types.DisabledReason {
if !self.c.Helpers().CherryPick.CanPaste() {
return self.c.Tr.NoCopiedCommits
return &types.DisabledReason{Text: self.c.Tr.NoCopiedCommits}
}
return ""
return nil
}
func (self *LocalCommitsController) markAsBaseCommit(commit *models.Commit) error {
@ -1100,15 +1100,15 @@ func (self *LocalCommitsController) isHeadCommit() bool {
}
// Convenience function for composing multiple disabled reason functions
func (self *LocalCommitsController) require(callbacks ...func() string) func() string {
return func() string {
func (self *LocalCommitsController) require(callbacks ...func() *types.DisabledReason) func() *types.DisabledReason {
return func() *types.DisabledReason {
for _, callback := range callbacks {
if disabledReason := callback(); disabledReason != "" {
if disabledReason := callback(); disabledReason != nil {
return disabledReason
}
}
return ""
return nil
}
}

View File

@ -25,7 +25,7 @@ func (self *OptionsMenuAction) Call() error {
appendBindings := func(bindings []*types.Binding, section *types.MenuSection) {
menuItems = append(menuItems,
lo.Map(bindings, func(binding *types.Binding, _ int) *types.MenuItem {
disabledReason := ""
var disabledReason *types.DisabledReason
if binding.GetDisabledReason != nil {
disabledReason = binding.GetDisabledReason()
}

View File

@ -59,16 +59,16 @@ func (self *SyncController) HandlePull() error {
return self.branchCheckedOut(self.pull)()
}
func (self *SyncController) getDisabledReasonForPushOrPull() string {
func (self *SyncController) getDisabledReasonForPushOrPull() *types.DisabledReason {
currentBranch := self.c.Helpers().Refs.GetCheckedOutRef()
if currentBranch != nil {
op := self.c.State().GetItemOperation(currentBranch)
if op != types.ItemOperationNone {
return self.c.Tr.CantPullOrPushSameBranchTwice
return &types.DisabledReason{Text: self.c.Tr.CantPullOrPushSameBranchTwice}
}
}
return ""
return nil
}
func (self *SyncController) branchCheckedOut(f func(*models.Branch) error) func() error {

View File

@ -17,6 +17,6 @@ func NewDummyUpdater() *updates.Updater {
func NewDummyGui() *Gui {
newAppConfig := config.NewDummyAppConfig()
dummyGui, _ := NewGui(utils.NewDummyCommon(), newAppConfig, &git_commands.GitVersion{}, NewDummyUpdater(), false, "")
dummyGui, _ := NewGui(utils.NewDummyCommon(), newAppConfig, &git_commands.GitVersion{}, NewDummyUpdater(), false, "", nil)
return dummyGui
}

View File

@ -467,6 +467,7 @@ func NewGui(
updater *updates.Updater,
showRecentRepos bool,
initialDir string,
test integrationTypes.IntegrationTest,
) (*Gui, error) {
gui := &Gui{
Common: cmn,
@ -516,7 +517,7 @@ func NewGui(
func(message string, f func() error) {
gui.helpers.AppStatus.WithWaitingStatusSync(message, f)
},
func(message string) { gui.helpers.AppStatus.Toast(message) },
func(message string, kind types.ToastKind) { gui.helpers.AppStatus.Toast(message, kind) },
func() string { return gui.Views.Confirmation.TextArea.GetContent() },
func() bool { return gui.c.InDemo() },
)

View File

@ -20,11 +20,14 @@ import (
type GuiDriver struct {
gui *Gui
isIdleChan chan struct{}
toastChan chan string
}
var _ integrationTypes.GuiDriver = &GuiDriver{}
func (self *GuiDriver) PressKey(keyStr string) {
self.CheckAllToastsAcknowledged()
key := keybindings.GetKey(keyStr)
var r rune
@ -46,6 +49,8 @@ func (self *GuiDriver) PressKey(keyStr string) {
}
func (self *GuiDriver) Click(x, y int) {
self.CheckAllToastsAcknowledged()
self.gui.g.ReplayedEvents.MouseEvents <- gocui.NewTcellMouseEventWrapper(
tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0),
0,
@ -58,6 +63,12 @@ func (self *GuiDriver) waitTillIdle() {
<-self.isIdleChan
}
func (self *GuiDriver) CheckAllToastsAcknowledged() {
if t := self.NextToast(); t != nil {
self.Fail("Toast not acknowledged: " + *t)
}
}
func (self *GuiDriver) Keys() config.KeybindingConfig {
return self.gui.Config.GetUserConfig().Keybinding
}
@ -133,3 +144,12 @@ func (self *GuiDriver) SetCaptionPrefix(prefix string) {
self.gui.setCaptionPrefix(prefix)
self.waitTillIdle()
}
func (self *GuiDriver) NextToast() *string {
select {
case t := <-self.toastChan:
return &t
default:
return nil
}
}

View File

@ -411,12 +411,17 @@ func (gui *Gui) SetMouseKeybinding(binding *gocui.ViewMouseBinding) error {
}
func (gui *Gui) callKeybindingHandler(binding *types.Binding) error {
disabledReason := ""
var disabledReason *types.DisabledReason
if binding.GetDisabledReason != nil {
disabledReason = binding.GetDisabledReason()
}
if disabledReason != "" {
return gui.c.ErrorMsg(disabledReason)
if disabledReason != nil {
if disabledReason.ShowErrorInPanel {
return gui.c.ErrorMsg(disabledReason.Text)
}
gui.c.ErrorToast(gui.Tr.DisabledMenuItemPrefix + disabledReason.Text)
return nil
}
return binding.Handler()
}

View File

@ -22,7 +22,7 @@ type PopupHandler struct {
createMenuFn func(types.CreateMenuOptions) error
withWaitingStatusFn func(message string, f func(gocui.Task) error)
withWaitingStatusSyncFn func(message string, f func() error)
toastFn func(message string)
toastFn func(message string, kind types.ToastKind)
getPromptInputFn func() string
inDemo func() bool
}
@ -38,7 +38,7 @@ func NewPopupHandler(
createMenuFn func(types.CreateMenuOptions) error,
withWaitingStatusFn func(message string, f func(gocui.Task) error),
withWaitingStatusSyncFn func(message string, f func() error),
toastFn func(message string),
toastFn func(message string, kind types.ToastKind),
getPromptInputFn func() string,
inDemo func() bool,
) *PopupHandler {
@ -63,7 +63,15 @@ func (self *PopupHandler) Menu(opts types.CreateMenuOptions) error {
}
func (self *PopupHandler) Toast(message string) {
self.toastFn(message)
self.toastFn(message, types.ToastKindStatus)
}
func (self *PopupHandler) ErrorToast(message string) {
self.toastFn(message, types.ToastKindError)
}
func (self *PopupHandler) SetToastFunc(f func(string, types.ToastKind)) {
self.toastFn = f
}
func (self *PopupHandler) WithWaitingStatus(message string, f func(gocui.Task) error) error {

View File

@ -3,6 +3,8 @@ package status
import (
"time"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/sasha-s/go-deadlock"
@ -26,7 +28,7 @@ type WaitingStatusHandle struct {
}
func (self *WaitingStatusHandle) Show() {
self.id = self.statusManager.addStatus(self.message, "waiting")
self.id = self.statusManager.addStatus(self.message, "waiting", types.ToastKindStatus)
self.renderFunc()
}
@ -37,6 +39,7 @@ func (self *WaitingStatusHandle) Hide() {
type appStatus struct {
message string
statusType string
color gocui.Attribute
id int
}
@ -53,11 +56,12 @@ func (self *StatusManager) WithWaitingStatus(message string, renderFunc func(),
handle.Hide()
}
func (self *StatusManager) AddToastStatus(message string) int {
id := self.addStatus(message, "toast")
func (self *StatusManager) AddToastStatus(message string, kind types.ToastKind) int {
id := self.addStatus(message, "toast", kind)
go func() {
time.Sleep(time.Second * 2)
delay := lo.Ternary(kind == types.ToastKindError, time.Second*4, time.Second*2)
time.Sleep(delay)
self.removeStatus(id)
}()
@ -65,31 +69,37 @@ func (self *StatusManager) AddToastStatus(message string) int {
return id
}
func (self *StatusManager) GetStatusString() string {
func (self *StatusManager) GetStatusString() (string, gocui.Attribute) {
if len(self.statuses) == 0 {
return ""
return "", gocui.ColorDefault
}
topStatus := self.statuses[0]
if topStatus.statusType == "waiting" {
return topStatus.message + " " + utils.Loader(time.Now())
return topStatus.message + " " + utils.Loader(time.Now()), topStatus.color
}
return topStatus.message
return topStatus.message, topStatus.color
}
func (self *StatusManager) HasStatus() bool {
return len(self.statuses) > 0
}
func (self *StatusManager) addStatus(message string, statusType string) int {
func (self *StatusManager) addStatus(message string, statusType string, kind types.ToastKind) int {
self.mutex.Lock()
defer self.mutex.Unlock()
self.nextId++
id := self.nextId
color := gocui.ColorCyan
if kind == types.ToastKindError {
color = gocui.ColorRed
}
newStatus := appStatus{
message: message,
statusType: statusType,
color: color,
id: id,
}
self.statuses = append([]appStatus{newStatus}, self.statuses...)

View File

@ -6,6 +6,8 @@ import (
"time"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/popup"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/utils"
)
@ -32,7 +34,11 @@ func (gui *Gui) handleTestMode() {
go func() {
waitUntilIdle()
test.Run(&GuiDriver{gui: gui, isIdleChan: isIdleChan})
toastChan := make(chan string, 100)
gui.PopupHandler.(*popup.PopupHandler).SetToastFunc(
func(message string, kind types.ToastKind) { toastChan <- message })
test.Run(&GuiDriver{gui: gui, isIdleChan: isIdleChan, toastChan: toastChan})
gui.g.Update(func(*gocui.Gui) error {
return gocui.ErrQuit

View File

@ -144,9 +144,18 @@ type IPopupHandler interface {
WithWaitingStatusSync(message string, f func() error) error
Menu(opts CreateMenuOptions) error
Toast(message string)
ErrorToast(message string)
SetToastFunc(func(string, ToastKind))
GetPromptInput() string
}
type ToastKind int
const (
ToastKindStatus ToastKind = iota
ToastKindError
)
type CreateMenuOptions struct {
Title string
Items []*MenuItem
@ -192,6 +201,16 @@ type MenuSection struct {
Column int // The column that this section title should be aligned with
}
type DisabledReason struct {
Text string
// When trying to invoke a disabled key binding or menu item, we normally
// show the disabled reason as a toast; setting this to true shows it as an
// error panel instead. This is useful if the text is very long, or if it is
// important enough to show it more prominently, or both.
ShowErrorInPanel bool
}
type MenuItem struct {
Label string
@ -210,9 +229,9 @@ type MenuItem struct {
// The tooltip will be displayed upon highlighting the menu item
Tooltip string
// If non-empty, show this in a tooltip, style the menu item as disabled,
// If non-nil, show this in a tooltip, style the menu item as disabled,
// and refuse to invoke the command
DisabledReason string
DisabledReason *DisabledReason
// Can be used to group menu items into sections with headers. MenuItems
// with the same Section should be contiguous, and will automatically get a

View File

@ -31,7 +31,7 @@ type Binding struct {
// disabled and we show the given text in an error message when trying to
// invoke it. When left nil, the command is always enabled. Note that this
// function must not do expensive calls.
GetDisabledReason func() string
GetDisabledReason func() *DisabledReason
}
// A guard is a decorator which checks something before executing a handler

View File

@ -18,10 +18,12 @@ func (self *MenuDriver) Title(expected *TextMatcher) *MenuDriver {
return self
}
func (self *MenuDriver) Confirm() {
func (self *MenuDriver) Confirm() *MenuDriver {
self.checkNecessaryChecksCompleted()
self.getViewDriver().PressEnter()
return self
}
func (self *MenuDriver) Cancel() {
@ -72,6 +74,11 @@ func (self *MenuDriver) Tooltip(option *TextMatcher) *MenuDriver {
return self
}
func (self *MenuDriver) Tap(f func()) *MenuDriver {
self.getViewDriver().Tap(f)
return self
}
func (self *MenuDriver) checkNecessaryChecksCompleted() {
if !self.hasCheckedTitle {
self.t.Fail("You must check the title of a menu popup by calling Title() before calling Confirm()/Cancel().")

View File

@ -194,6 +194,8 @@ func (self *IntegrationTest) Run(gui integrationTypes.GuiDriver) {
self.run(testDriver, keys)
gui.CheckAllToastsAcknowledged()
if InputDelay() > 0 {
// Clear whatever caption there was so it doesn't linger
testDriver.SetCaption("")

View File

@ -102,8 +102,19 @@ func (self *TestDriver) ExpectPopup() *Popup {
return &Popup{t: self}
}
func (self *TestDriver) ExpectToast(matcher *TextMatcher) {
self.Views().AppStatus().Content(matcher)
func (self *TestDriver) ExpectToast(matcher *TextMatcher) *TestDriver {
t := self.gui.NextToast()
if t == nil {
self.gui.Fail("Expected toast, but didn't get one")
} else {
self.matchString(matcher, "Unexpected toast message",
func() string {
return *t
},
)
}
return self
}
func (self *TestDriver) ExpectClipboard(matcher *TextMatcher) {

View File

@ -78,6 +78,12 @@ func (self *fakeGuiDriver) SetCaption(string) {
func (self *fakeGuiDriver) SetCaptionPrefix(string) {
}
func (self *fakeGuiDriver) NextToast() *string {
return nil
}
func (self *fakeGuiDriver) CheckAllToastsAcknowledged() {}
func TestManualFailure(t *testing.T) {
test := NewIntegrationTest(NewIntegrationTestArgs{
Description: unitTestDescription,

View File

@ -37,12 +37,11 @@ var Delete = NewIntegrationTest(NewIntegrationTestArgs{
Tooltip(Contains("You cannot delete the checked out branch!")).
Title(Equals("Delete branch 'branch-three'?")).
Select(Contains("Delete local branch")).
Confirm()
t.ExpectPopup().
Alert().
Title(Equals("Error")).
Content(Contains("You cannot delete the checked out branch!")).
Confirm()
Confirm().
Tap(func() {
t.ExpectToast(Contains("You cannot delete the checked out branch!"))
}).
Cancel()
}).
SelectNextItem().
Press(keys.Universal.Remove).

View File

@ -48,11 +48,11 @@ var RebaseToUpstream = NewIntegrationTest(NewIntegrationTestArgs{
Title(Equals("Upstream options")).
Select(Contains("Rebase checked-out branch onto upstream of selected branch")).
Tooltip(Contains("Disabled: The selected branch has no upstream (or the upstream is not stored locally)")).
Confirm()
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Equals("The selected branch has no upstream (or the upstream is not stored locally)")).
Confirm()
Confirm().
Tap(func() {
t.ExpectToast(Equals("Disabled: The selected branch has no upstream (or the upstream is not stored locally)"))
}).
Cancel()
}).
SelectNextItem().
Lines(

View File

@ -42,11 +42,11 @@ var ResetToUpstream = NewIntegrationTest(NewIntegrationTestArgs{
Title(Equals("Upstream options")).
Select(Contains("Reset checked-out branch onto upstream of selected branch")).
Tooltip(Contains("Disabled: The selected branch has no upstream (or the upstream is not stored locally)")).
Confirm()
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Equals("The selected branch has no upstream (or the upstream is not stored locally)")).
Confirm()
Confirm().
Tap(func() {
t.ExpectToast(Equals("Disabled: The selected branch has no upstream (or the upstream is not stored locally)"))
}).
Cancel()
}).
SelectNextItem().
Lines(

View File

@ -30,12 +30,11 @@ var CopyMenu = NewIntegrationTest(NewIntegrationTestArgs{
Title(Equals("Copy to clipboard")).
Select(Contains("File name")).
Tooltip(Equals("Disabled: Nothing to copy")).
Confirm()
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Equals("Nothing to copy")).
Confirm()
Confirm().
Tap(func() {
t.ExpectToast(Equals("Disabled: Nothing to copy"))
}).
Cancel()
})
t.Shell().
@ -56,12 +55,11 @@ var CopyMenu = NewIntegrationTest(NewIntegrationTestArgs{
Title(Equals("Copy to clipboard")).
Select(Contains("Diff of selected file")).
Tooltip(Contains("Disabled: Nothing to copy")).
Confirm()
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Equals("Nothing to copy")).
Confirm()
Confirm().
Tap(func() {
t.ExpectToast(Equals("Disabled: Nothing to copy"))
}).
Cancel()
}).
Press(keys.Files.CopyFileInfoToClipboard).
Tap(func() {
@ -69,12 +67,11 @@ var CopyMenu = NewIntegrationTest(NewIntegrationTestArgs{
Title(Equals("Copy to clipboard")).
Select(Contains("Diff of all files")).
Tooltip(Contains("Disabled: Nothing to copy")).
Confirm()
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Equals("Nothing to copy")).
Confirm()
Confirm().
Tap(func() {
t.ExpectToast(Equals("Disabled: Nothing to copy"))
}).
Cancel()
})
t.Shell().
@ -101,6 +98,8 @@ var CopyMenu = NewIntegrationTest(NewIntegrationTestArgs{
Select(Contains("File name")).
Confirm()
t.ExpectToast(Equals("File name copied to clipboard"))
expectClipboard(t, Contains("unstaged_file"))
})
@ -113,6 +112,8 @@ var CopyMenu = NewIntegrationTest(NewIntegrationTestArgs{
Select(Contains("Path")).
Confirm()
t.ExpectToast(Equals("File path copied to clipboard"))
expectClipboard(t, Contains("dir/1-unstaged_file"))
})
@ -126,6 +127,8 @@ var CopyMenu = NewIntegrationTest(NewIntegrationTestArgs{
Tooltip(Equals("If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.")).
Confirm()
t.ExpectToast(Equals("File diff copied to clipboard"))
expectClipboard(t, Contains("+unstaged content (new)"))
})
@ -145,6 +148,8 @@ var CopyMenu = NewIntegrationTest(NewIntegrationTestArgs{
Tooltip(Equals("If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.")).
Confirm()
t.ExpectToast(Equals("File diff copied to clipboard"))
expectClipboard(t, Contains("+staged content (new)"))
})
@ -158,6 +163,8 @@ var CopyMenu = NewIntegrationTest(NewIntegrationTestArgs{
Tooltip(Equals("If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.")).
Confirm()
t.ExpectToast(Equals("All files diff copied to clipboard"))
expectClipboard(t, Contains("+staged content (new)"))
})
@ -179,6 +186,8 @@ var CopyMenu = NewIntegrationTest(NewIntegrationTestArgs{
Tooltip(Equals("If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.")).
Confirm()
t.ExpectToast(Equals("All files diff copied to clipboard"))
expectClipboard(t, Contains("+staged content (new)").Contains("+unstaged content (new)"))
})
},

View File

@ -34,10 +34,7 @@ var AmendNonHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{
NavigateToLine(Contains(commit)).
Press(keys.Commits.AmendToCommit)
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Contains("Can't perform this action during a rebase")).
Confirm()
t.ExpectToast(Contains("Can't perform this action during a rebase"))
}
},
})

View File

@ -29,9 +29,6 @@ var EditNonTodoCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{
NavigateToLine(Contains("commit 01")).
Press(keys.Universal.Edit)
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Contains("Can't perform this action during a rebase")).
Confirm()
t.ExpectToast(Contains("Can't perform this action during a rebase"))
},
})

View File

@ -39,9 +39,6 @@ var EditTheConflCommit = NewIntegrationTest(NewIntegrationTestArgs{
NavigateToLine(Contains("<-- YOU ARE HERE --- commit three")).
Press(keys.Commits.RenameCommit)
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Contains("Changing this kind of rebase todo entry is not allowed")).
Confirm()
t.ExpectToast(Contains("Changing this kind of rebase todo entry is not allowed"))
},
})

View File

@ -24,10 +24,7 @@ var FixupFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{
NavigateToLine(Contains("commit 01")).
Press(keys.Commits.MarkCommitAsFixup).
Tap(func() {
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Equals("There's no commit below to squash into")).
Confirm()
t.ExpectToast(Equals("Disabled: There's no commit below to squash into"))
}).
Lines(
Contains("commit 02"),

View File

@ -50,13 +50,12 @@ var QuickStart = NewIntegrationTest(NewIntegrationTestArgs{
Contains("initial commit"),
).
// Verify we can't quick start from main
Press(keys.Commits.StartInteractiveRebase).
Tap(func() {
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Contains("Cannot start interactive rebase: the HEAD commit is a merge commit or is present on the main branch, so there is no appropriate base commit to start the rebase from. You can start an interactive rebase from a specific commit by selecting the commit and pressing `e`.")).
Confirm()
})
Press(keys.Commits.StartInteractiveRebase)
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Equals("Cannot start interactive rebase: the HEAD commit is a merge commit or is present on the main branch, so there is no appropriate base commit to start the rebase from. You can start an interactive rebase from a specific commit by selecting the commit and pressing `e`.")).
Confirm()
t.Views().Branches().
Focus().
@ -80,15 +79,10 @@ var QuickStart = NewIntegrationTest(NewIntegrationTestArgs{
Contains("initial commit"),
).
// Try again, verify we fail because we're already rebasing
Press(keys.Commits.StartInteractiveRebase).
Tap(func() {
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Contains("Can't perform this action during a rebase")).
Confirm()
Press(keys.Commits.StartInteractiveRebase)
t.Common().AbortRebase()
})
t.ExpectToast(Equals("Disabled: Can't perform this action during a rebase"))
t.Common().AbortRebase()
// Verify if a merge commit is present on the branch we start from there
t.Views().Branches().

View File

@ -24,10 +24,7 @@ var SquashDownFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{
NavigateToLine(Contains("commit 01")).
Press(keys.Commits.SquashDown).
Tap(func() {
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Equals("There's no commit below to squash into")).
Confirm()
t.ExpectToast(Equals("Disabled: There's no commit below to squash into"))
}).
Lines(
Contains("commit 02"),

View File

@ -27,6 +27,8 @@ var CopyToClipboard = NewIntegrationTest(NewIntegrationTestArgs{
).
Press(keys.Universal.CopyToClipboard)
t.ExpectToast(Equals("'branch-a' Copied to clipboard"))
t.Views().Files().
Focus()

View File

@ -62,6 +62,9 @@ var DiffContextChange = NewIntegrationTest(NewIntegrationTestArgs{
Contains(` 6a`),
).
Press(keys.Universal.IncreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 4"))
}).
SelectedLines(
Contains(`@@ -1,7 +1,7 @@`),
Contains(` 1a`),
@ -74,6 +77,9 @@ var DiffContextChange = NewIntegrationTest(NewIntegrationTestArgs{
Contains(` 7a`),
).
Press(keys.Universal.DecreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 3"))
}).
SelectedLines(
Contains(`@@ -1,6 +1,6 @@`),
Contains(` 1a`),
@ -85,6 +91,9 @@ var DiffContextChange = NewIntegrationTest(NewIntegrationTestArgs{
Contains(` 6a`),
).
Press(keys.Universal.DecreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 2"))
}).
SelectedLines(
Contains(`@@ -1,5 +1,5 @@`),
Contains(` 1a`),
@ -95,6 +104,9 @@ var DiffContextChange = NewIntegrationTest(NewIntegrationTestArgs{
Contains(` 5a`),
).
Press(keys.Universal.DecreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 1"))
}).
SelectedLines(
Contains(`@@ -2,3 +2,3 @@`),
Contains(` 2a`),
@ -116,6 +128,9 @@ var DiffContextChange = NewIntegrationTest(NewIntegrationTestArgs{
Contains(` 4a`),
).
Press(keys.Universal.IncreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 2"))
}).
SelectedLines(
Contains(`@@ -1,5 +1,5 @@`),
Contains(` 1a`),

View File

@ -65,6 +65,7 @@ var CrudAnnotated = NewIntegrationTest(NewIntegrationTestArgs{
Title(Equals("Delete tag 'new-tag'?")).
Content(Equals("Are you sure you want to delete the remote tag 'new-tag' from 'origin'?")).
Confirm()
t.ExpectToast(Equals("Remote tag deleted"))
}).
Lines(
MatchesRegexp(`new-tag.*message`).IsSelected(),

View File

@ -70,6 +70,7 @@ var CrudLightweight = NewIntegrationTest(NewIntegrationTestArgs{
Title(Equals("Delete tag 'new-tag'?")).
Content(Equals("Are you sure you want to delete the remote tag 'new-tag' from 'origin'?")).
Confirm()
t.ExpectToast(Equals("Remote tag deleted"))
}).
Lines(
MatchesRegexp(`new-tag.*initial commit`).IsSelected(),

View File

@ -43,4 +43,7 @@ type GuiDriver interface {
View(viewName string) *gocui.View
SetCaption(caption string)
SetCaptionPrefix(prefix string)
// Pop the next toast that was displayed; returns nil if there was none
NextToast() *string
CheckAllToastsAcknowledged()
}