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,