Route space to the conflict picker for non-textual conflicts

For a non-textual conflict (e.g. DD/AU/UA/UD/DU), pressing space used to run the
normal stage path, which did something unclear: `git add` happens to resolve the
conflict by keeping the file, but that's neither obvious nor symmetric. Route a
single such file to the same Keep/Delete picker that enter opens, so space and
enter agree.

For a range selection that includes one of these conflicts, staging makes no
sense, so disable it with a toast that points the user at resolving them one at
a time. (Entering a range was already disabled with the standard toast.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-28 13:38:44 +02:00
parent 41d92aac36
commit 860f89e0c9
4 changed files with 87 additions and 1 deletions

View file

@ -44,7 +44,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
{
Keys: opts.GetKeys(opts.Config.Universal.Select),
Handler: self.withItems(self.press),
GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected())),
GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canStageSelection))),
Description: self.c.Tr.Stage,
Tooltip: self.c.Tr.StageTooltip,
DisplayOnScreen: true,
@ -583,6 +583,12 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e
}
func (self *FilesController) press(nodes []*filetree.FileNode) error {
// A single file with a conflict that can only be resolved through a dialog
// can't be staged; route it to the same picker that `enter` uses instead.
if len(nodes) == 1 && self.conflictNeedsResolutionDialog(nodes[0].File) {
return self.openConflictResolutionMenu(nodes[0].File)
}
if err := self.pressWithLock(nodes); err != nil {
return err
}
@ -716,6 +722,27 @@ func (self *FilesController) conflictNeedsResolutionDialog(file *models.File) bo
return !file.HasInlineMergeConflicts
}
// canStageSelection disables staging when a multiple selection includes a file
// with a conflict that must be resolved through a dialog; those have to be
// resolved one at a time.
func (self *FilesController) canStageSelection(nodes []*filetree.FileNode) *types.DisabledReason {
if len(nodes) > 1 {
for _, node := range nodes {
if node.SomeFile(self.conflictNeedsResolutionDialog) {
return &types.DisabledReason{
Text: utils.ResolvePlaceholderString(
self.c.Tr.StageConflictsRangeDisabled, map[string]string{
"goIntoKey": self.c.UserConfig().Keybinding.Universal.GoInto.String(),
},
),
}
}
}
}
return nil
}
func (self *FilesController) openConflictResolutionMenu(file *models.File) error {
handle := func(command func(command string) error, logText string) error {
self.c.LogAction(logText)

View file

@ -101,6 +101,7 @@ type TranslationSet struct {
MergeConflictPressEnterToResolve string
MergeConflictKeepFile string
MergeConflictDeleteFile string
StageConflictsRangeDisabled string
Checkout string
CheckoutTooltip string
CantCheckoutBranchWhilePulling string
@ -1200,6 +1201,7 @@ func EnglishTranslationSet() *TranslationSet {
MergeConflictPressEnterToResolve: "Press %s to resolve.",
MergeConflictKeepFile: "Keep file",
MergeConflictDeleteFile: "Delete file",
StageConflictsRangeDisabled: "Cannot stage a selection that includes files with merge conflicts; resolve them individually with {{.goIntoKey}} first.",
Checkout: "Checkout",
CheckoutTooltip: "Checkout selected item.",
CantCheckoutBranchWhilePulling: "You cannot checkout another branch while pulling the current branch",

View file

@ -0,0 +1,56 @@
package conflicts
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SpaceOnNonTextualConflict = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Pressing space on a non-textual conflict opens the resolution menu; staging is disabled for a range that includes one",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.ShowFileTree = false
},
SetupRepo: func(shell *Shell) {
shell.RunShellCommand(`echo 1 > foo && echo 1 > bar`)
shell.RunShellCommand(`git checkout -b base && git add . && git commit -m base`)
// theirs: delete foo, modify bar
shell.RunShellCommand(`git checkout -b theirs`)
shell.RunShellCommand(`git rm foo && echo 2 > bar && git add bar && git commit -m theirs`)
// ours: modify foo, delete bar
shell.RunShellCommand(`git checkout base && git checkout -b ours`)
shell.RunShellCommand(`echo 2 > foo && git add foo && git rm bar && git commit -m ours`)
shell.RunCommandExpectError([]string{"git", "merge", "theirs"})
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Lines(
Contains("DU bar"),
Contains("UD foo"),
).
// Pressing space on a single non-textual conflict opens the
// resolution menu rather than trying to stage it.
NavigateToLine(Contains("bar")).
PressPrimaryAction().
Tap(func() {
t.ExpectPopup().Menu().Title(Equals("Merge conflicts")).Cancel()
}).
// Staging is disabled for a range selection that includes a conflict.
Press(keys.Universal.ToggleRangeSelect).
NavigateToLine(Contains("foo")).
PressPrimaryAction().
Tap(func() {
t.ExpectToast(Contains("Cannot stage a selection that includes files with merge conflicts"))
}).
// Entering a range selection is disabled too, with the usual toast.
Press(keys.Universal.GoInto).
Tap(func() {
t.ExpectToast(Contains("does not support range selection"))
})
},
})

View file

@ -172,6 +172,7 @@ var tests = []*components.IntegrationTest{
conflicts.ResolveNoAutoStage,
conflicts.ResolveNonTextualConflicts,
conflicts.ResolveWithoutTrailingLf,
conflicts.SpaceOnNonTextualConflict,
conflicts.UndoChooseHunk,
custom_commands.AccessCommitProperties,
custom_commands.BasicCommand,