From 26b27ed69b13f29920acd3daf22ae0646653f3d7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 19 Jun 2026 12:51:35 +0200 Subject: [PATCH] Extract the patch-toggle back end out of CommitFilesController The next commit adds patch building from the commits / sub-commits / stash main views, which toggle the same way as the commit files main view; only the patch target and the post-toggle refresh differ. Pull the panel-agnostic back end (the toggle skeleton, the line toggle, the post-toggle reveal, the patch filename mapping) into shared free functions so both controllers reuse it. The skeleton now takes the patch target (from/to/reverse/canRebase) and a refresh callback as parameters rather than reading them from the commit files context and hardcoding Refresh({COMMIT_FILES}). The commit files handler passes exactly what it did before, so behaviour is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/commits_files_controller.go | 161 ++--------------- .../patch_building_from_main_view.go | 168 ++++++++++++++++++ 2 files changed, 178 insertions(+), 151 deletions(-) create mode 100644 pkg/gui/controllers/patch_building_from_main_view.go diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 77366bbce..629c9de04 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -593,164 +593,23 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName } // GetOnTogglePatchFocusedMainView toggles the selected diff line(s) into or out of the -// custom patch when space is pressed in the focused main view of a commit's files. It -// is the patch-building counterpart of the files panel's staging handler: it resolves -// the selection to change-line identities (shared with staging), maps them to the patch -// builder's per-file line indices, and adds or removes them. The commit's diff is -// unchanged by the toggle, but it refreshes so the browser indicator and the secondary -// patch view update (and the layout splits); the selection is re-established afterwards -// since that re-render (and its layout re-wrap) is in view-line space. +// custom patch when space is pressed in the focused main view of a commit's files. The +// per-file diff's patch target comes from the commit files context. It refreshes +// normally afterwards so the file's patch-status indicator in the browser updates along +// with the secondary patch view (the commits / sub-commits / stash panels, which build +// from the whole-commit diff, have no such indicator and refresh more cheaply). func (self *CommitFilesController) GetOnTogglePatchFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error { return func(mainViewName string, firstLineIdx int, lastLineIdx int) error { - infos := self.c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx) - if len(infos) == 0 { - return nil - } - - // Building a patch needs the patch builder started for this commit; if a patch - // for a different commit is active, confirm before discarding it (as entering - // the patch builder from the commit files panel does). from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding() - mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && - self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse) - return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{ - Title: self.c.Tr.DiscardPatch, - Prompt: self.c.Tr.DiscardPatchConfirm, - HandleConfirm: func() error { - if mustDiscardPatch { - self.c.Git().Patch.PatchBuilder.Reset() - } - if !self.c.Git().Patch.PatchBuilder.Active() { - if err := self.c.Helpers().CommitFiles.StartPatchBuilder(); err != nil { - return err - } - } - - if err := self.togglePatchLines(infos); err != nil { - return err - } - - // Refresh normally: this updates the file's patch-status indicator in - // the browser and the cumulative patch in the secondary view (splitting - // the layout when the patch first becomes non-empty), and re-renders this - // diff. The diff is unchanged, so the re-render keeps its scroll (same - // command) and the inclusion gutter is recomputed over it (see - // GetOnRenderToMain). + canRebase := self.context().GetCanRebase() + return togglePatchFromFocusedMainView(self.c, mainViewName, firstLineIdx, lastLineIdx, + from, to, reverse, canRebase, + func() { self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMIT_FILES}}) - - // The re-render — and the layout re-wrap when the secondary view first - // appears — is in view-line space, which moves the selection. Re-select - // the same content by its (unchanged, width-independent) change-line - // ordinal once the re-render lands. - self.revealSelectionAfterPatchToggle(mainViewName, firstLineIdx) - return nil - }, - }) + }) } } -// togglePatchLines toggles the change lines identified by infos into or out of the -// custom patch. The direction is decided once, from the first selected change line (in -// view order) — already in the patch means remove the whole selection, otherwise add it -// — and applied to every file the selection spans, mirroring how the patch explorer -// toggles a range. -func (self *CommitFilesController) togglePatchLines(infos []types.DiffLineInfo) error { - patchBuilder := self.c.Git().Patch.PatchBuilder - - // Group the selected change lines by file (in view order), then resolve each file's - // identities to patch-line indices in one pass. - var order []string - identitiesByFile := map[string][]patch.LineIdentity{} - for _, info := range infos { - filename := self.patchFilename(info.Path) - if filename == "" { - continue - } - if _, seen := identitiesByFile[filename]; !seen { - order = append(order, filename) - } - lineNumber, isDeletion := info.PatchSelectLine() - identitiesByFile[filename] = append(identitiesByFile[filename], - patch.LineIdentity{LineNumber: lineNumber, IsDeletion: isDeletion}) - } - if len(order) == 0 { - return nil - } - - indicesByFile := map[string][]int{} - for filename, identities := range identitiesByFile { - indices, err := patchBuilder.PatchLineIndicesForLines(filename, identities) - if err != nil { - return err - } - indicesByFile[filename] = indices - } - - // Decide the direction from the first selected change line. - firstFile := order[0] - included, err := patchBuilder.GetFileIncLineIndices(firstFile, "") - if err != nil { - return err - } - remove := len(indicesByFile[firstFile]) > 0 && lo.Contains(included, indicesByFile[firstFile][0]) - toggle := patchBuilder.AddFileLineRange - if remove { - toggle = patchBuilder.RemoveFileLineRange - } - - for _, filename := range order { - indices := indicesByFile[filename] - if len(indices) == 0 { - continue - } - if err := toggle(filename, "", indices); err != nil { - return err - } - } - return nil -} - -// revealSelectionAfterPatchToggle re-establishes the selection after a toggle's -// re-render. Toggling doesn't change the diff, so the same content is still there — but -// the re-render (and the layout re-wrap when the secondary view first appears) is in -// view-line space, which moves the selection. We preserve the selection's change-line -// ordinal, which is unchanged (the line isn't consumed, unlike staging) and -// width-independent, re-expanding the hunk in hunk mode. A range collapses to a line, -// as a staged range does. -func (self *CommitFilesController) revealSelectionAfterPatchToggle(mainViewName string, firstLineIdx int) { - mainContext := self.c.Contexts().Normal - if mainViewName == self.c.Contexts().NormalSecondary.GetViewName() { - mainContext = self.c.Contexts().NormalSecondary - } - view := mainContext.GetView() - - sel := mainContext.DiffSelectState() - if sel.Mode == context.DiffSelectModeRange { - sel.Mode = context.DiffSelectModeLine - sel.RangeIsSticky = false - } - mode := sel.Mode - - self.c.Helpers().Staging.RevealSelectionAfterStaging(view, view, firstLineIdx, func(viewLine int) { - if mode == context.DiffSelectModeHunk { - selectDiffHunk(self.c, mainContext, viewLine) - } else { - view.CancelRangeSelect() - showSelectionAtLine(view, viewLine, true) - } - }) -} - -// patchFilename maps a diff line's absolute path to the key the patch builder stores -// the file under — its repo-relative, slash-separated path. -func (self *CommitFilesController) patchFilename(absPath string) string { - relPath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), absPath) - if err != nil { - return "" - } - return filepath.ToSlash(relPath) -} - // pathsForDiff returns the file paths to use for a diff command. When a text // filter is active and the node is a directory, only the visible (filtered) // file paths are returned so the diff reflects what the user sees. diff --git a/pkg/gui/controllers/patch_building_from_main_view.go b/pkg/gui/controllers/patch_building_from_main_view.go new file mode 100644 index 000000000..880e51dcc --- /dev/null +++ b/pkg/gui/controllers/patch_building_from_main_view.go @@ -0,0 +1,168 @@ +package controllers + +import ( + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/commands/patch" + "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" +) + +// togglePatchFromFocusedMainView toggles the selected diff line(s) — a single line, a +// range, or a hunk — into or out of the custom patch being built for (from, to, +// reverse). It is the patch-building counterpart of the files panel's staging handler, +// shared by the panels that build a patch from a focused main view: the commit files +// panel (the per-file diff) and the commits / sub-commits / stash panels (the +// whole-commit diff). The selection is resolved to change-line identities (shared with +// staging) and mapped to the patch builder's per-file line indices. +// +// Building a patch needs the patch builder started for this target; if a patch for a +// different target is active, we confirm before discarding it (as entering the patch +// builder does). The diff itself is unchanged by a toggle, so refresh — supplied by the +// caller, since what needs re-rendering differs per panel — re-renders the same diff +// command (keeping its scroll) and the secondary patch view, and the inclusion gutter +// rides that re-render. That re-render (and its layout re-wrap when the secondary view +// first appears) is in view-line space, which moves the selection, so we re-establish it +// afterwards by its width-independent change-line ordinal. +func togglePatchFromFocusedMainView( + c *ControllerCommon, + mainViewName string, + firstLineIdx int, + lastLineIdx int, + from string, + to string, + reverse bool, + canRebase bool, + refresh func(), +) error { + infos := c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx) + if len(infos) == 0 { + return nil + } + + patchBuilder := c.Git().Patch.PatchBuilder + mustDiscardPatch := patchBuilder.Active() && patchBuilder.NewPatchRequired(from, to, reverse) + return c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{ + Title: c.Tr.DiscardPatch, + Prompt: c.Tr.DiscardPatchConfirm, + HandleConfirm: func() error { + if mustDiscardPatch { + patchBuilder.Reset() + } + if !patchBuilder.Active() { + patchBuilder.Start(from, to, reverse, canRebase) + } + + if err := togglePatchLines(c, infos); err != nil { + return err + } + + refresh() + revealSelectionAfterPatchToggle(c, mainViewName, firstLineIdx) + return nil + }, + }) +} + +// togglePatchLines toggles the change lines identified by infos into or out of the +// custom patch. The direction is decided once, from the first selected change line (in +// view order) — already in the patch means remove the whole selection, otherwise add it +// — and applied to every file the selection spans, mirroring how the patch explorer +// toggles a range. +func togglePatchLines(c *ControllerCommon, infos []types.DiffLineInfo) error { + patchBuilder := c.Git().Patch.PatchBuilder + + // Group the selected change lines by file (in view order), then resolve each file's + // identities to patch-line indices in one pass. + var order []string + identitiesByFile := map[string][]patch.LineIdentity{} + for _, info := range infos { + filename := patchFilename(c, info.Path) + if filename == "" { + continue + } + if _, seen := identitiesByFile[filename]; !seen { + order = append(order, filename) + } + lineNumber, isDeletion := info.PatchSelectLine() + identitiesByFile[filename] = append(identitiesByFile[filename], + patch.LineIdentity{LineNumber: lineNumber, IsDeletion: isDeletion}) + } + if len(order) == 0 { + return nil + } + + indicesByFile := map[string][]int{} + for filename, identities := range identitiesByFile { + indices, err := patchBuilder.PatchLineIndicesForLines(filename, identities) + if err != nil { + return err + } + indicesByFile[filename] = indices + } + + // Decide the direction from the first selected change line. + firstFile := order[0] + included, err := patchBuilder.GetFileIncLineIndices(firstFile, "") + if err != nil { + return err + } + remove := len(indicesByFile[firstFile]) > 0 && lo.Contains(included, indicesByFile[firstFile][0]) + toggle := patchBuilder.AddFileLineRange + if remove { + toggle = patchBuilder.RemoveFileLineRange + } + + for _, filename := range order { + indices := indicesByFile[filename] + if len(indices) == 0 { + continue + } + if err := toggle(filename, "", indices); err != nil { + return err + } + } + return nil +} + +// revealSelectionAfterPatchToggle re-establishes the selection after a toggle's +// re-render. Toggling doesn't change the diff, so the same content is still there — but +// the re-render (and the layout re-wrap when the secondary view first appears) is in +// view-line space, which moves the selection. We preserve the selection's change-line +// ordinal, which is unchanged (the line isn't consumed, unlike staging) and +// width-independent, re-expanding the hunk in hunk mode. A range collapses to a line, +// as a staged range does. +func revealSelectionAfterPatchToggle(c *ControllerCommon, mainViewName string, firstLineIdx int) { + mainContext := c.Contexts().Normal + if mainViewName == c.Contexts().NormalSecondary.GetViewName() { + mainContext = c.Contexts().NormalSecondary + } + view := mainContext.GetView() + + sel := mainContext.DiffSelectState() + if sel.Mode == context.DiffSelectModeRange { + sel.Mode = context.DiffSelectModeLine + sel.RangeIsSticky = false + } + mode := sel.Mode + + c.Helpers().Staging.RevealSelectionAfterStaging(view, view, firstLineIdx, func(viewLine int) { + if mode == context.DiffSelectModeHunk { + selectDiffHunk(c, mainContext, viewLine) + } else { + view.CancelRangeSelect() + showSelectionAtLine(view, viewLine, true) + } + }) +} + +// patchFilename maps a diff line's absolute path to the key the patch builder stores +// the file under — its repo-relative, slash-separated path. +func patchFilename(c *ControllerCommon, absPath string) string { + relPath, err := filepath.Rel(c.Git().RepoPaths.WorktreePath(), absPath) + if err != nil { + return "" + } + return filepath.ToSlash(relPath) +}