mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-13 09:06:27 -04:00
Preserve the focused main view's selection across commit rewrites
Dropping a hunk with `d` re-establishes the focused main view's selection on the next surviving change, which feels great. But other operations that rewrite the commit under the focused main view — moving a custom patch out into the index, undoing right after a discard or a patch move — don't run through the focused-main-view action handlers, so nothing was preserving the selection. The stale gocui selection was left painted over the new content, often as a large, now-meaningless range. Rather than teach every such command to capture and restore the selection (move-patch, undo, redo, and any future one), the focused main view now preserves it itself, by its change-line ordinal, as the diff re-renders — the command-agnostic counterpart of revealSelectionAfterPrimaryAction. The diff side panels call it from their render-to-main before triggering the render, so the restore rides the re-render. It stands down unless the focused main view is current and shows a selection, no precise restore is already pending (escape / post-stage reveal / context-size place the selection more precisely), and the diff command is actually changing. That last gate matters: a plain background refresh re-renders the same commit's diff unchanged, and there the selection — range and all — must be left alone; only a command change (e.g. a rebase rewriting the commit's hash) means the content moved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
49e2fbc076
commit
fdb16a1c7e
|
|
@ -186,6 +186,10 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
|
|||
// recomputed over the current content stay valid through the swap.
|
||||
self.c.Helpers().Staging.RefreshInclusionGutter()
|
||||
|
||||
// Preserve the focused-main-view selection across a commit rewrite. See
|
||||
// LocalCommitsController.GetOnRenderToMain.
|
||||
preserveFocusedMainViewSelectionAcrossContentChange(self.c, task)
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
|
|
|
|||
|
|
@ -713,6 +713,11 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() {
|
|||
// the marks recomputed over the current content stay valid through the swap.
|
||||
self.c.Helpers().Staging.RefreshInclusionGutter()
|
||||
|
||||
// If this re-render is a commit rewrite under the focused main view (drop,
|
||||
// move-patch-out, undo, …), keep the selection on a surviving change rather
|
||||
// than leaving the stale range painted.
|
||||
preserveFocusedMainViewSelectionAcrossContentChange(self.c, task)
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
|
|
|
|||
|
|
@ -513,6 +513,67 @@ func revealSelectionAfterPrimaryAction(c *ControllerCommon, sourceViewName strin
|
|||
})
|
||||
}
|
||||
|
||||
// preserveFocusedMainViewSelectionAcrossContentChange keeps the focused main view's
|
||||
// selection sensible when its diff is about to re-render with *different* content — e.g. a
|
||||
// commit's diff shrinking after the custom patch built from it is moved into the index, or
|
||||
// the whole diff changing after an undo. Those mutations don't run through the
|
||||
// focused-main-view action handlers (which install their own reveal; see
|
||||
// revealSelectionAfterPrimaryAction), so without this the stale selection — often a large,
|
||||
// now-meaningless range — would be left painted over the new content.
|
||||
//
|
||||
// It is the command-agnostic counterpart of revealSelectionAfterPrimaryAction: rather than
|
||||
// teaching every mutating command to capture the selection, the focused main view preserves
|
||||
// it itself, by its change-line ordinal, as the diff re-renders. Call it from a diff side
|
||||
// panel's render-to-main with the task about to render into the main pane, before
|
||||
// triggering the render so the restore rides it.
|
||||
//
|
||||
// It stands down — leaving the selection exactly as is — unless all of these hold, so it
|
||||
// neither fights a more precise restore nor disturbs the selection needlessly:
|
||||
// - the focused main view is the current context and shows a selection;
|
||||
// - no precise restore is already pending (escape / post-stage reveal / context-size),
|
||||
// which places the selection more precisely than an ordinal can;
|
||||
// - the diff command is changing. A plain refresh re-renders the *same* commit's diff,
|
||||
// identical content under an identical command, and there the selection — range and
|
||||
// all — must be left alone; only a command change (e.g. a rebase rewriting the commit's
|
||||
// hash) means the content, and so the old selection's position, actually moved.
|
||||
func preserveFocusedMainViewSelectionAcrossContentChange(c *ControllerCommon, mainTask types.UpdateTask) {
|
||||
mainContext := c.Contexts().Normal
|
||||
if c.Context().Current() != mainContext {
|
||||
return
|
||||
}
|
||||
view := mainContext.GetView()
|
||||
if !view.Highlight {
|
||||
return
|
||||
}
|
||||
manager := c.GetViewBufferManagerForView(view)
|
||||
if manager == nil || manager.GetRestoreForNextTask() != nil {
|
||||
return
|
||||
}
|
||||
newKey, ok := diffTaskCommandKey(mainTask)
|
||||
if !ok || newKey == manager.GetTaskKey() {
|
||||
return
|
||||
}
|
||||
|
||||
// Anchor on the selection's first line, matching every revealSelectionAfterPrimaryAction
|
||||
// caller, so a range collapses to its top and the ordinal lands on the nearest surviving
|
||||
// change there.
|
||||
first, _ := view.SelectedLineRange()
|
||||
revealSelectionAfterPrimaryAction(c, mainContext.GetViewName(), mainContext.GetViewName(), first)
|
||||
}
|
||||
|
||||
// diffTaskCommandKey returns the buffer-manager task key a diff render task will register
|
||||
// under — the joined command line, as newCmdTask/newPtyTask derive it — or false for a
|
||||
// non-command task (a placeholder string), which has no command to compare.
|
||||
func diffTaskCommandKey(task types.UpdateTask) (string, bool) {
|
||||
switch t := task.(type) {
|
||||
case *types.RunCommandTask:
|
||||
return strings.Join(t.Cmd.Args, " "), true
|
||||
case *types.RunPtyTask:
|
||||
return strings.Join(t.Cmd.Args, " "), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (self *MainViewController) enter() error {
|
||||
if !self.context.GetView().Highlight {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -106,6 +106,10 @@ func (self *StashController) GetOnRenderToMain() func() {
|
|||
// built from this panel. See LocalCommitsController.GetOnRenderToMain.
|
||||
self.c.Helpers().Staging.RefreshInclusionGutter()
|
||||
|
||||
// Preserve the focused-main-view selection across a content change. See
|
||||
// LocalCommitsController.GetOnRenderToMain.
|
||||
preserveFocusedMainViewSelectionAcrossContentChange(self.c, task)
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ func (self *SubCommitsController) GetOnRenderToMain() func() {
|
|||
// built from this panel. See LocalCommitsController.GetOnRenderToMain.
|
||||
self.c.Helpers().Staging.RefreshInclusionGutter()
|
||||
|
||||
// Preserve the focused-main-view selection across a commit rewrite. See
|
||||
// LocalCommitsController.GetOnRenderToMain.
|
||||
preserveFocusedMainViewSelectionAcrossContentChange(self.c, task)
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
package patch_building
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var KeepSelectionAfterMovingPatchOutMainView = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Moving a custom patch out of a commit from the focused main view re-establishes the (stale, multi-line) selection on a surviving change rather than leaving it painted over the shrunk diff",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("first commit")
|
||||
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("commit to move a patch out of")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("commit to move a patch out of").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
// Focus the commit's diff straight from the commits panel, rather than
|
||||
// entering the commit files panel first.
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("+one"),
|
||||
).
|
||||
// Toggle just the first line into a custom patch, then leave a multi-line
|
||||
// range selected — the patch move below doesn't go through the focused-main-
|
||||
// view action handlers, so without the preserve net this stale range would be
|
||||
// left painted over the shrunk diff.
|
||||
PressPrimaryAction().
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
NavigateToLine(Contains("+four")).
|
||||
SelectedLines(
|
||||
Contains("+one"),
|
||||
Contains("+two"),
|
||||
Contains("+three"),
|
||||
Contains("+four"),
|
||||
)
|
||||
|
||||
t.Common().SelectPatchOption(Contains("Move patch out into index"))
|
||||
|
||||
// The moved line ('one') is gone from the commit, and the stale multi-line range
|
||||
// collapses to a single surviving change at the selection's top ordinal (now the
|
||||
// 'two' line) — just like discarding from the commit does.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Content(DoesNotContain("+one")).
|
||||
ContainsLines(
|
||||
Equals("+two"),
|
||||
Equals("+three"),
|
||||
Equals("+four"),
|
||||
Equals("+five"),
|
||||
).
|
||||
SelectedLines(
|
||||
Contains("+two"),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
|
@ -369,6 +369,7 @@ var tests = []*components.IntegrationTest{
|
|||
patch_building.DiscardLinesFromCommit,
|
||||
patch_building.DiscardLinesFromCommitMainView,
|
||||
patch_building.EditLineInPatchBuildingPanel,
|
||||
patch_building.KeepSelectionAfterMovingPatchOutMainView,
|
||||
patch_building.MoveRangeToIndex,
|
||||
patch_building.MoveToEarlierCommit,
|
||||
patch_building.MoveToEarlierCommitFromAddedFile,
|
||||
|
|
@ -540,6 +541,7 @@ var tests = []*components.IntegrationTest{
|
|||
undo.UndoCheckoutAndDrop,
|
||||
undo.UndoCommit,
|
||||
undo.UndoDrop,
|
||||
undo.UndoKeepsFocusedMainViewSelection,
|
||||
worktree.AddForExistingBranch,
|
||||
worktree.AddFromBranch,
|
||||
worktree.AddFromBranchDetached,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
package undo
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var UndoKeepsFocusedMainViewSelection = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Undoing a commit rewrite while focused in the main view re-establishes the (stale, multi-line) selection on a surviving change rather than leaving it painted over the changed diff",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("first commit")
|
||||
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("commit to rewrite")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("commit to rewrite").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
// Focus the commit's diff straight from the commits panel.
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// Discard the first line from the commit; the selection advances to the next
|
||||
// surviving change ('two').
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("+one"),
|
||||
).
|
||||
Press(keys.Universal.Remove)
|
||||
|
||||
t.ExpectPopup().Confirmation().
|
||||
Title(Equals("Discard lines from commit")).
|
||||
Content(Equals("Are you sure you want to discard the selected lines from this commit?")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Content(DoesNotContain("+one")).
|
||||
SelectedLines(
|
||||
Contains("+two"),
|
||||
).
|
||||
// Leave a multi-line range selected before undoing — undo rewrites the commit
|
||||
// outside the focused-main-view action handlers, so without the preserve net
|
||||
// this range would be left stale over the restored diff.
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
NavigateToLine(Contains("+four")).
|
||||
SelectedLines(
|
||||
Contains("+two"),
|
||||
Contains("+three"),
|
||||
Contains("+four"),
|
||||
)
|
||||
|
||||
// Undo is a global keybinding, so it fires while the main view holds focus.
|
||||
t.GlobalPress(keys.Universal.Undo)
|
||||
|
||||
t.ExpectPopup().Confirmation().
|
||||
Title(Equals("Undo")).
|
||||
Content(MatchesRegexp(`Are you sure you want to hard reset to '.*'\?`)).
|
||||
Confirm()
|
||||
|
||||
// The discarded line is back, and the stale multi-line range collapses to a
|
||||
// single surviving change at the selection's top ordinal (the first change line,
|
||||
// now 'one' again) rather than spanning arbitrary lines of the restored diff.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
ContainsLines(
|
||||
Equals("+one"),
|
||||
Equals("+two"),
|
||||
Equals("+three"),
|
||||
Equals("+four"),
|
||||
Equals("+five"),
|
||||
).
|
||||
SelectedLines(
|
||||
Contains("+one"),
|
||||
)
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue