Build a custom patch from the commits / sub-commits / stash main views

Until now space toggled lines into a custom patch only from the commit files
main view (the per-file diff). Register the same toggle handler on the commits,
sub-commits and stash panels, so a patch can be built straight from the
whole-commit (multi-file) diff their main view shows, without first diving into
the commit files panel.

The handler lives on SwitchToDiffFilesController, which is already bound to
exactly those three panels and already knows how to derive the patch target
(from/to/reverse/canRebase) for the selected ref — pulled out of enter() into a
shared canRebase helper and the reused FromAndToForDiff. The post-toggle refresh
is the cheap one: these panels have no per-file patch indicator to update, so we
just re-render their own main + secondary views rather than reloading the whole
commit list on every keystroke.

Sub-commits and stash didn't render the secondary patch view at all; give them
the same secondaryPatchPanelUpdateOpts the commits panel uses, and recompute the
inclusion gutter as their diff (re-)renders, so the cumulative patch and the
gutter track the toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-19 13:05:00 +02:00
parent 26b27ed69b
commit 86ce3ba798
10 changed files with 343 additions and 21 deletions

View file

@ -83,10 +83,17 @@ func (self *CommitFilesContext) RefForAdjustingLineNumberInDiff() string {
}
func (self *CommitFilesContext) GetFromAndToForDiff() (string, string) {
if refs := self.GetRefRange(); refs != nil {
return refs.From.ParentRefName(), refs.To.RefName()
return FromAndToForDiff(self.GetRef(), self.GetRefRange())
}
// FromAndToForDiff derives the diff endpoints for a ref (or a range of refs): a range
// diffs its parent-of-from against to, a single ref its parent against itself. It's
// shared by the commit files context and by patch building straight from the commits /
// sub-commits / stash main views, which build a patch for the panel's selected ref.
func FromAndToForDiff(ref models.Ref, refRange *types.RefRange) (string, string) {
if refRange != nil {
return refRange.From.ParentRefName(), refRange.To.RefName()
}
ref := self.GetRef()
return ref.ParentRefName(), ref.RefName()
}

View file

@ -371,11 +371,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway
if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) {
var capturedCommitFiles capturedCommitFilesState
var capturedCommitFiles *capturedCommitFilesState
self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedCommitFiles = self.captureCommitFilesState()
})
refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) })
if capturedCommitFiles != nil {
refresh("commit files", func() { _ = self.refreshCommitFilesContext(*capturedCommitFiles, env) })
}
}
fileWg := sync.WaitGroup{}
@ -752,10 +754,12 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS
// Capture the diff endpoints here, on the UI thread and after
// ReInit has set them, before dispatching the git work.
capturedCommitFiles := self.captureCommitFilesState()
self.onWorker(env.background, func(gocui.Task) error {
_ = self.refreshCommitFilesContext(capturedCommitFiles, env)
return nil
})
if capturedCommitFiles != nil {
self.onWorker(env.background, func(gocui.Task) error {
_ = self.refreshCommitFilesContext(*capturedCommitFiles, env)
return nil
})
}
}
})
}
@ -1013,10 +1017,18 @@ type capturedCommitFilesState struct {
// captureCommitFilesState reads the commit-files refresh's diff endpoints into
// an immutable snapshot. It must run on the UI thread.
func (self *RefreshHelper) captureCommitFilesState() capturedCommitFilesState {
func (self *RefreshHelper) captureCommitFilesState() *capturedCommitFilesState {
// The commit files context may have no ref associated with it yet — e.g. a custom
// patch was started straight from the commits panel's main view without ever entering
// the commit files panel. There are then no commit files to load (and deriving a diff
// range from a nil ref would panic), so there's nothing to refresh.
if self.c.Contexts().CommitFiles.GetRef() == nil && self.c.Contexts().CommitFiles.GetRefRange() == nil {
return nil
}
from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
return capturedCommitFilesState{from: from, to: to, reverse: reverse}
return &capturedCommitFilesState{from: from, to: to, reverse: reverse}
}
func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, env refreshEnv) error {

View file

@ -706,6 +706,12 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() {
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange)
}
// Keep the inclusion gutter in step with the content as this diff
// (re-)renders. It's a no-op unless the main view is focused and a patch is
// being built from this panel; a patch toggle re-renders this same diff, so
// the marks recomputed over the current content stay valid through the swap.
self.c.Helpers().Staging.RefreshInclusionGutter()
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{

View file

@ -99,6 +99,11 @@ func (self *StashController) GetOnRenderToMain() func() {
)
}
// Keep the inclusion gutter in step with the content as this diff
// (re-)renders; a no-op unless the main view is focused and a patch is being
// built from this panel. See LocalCommitsController.GetOnRenderToMain.
self.c.Helpers().Staging.RefreshInclusionGutter()
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
@ -106,6 +111,7 @@ func (self *StashController) GetOnRenderToMain() func() {
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: task,
},
Secondary: secondaryPatchPanelUpdateOpts(self.c),
})
})
}

View file

@ -49,6 +49,11 @@ func (self *SubCommitsController) GetOnRenderToMain() func() {
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange)
}
// Keep the inclusion gutter in step with the content as this diff
// (re-)renders; a no-op unless the main view is focused and a patch is being
// built from this panel. See LocalCommitsController.GetOnRenderToMain.
self.c.Helpers().Staging.RefreshInclusionGutter()
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
@ -56,6 +61,7 @@ func (self *SubCommitsController) GetOnRenderToMain() func() {
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: task,
},
Secondary: secondaryPatchPanelUpdateOpts(self.c),
})
})
}

View file

@ -5,6 +5,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@ -112,16 +113,7 @@ func (self *SwitchToDiffFilesController) enter() error {
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
commitFilesContext := self.c.Contexts().CommitFiles
canRebase := self.context.CanRebase()
if canRebase {
if self.c.Modes().Diffing.Active() {
if self.c.Modes().Diffing.Ref != ref.RefName() {
canRebase = false
}
} else if refsRange != nil {
canRebase = false
}
}
canRebase := self.canRebase(ref, refsRange)
commitFilesContext.ClearFilter()
commitFilesContext.ReInit(ref, refsRange)
@ -149,6 +141,55 @@ func (self *SwitchToDiffFilesController) enter() error {
return nil
}
// canRebase reports whether patches built from the selected ref may modify commits —
// true only for commits of the currently checked-out branch, and not while diffing a
// different ref or over a range. Shared by entering the commit files panel and toggling
// patch lines straight from the main view.
func (self *SwitchToDiffFilesController) canRebase(ref models.Ref, refsRange *types.RefRange) bool {
canRebase := self.context.CanRebase()
if canRebase {
if self.c.Modes().Diffing.Active() {
if self.c.Modes().Diffing.Ref != ref.RefName() {
canRebase = false
}
} else if refsRange != nil {
canRebase = false
}
}
return canRebase
}
// GetOnTogglePatchFocusedMainView toggles the selected line(s) of the whole-commit diff
// into or out of the custom patch when space is pressed in the focused main view of the
// commits / sub-commits / stash panels. The patch target is the panel's selected ref (or
// range), matching the diff the main view shows. Unlike the commit files panel there are
// no per-file patch indicators to update, so the toggle refreshes cheaply: it re-renders
// just this panel's main + secondary views (leaving the commit list untouched, which a
// list refresh would needlessly reload on every keystroke), re-running the same diff
// command (scroll preserved) and repainting the inclusion gutter.
func (self *SwitchToDiffFilesController) GetOnTogglePatchFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error {
return func(mainViewName string, firstLineIdx int, lastLineIdx int) error {
ref := self.context.GetSelectedRef()
if ref == nil {
return nil
}
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
from, to := context.FromAndToForDiff(ref, refsRange)
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
canRebase := self.canRebase(ref, refsRange)
return togglePatchFromFocusedMainView(self.c, mainViewName, firstLineIdx, lastLineIdx,
from, to, reverse, canRebase,
func() {
self.c.OnUIThread(func() error {
self.c.PostRefreshUpdate(self.context)
return nil
})
})
}
}
func (self *SwitchToDiffFilesController) canEnter() *types.DisabledReason {
refRange := self.context.GetSelectedRefRangeForDiffFiles()
if refRange != nil {

View file

@ -0,0 +1,96 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var BuildFromWholeCommitMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Build a custom patch from the whole-commit diff in a commits panel's focused main view (without entering the commit files), then apply it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.NewBranch("branch-a")
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.CreateFileAndAdd("file2", "alpha\nbeta\ngamma\ndelta\n")
shell.Commit("first commit")
// One commit touching two files, so the whole-commit diff is multi-file.
shell.NewBranch("branch-b")
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\n")
shell.UpdateFileAndAdd("file2", "alpha\nBETA\ngamma\ndelta\n")
shell.Commit("update")
shell.Checkout("branch-a")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Lines(
Contains("branch-a").IsSelected(),
Contains("branch-b"),
).
Press(keys.Universal.NextItem).
PressEnter()
// Focus the whole-commit diff straight from the sub-commits panel, rather than
// entering the commit files panel first.
t.Views().SubCommits().
IsFocused().
Lines(
Contains("update").IsSelected(),
Contains("first commit"),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
// The selection anchors on the first change line of the multi-file diff,
// which belongs to file1.
SelectedLines(
Contains("-three"),
).
// `a` extends to the whole change block, then space toggles just file1's block
// into the custom patch; file2's change, also in this diff, stays out.
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-three"),
Contains("+THREE"),
).
PressPrimaryAction().
// The selection is re-established on the same block after the toggle's
// re-render (which, when the secondary view first appears, re-wraps the
// narrower diff).
SelectedLines(
Contains("-three"),
Contains("+THREE"),
)
t.Views().Information().Content(Contains("Building patch"))
// The secondary view shows the cumulative patch live — only file1's toggled
// block, not file2's change from the same commit.
t.Views().Secondary().
ContainsLines(
Contains("-three"),
Contains("+THREE"),
).
Content(DoesNotContain("BETA"))
t.Common().SelectPatchOption(MatchesRegexp(`Apply patch$`))
// Only file1's toggled block reached the working tree; file2 is untouched.
t.Views().Files().
Focus().
Lines(
Contains("file1").IsSelected(),
)
t.Views().Main().
Content(Contains("THREE")).
Content(DoesNotContain("BETA"))
},
})

View file

@ -0,0 +1,90 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var BuildMultiFileFromWholeCommitMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Build a custom patch spanning two files from a commit's whole-commit diff in the focused main view, by toggling each file's hunk, then apply it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
},
SetupRepo: func(shell *Shell) {
shell.NewBranch("branch-a")
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.CreateFileAndAdd("file2", "alpha\nbeta\ngamma\ndelta\n")
shell.Commit("first commit")
shell.NewBranch("branch-b")
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\n")
shell.UpdateFileAndAdd("file2", "alpha\nBETA\ngamma\ndelta\n")
shell.Commit("update")
shell.Checkout("branch-a")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Press(keys.Universal.NextItem).
PressEnter()
t.Views().SubCommits().
IsFocused().
Lines(
Contains("update").IsSelected(),
Contains("first commit"),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
// Hunk mode is the default here, so the first change block of the multi-file
// diff (file1's) is selected on focus.
SelectedLines(
Contains("-three"),
Contains("+THREE"),
).
PressPrimaryAction().
SelectedLines(
Contains("-three"),
Contains("+THREE"),
).
// Move to the next change block, which is in file2, and toggle it in too.
Press(keys.Universal.NextItem).
SelectedLines(
Contains("-beta"),
Contains("+BETA"),
).
PressPrimaryAction().
SelectedLines(
Contains("-beta"),
Contains("+BETA"),
)
// The cumulative patch now spans both files.
t.Views().Secondary().
ContainsLines(
Contains("-three"),
Contains("+THREE"),
).
ContainsLines(
Contains("-beta"),
Contains("+BETA"),
)
t.Common().SelectPatchOption(MatchesRegexp(`Apply patch$`))
// Both files' changes reached the working tree.
t.Views().Files().
Focus().
ContainsLines(
Contains("file1"),
).
ContainsLines(
Contains("file2"),
)
},
})

View file

@ -0,0 +1,55 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ResetPatchBuiltFromMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Reset a custom patch that was built straight from the commits panel's main view, without ever having entered the commit files panel",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.NewBranch("branch-a")
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("first commit")
shell.NewBranch("branch-b")
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\n")
shell.Commit("update")
shell.Checkout("branch-a")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Press(keys.Universal.NextItem).
PressEnter()
// Build a patch straight from the whole-commit diff, never entering the commit
// files panel — so its context is never set up with a ref for this commit.
t.Views().SubCommits().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Main.ToggleSelectHunk).
PressPrimaryAction()
t.Views().Information().Content(Contains("Building patch"))
// Resetting the patch from the menu refreshes the (never-initialised) commit files
// context; this used to crash on its nil ref.
t.Views().Main().Press(keys.Universal.CreatePatchOptionsMenu)
t.ExpectPopup().Menu().
Title(Equals("Patch options")).
Select(Contains("Reset patch")).
Confirm()
t.Views().Information().Content(DoesNotContain("Building patch"))
},
})

View file

@ -362,6 +362,8 @@ var tests = []*components.IntegrationTest{
patch_building.ApplyWithModifiedFileConflict,
patch_building.ApplyWithModifiedFileNoConflict,
patch_building.BuildFromMainView,
patch_building.BuildFromWholeCommitMainView,
patch_building.BuildMultiFileFromWholeCommitMainView,
patch_building.CopyRenamedFileDiff,
patch_building.DiscardLinesFromCommit,
patch_building.EditLineInPatchBuildingPanel,
@ -388,6 +390,7 @@ var tests = []*components.IntegrationTest{
patch_building.RenameSimilarityThresholdChange,
patch_building.RenamedFilePartial,
patch_building.RenamedFileWhole,
patch_building.ResetPatchBuiltFromMainView,
patch_building.ResetWithEscape,
patch_building.SelectAllFiles,
patch_building.SelectDirecoriesSharingPrefix,