jesseduffield.lazygit/pkg/gui/controllers/patch_building_from_main_view.go
Stefan Haller 96b09bb41d Push the staging focus-follow into the side-panel handler
The focused main view's space binding delegates to the side panel beneath
it, but the two handlers were asymmetric: the patch-toggle handler did its
own re-render and re-established the selection itself, while the staging
handler returned a focusViewName so the dispatcher (stageRange) could do the
reveal-and-focus dance on its behalf. That split blocks collapsing the
per-command handler channels into one, since the two have different return
types.

Make staging match the toggle: GetOnStageFocusedMainView returns plain
error, and the FilesController handler does the post-staging reveal and pane
focus itself. The reveal/select-mode logic the two handlers shared (collapse
a range to a line, preserve the change-line ordinal across the re-render,
re-expand a hunk) is extracted into revealSelectionAfterPrimaryAction, with
mainContextForViewName resolving a main view name to its context. The
dispatcher is now uniform: read the selected range, hand it to whichever
handler the panel registered.

Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00

140 lines
4.9 KiB
Go

package controllers
import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"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()
// A toggle doesn't change the diff, so source and target are the same pane;
// the re-render (and the layout re-wrap when the secondary view first appears)
// still moves the selection in view-line space, so re-establish it.
revealSelectionAfterPrimaryAction(c, mainViewName, 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
}
// 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)
}