From 86ce3ba7984787ee1f85cdbe8c161598e684f457 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 19 Jun 2026 13:05:00 +0200 Subject: [PATCH] Build a custom patch from the commits / sub-commits / stash main views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pkg/gui/context/commit_files_context.go | 13 ++- pkg/gui/controllers/helpers/refresh_helper.go | 28 ++++-- .../controllers/local_commits_controller.go | 6 ++ pkg/gui/controllers/stash_controller.go | 6 ++ pkg/gui/controllers/sub_commits_controller.go | 6 ++ .../switch_to_diff_files_controller.go | 61 ++++++++++-- .../build_from_whole_commit_main_view.go | 96 +++++++++++++++++++ ..._multi_file_from_whole_commit_main_view.go | 90 +++++++++++++++++ .../reset_patch_built_from_main_view.go | 55 +++++++++++ pkg/integration/tests/test_list.go | 3 + 10 files changed, 343 insertions(+), 21 deletions(-) create mode 100644 pkg/integration/tests/patch_building/build_from_whole_commit_main_view.go create mode 100644 pkg/integration/tests/patch_building/build_multi_file_from_whole_commit_main_view.go create mode 100644 pkg/integration/tests/patch_building/reset_patch_built_from_main_view.go diff --git a/pkg/gui/context/commit_files_context.go b/pkg/gui/context/commit_files_context.go index 328e44173..fed245f41 100644 --- a/pkg/gui/context/commit_files_context.go +++ b/pkg/gui/context/commit_files_context.go @@ -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() } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 53505ba6c..09054ceab 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -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 { diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 642263d12..c9d1601ee 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -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{ diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 03011e421..27c7b78a9 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -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), }) }) } diff --git a/pkg/gui/controllers/sub_commits_controller.go b/pkg/gui/controllers/sub_commits_controller.go index d3d0c0b98..f2333765d 100644 --- a/pkg/gui/controllers/sub_commits_controller.go +++ b/pkg/gui/controllers/sub_commits_controller.go @@ -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), }) }) } diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index 0d19ae474..39733fb89 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -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 { diff --git a/pkg/integration/tests/patch_building/build_from_whole_commit_main_view.go b/pkg/integration/tests/patch_building/build_from_whole_commit_main_view.go new file mode 100644 index 000000000..57c1a200e --- /dev/null +++ b/pkg/integration/tests/patch_building/build_from_whole_commit_main_view.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/patch_building/build_multi_file_from_whole_commit_main_view.go b/pkg/integration/tests/patch_building/build_multi_file_from_whole_commit_main_view.go new file mode 100644 index 000000000..0dd5d1f08 --- /dev/null +++ b/pkg/integration/tests/patch_building/build_multi_file_from_whole_commit_main_view.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/patch_building/reset_patch_built_from_main_view.go b/pkg/integration/tests/patch_building/reset_patch_built_from_main_view.go new file mode 100644 index 000000000..a52a9ed9e --- /dev/null +++ b/pkg/integration/tests/patch_building/reset_patch_built_from_main_view.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 7823b75a0..7df2f6c41 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -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,