From df434f48157201a721f6d517ef6922aa7bcbe186 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 10 Sep 2026 10:52:09 +0200 Subject: [PATCH] Show a selection only over the diff the panel offers Some merge conflicts can only be resolved by picking a side. The files panel explains those rather than diffing them, and where one side deleted the file and the other modified it, git's diff of that modification is shown below the explanation. The focused main view took those change lines for a diff of its own. It drew a selection over them and offered to stage hunks of a file whose conflict staging can't resolve. So have a render say whether it holds the diff the panel offers in the main view, and put a selection only on one that does. Every diff render already goes through NewMainViewDiffTask, so it says so for itself; the custom patch preview, assembled as text rather than run as a command, says so through NewMainViewDiffStringTask. Establishing a selection asks the pane the same question rather than looking for change lines itself. The selection has been wrong over this content since "Show a selection in the focused main view" introduced it, and the fix belongs there. It lands here instead because a render had no way to say what it holds until "Show git's own diff when the renderer's can't be acted on" gave every diff render one constructor to go through. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/context/main_context.go | 16 ++++++ pkg/gui/controllers/helpers/diff_helper.go | 2 +- .../helpers/diff_line_selection.go | 8 +-- .../controllers/local_commits_controller.go | 2 +- pkg/gui/main_panels.go | 23 ++++++-- pkg/gui/types/rendering.go | 46 +++++++++++++++- .../no_selection_over_a_conflict_hint.go | 52 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 8 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 pkg/integration/tests/main_view/no_selection_over_a_conflict_hint.go diff --git a/pkg/gui/context/main_context.go b/pkg/gui/context/main_context.go index 92ce65d37..f726b4ea6 100644 --- a/pkg/gui/context/main_context.go +++ b/pkg/gui/context/main_context.go @@ -19,6 +19,11 @@ type MainContext struct { // was worked out from. What there is to select is a property of the content, so an // answer about the content of another render says nothing about this one. selectableContentRenderKey string + // contentIsDiff says the render this pane was last given is the diff the panel + // beneath offers here, rather than a message or an explanation (see + // types.ContentIsDiff). It is known as the render is asked for, well before there + // is any content to look at. + contentIsDiff bool } var ( @@ -65,6 +70,17 @@ func (self *MainContext) SetSelectableContentRenderKey(key string) { self.selectableContentRenderKey = key } +// ContentIsDiff reports whether this pane is showing the panel's diff (see +// contentIsDiff). +func (self *MainContext) ContentIsDiff() bool { + return self.contentIsDiff +} + +// SetContentIsDiff records what kind of render the pane is being given. +func (self *MainContext) SetContentIsDiff(value bool) { + self.contentIsDiff = value +} + func NewMainContext( view *gocui.View, windowName string, diff --git a/pkg/gui/controllers/helpers/diff_helper.go b/pkg/gui/controllers/helpers/diff_helper.go index fed353d82..0c829639c 100644 --- a/pkg/gui/controllers/helpers/diff_helper.go +++ b/pkg/gui/controllers/helpers/diff_helper.go @@ -141,7 +141,7 @@ func (self *DiffHelper) RenderDiff() { self.c.Tr.ShowingGitDiff, "git diff "+strings.Join(args, " "), ) - task := types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix) + task := types.NewMainViewDiffTaskWithPrefix(cmdObj.GetCmd(), prefix, git_commands.DiffModeRendered) self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, diff --git a/pkg/gui/controllers/helpers/diff_line_selection.go b/pkg/gui/controllers/helpers/diff_line_selection.go index 1b42cc181..a8b465140 100644 --- a/pkg/gui/controllers/helpers/diff_line_selection.go +++ b/pkg/gui/controllers/helpers/diff_line_selection.go @@ -30,10 +30,10 @@ func (self *DiffLineHelper) EstablishSelection(mainContext *context.MainContext, mainContext.ResetDiffSelectMode() view := mainContext.GetView() - // The panel beneath renders a diff, but that diff may hold nothing to act on: a - // binary file, or an empty commit. Rendering it worked that out, so the pane is - // already showing no selection and there is nowhere to put one. - if !self.ViewHasChangeLines(view) { + // The pane may hold nothing to act on: a diff of a binary file or an empty commit, + // or content that is no diff of the panel's at all. Rendering it worked that out, + // so the pane is already showing no selection and there is nowhere to put one. + if !mainContext.HasSelectableContent() { return } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 1e1a01427..72c99d2ad 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -727,7 +727,7 @@ func secondaryPatchPanelUpdateOpts(c *ControllerCommon) *types.ViewUpdateOpts { patch := c.Git().Patch.PatchBuilder.RenderAggregatedPatch(false) return &types.ViewUpdateOpts{ - Task: types.NewRenderStringWithoutScrollTask(patch), + Task: types.NewMainViewDiffStringTask(patch), Title: c.Tr.CustomPatch, } } diff --git a/pkg/gui/main_panels.go b/pkg/gui/main_panels.go index ba8e7c200..5db9d1925 100644 --- a/pkg/gui/main_panels.go +++ b/pkg/gui/main_panels.go @@ -66,6 +66,10 @@ func (gui *Gui) RefreshMainView(opts *types.ViewUpdateOpts, context types.Contex view.Subtitle = opts.SubTitle + if mainContext := gui.mainContextForView(view); mainContext != nil { + mainContext.SetContentIsDiff(types.ContentIsDiff(opts.Task)) + } + if err := gui.runTaskForView(view, opts.Task); err != nil { gui.c.Log.Error(err) } @@ -290,6 +294,9 @@ func (gui *Gui) clearMainView(mainContext types.Context) { view.Clear() view.SetOrigin(0, 0) mainContext.SetHasSelectableContent(false) + if pane := gui.mainContextForView(view); pane != nil { + pane.SetContentIsDiff(false) + } gui.State.ContextMgr.UpdateSelectionHighlights() if manager := gui.getViewBufferManagerForView(view); manager != nil { manager.ForgetRenderedContent() @@ -298,10 +305,10 @@ func (gui *Gui) clearMainView(mainContext types.Context) { // updateDiffSelectionVisibility works out whether a main pane holds anything for a // selection to sit on, from what it is now showing: only beneath a panel whose main -// view is a diff, and only while that diff holds something to select — never over a -// message like "No changed files", and never over a diff with nothing in it, such as a -// binary file's or an empty commit's. Whether the selection is then drawn, and drawn as -// the active one, follows from the context stack. +// view is a diff, only while the pane is showing that diff rather than a message like +// "No changed files", and only while the diff holds something to select — never over +// one with nothing in it, such as a binary file's or an empty commit's. Whether the +// selection is then drawn, and drawn as the active one, follows from the context stack. // // It is asked wherever the pane's content changes: as a string is rendered, at the // paint that reveals a command's output, with every further batch of that output, and @@ -379,6 +386,14 @@ func (gui *Gui) readOnUntilTheDiffPaneCanTell(view *gocui.View) { func (gui *Gui) diffPaneHasSomethingToSelect( mainContext *context.MainContext, view *gocui.View, contentIsComplete bool, ) (bool, bool) { + if !mainContext.ContentIsDiff() { + // The pane is holding something other than the panel's diff: a message, or the + // hint shown for a merge conflict that has to be resolved by picking a side, + // which explains itself with a diff of what the sides did. Whatever is in there + // is not ours to act on, so this needs no content to answer either. + return false, true + } + if _, showsDiff := gui.State.ContextMgr.CurrentSide().(types.DiffMainViewContext); !showsDiff { // Under a panel that shows no diff there is nothing to select whatever the pane // ends up holding, so this needs no content to answer. Answering it now matters, diff --git a/pkg/gui/types/rendering.go b/pkg/gui/types/rendering.go index 6f791d1bf..ac8932a9b 100644 --- a/pkg/gui/types/rendering.go +++ b/pkg/gui/types/rendering.go @@ -69,6 +69,9 @@ func NewRenderStringTask(str string) *RenderStringTask { type RenderStringWithoutScrollTask struct { Str string + + // contentIsDiff marks a string that is a panel's own diff; see ContentIsDiff. + contentIsDiff bool } func (t *RenderStringWithoutScrollTask) IsUpdateTask() {} @@ -77,6 +80,16 @@ func NewRenderStringWithoutScrollTask(str string) *RenderStringWithoutScrollTask return &RenderStringWithoutScrollTask{Str: str} } +// NewMainViewDiffStringTask returns the task for rendering a diff we hold as text +// rather than as a command to run — the custom patch being built, which we assemble +// ourselves. The view stays where it is, the patch being rendered again on every +// change to it. +func NewMainViewDiffStringTask(str string) UpdateTask { + task := NewRenderStringWithoutScrollTask(str) + task.contentIsDiff = true + return task +} + type RenderStringWithScrollTask struct { Str string OriginX int @@ -92,6 +105,9 @@ func NewRenderStringWithScrollTask(str string, originX int, originY int) *Render type RunCommandTask struct { Cmd *exec.Cmd Prefix string + + // contentIsDiff marks output that is a panel's own diff; see ContentIsDiff. + contentIsDiff bool } func (t *RunCommandTask) IsUpdateTask() {} @@ -107,6 +123,9 @@ func NewRunCommandTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunCommandTask { type RunDiffRendererTask struct { Cmd *exec.Cmd Prefix string + + // contentIsDiff marks output that is a panel's own diff; see ContentIsDiff. + contentIsDiff bool } func (t *RunDiffRendererTask) IsUpdateTask() {} @@ -123,13 +142,36 @@ func NewRunDiffRendererTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunDiffRend // normally goes through the diff renderer, however the render arranges to feed it. A // diff we are producing with git itself, because the renderer's version of it couldn't // be acted on, has to keep the renderer out, so it runs as a plain command instead. +// +// The task it returns is the one that says its output is a diff, so a pane rendering +// it can be pointed at (see ContentIsDiff). func NewMainViewDiffTask(cmd *exec.Cmd, mode git_commands.DiffMode) UpdateTask { return NewMainViewDiffTaskWithPrefix(cmd, "", mode) } func NewMainViewDiffTaskWithPrefix(cmd *exec.Cmd, prefix string, mode git_commands.DiffMode) UpdateTask { if mode == git_commands.DiffModeRaw { - return NewRunCommandTaskWithPrefix(cmd, prefix) + task := NewRunCommandTaskWithPrefix(cmd, prefix) + task.contentIsDiff = true + return task } - return NewRunDiffRendererTaskWithPrefix(cmd, prefix) + task := NewRunDiffRendererTaskWithPrefix(cmd, prefix) + task.contentIsDiff = true + return task +} + +// ContentIsDiff reports whether the given render fills a main pane with the diff a +// panel offers there, as opposed to a message, a commit log, or a diff that is part of +// an explanation. A selection means the lines of the panel's diff, so it is only over +// such a render that there is anything to point at. +func ContentIsDiff(task UpdateTask) bool { + switch task := task.(type) { + case *RenderStringWithoutScrollTask: + return task.contentIsDiff + case *RunCommandTask: + return task.contentIsDiff + case *RunDiffRendererTask: + return task.contentIsDiff + } + return false } diff --git a/pkg/integration/tests/main_view/no_selection_over_a_conflict_hint.go b/pkg/integration/tests/main_view/no_selection_over_a_conflict_hint.go new file mode 100644 index 000000000..45cd19ce2 --- /dev/null +++ b/pkg/integration/tests/main_view/no_selection_over_a_conflict_hint.go @@ -0,0 +1,52 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var NoSelectionOverAConflictHint = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The hint for a conflict that has to be resolved by picking a side shows no selection, diff and all", + 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) { + // The hint for a file deleted on one side and modified on the other explains + // itself with a diff of what the other side did. That diff is no more ours to + // stage than the words above it, so the pane holds nothing to point at. + t.Views().Files(). + IsFocused(). + NavigateToLine(Contains("DU bar")). + Tap(func() { + t.Views().Main(). + Content(Contains("Conflict: this file was deleted in the current changes")). + Content(Contains("Incoming changes:")). + Content(Contains("+2")) + }). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectionIsHidden(). + PressPrimaryAction(). + Tap(func() { + t.ExpectToast(Contains("There is nothing to select here")) + }) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 6add968cf..d8244e0d6 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -401,6 +401,7 @@ var tests = []*components.IntegrationTest{ main_view.NavigateByHunkAndFile, main_view.NoSelectionOverABinaryDiff, main_view.NoSelectionOverACommitLog, + main_view.NoSelectionOverAConflictHint, main_view.NoSelectionWhenNoChanges, main_view.RangeSelectDiffLines, main_view.RawFallbackUnderAnExternalDiff,