jesseduffield.lazygit/pkg/gui/controllers/switch_to_diff_files_controller.go
Stefan Haller f7e26fec4e Make the patch-building secondary pane's space remove the right lines, disable its discard
The focused-main-view rework made the secondary pane actionable, but the old
patch-building explorer's secondary was inert, so its actions were never thought
through. Pressing space there routed through the same toggle handler as the main
pane, resolving the selection against the secondary's diff and mapping it to
patch-builder indices by line number. But the secondary shows the *aggregated*
custom patch, which renumbers included additions whenever an earlier addition in
the same hunk is excluded (Transform recomputes each hunk's +start). So the
shifted number resolved to the wrong line in the original diff — often adding an
unrelated line instead of removing the selected one.

Resolve the secondary selection by its *ordinal* among the change lines shown
instead: the custom-patch view renders exactly the included change lines in
order, so the k-th change line of a file is that file's k-th included change line
(PatchBuilder.IncludedChangeLineIndices), independent of the renumbering. Space
in the secondary now only ever removes, mirroring how space in the staging view's
staged pane unstages.

Discarding from the commit (the remove key) makes no sense in the custom-patch
preview — it would act on lines shown only as the patch, and space already
removes them — so it's disabled there.

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

234 lines
7.8 KiB
Go

package controllers
import (
"path/filepath"
"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"
)
// This controller is for all contexts that contain commit files.
var _ types.IController = &SwitchToDiffFilesController{}
type CanSwitchToDiffFiles interface {
types.IListContext
CanRebase() bool
GetSelectedRef() models.Ref
GetSelectedRefRangeForDiffFiles() *types.RefRange
}
// Not using our ListControllerTrait because we have our own way of working with
// range selections that's different from ListControllerTrait's
type SwitchToDiffFilesController struct {
baseController
c *ControllerCommon
context CanSwitchToDiffFiles
}
func NewSwitchToDiffFilesController(
c *ControllerCommon,
context CanSwitchToDiffFiles,
) *SwitchToDiffFilesController {
return &SwitchToDiffFilesController{
baseController: baseController{},
c: c,
context: context,
}
}
func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{
{
Keys: opts.GetKeys(opts.Config.Universal.GoInto),
Handler: self.enter,
GetDisabledReason: self.canEnter,
Description: self.c.Tr.ViewItemFiles,
},
}
return bindings
}
func (self *SwitchToDiffFilesController) GetFocusedMainViewActions() types.FocusedMainViewActions {
return self
}
func (self *SwitchToDiffFilesController) OnClick(mainViewName string, clickedLineIdx int) error {
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
if !ok {
return nil
}
line, isDeletion := info.PatchSelectLine()
// Capture before self.enter() pushes the commit files panel, which
// re-renders the main view. We escape "all the way out" to this side
// panel (skipping the commit files panel), then focus the main view.
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context)
if err := self.enter(); err != nil {
return err
}
context := self.c.Contexts().CommitFiles
var node *filetree.CommitFileNode
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
if err != nil {
return err
}
relativePath = "./" + relativePath
context.CommitFileTreeViewModel.ExpandToPath(relativePath)
self.c.PostRefreshUpdate(context)
idx, ok := context.CommitFileTreeViewModel.GetIndexForPath(relativePath)
if !ok {
return nil
}
context.SetSelectedLineIdx(idx)
context.GetViewTrait().FocusPoint(
context.ModelIndexToViewIndex(idx), false)
node = context.GetSelected()
return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
}
func (self *SwitchToDiffFilesController) Context() types.Context {
return self.context
}
func (self *SwitchToDiffFilesController) GetOnDoubleClick() func() error {
return func() error {
if self.canEnter() == nil {
return self.enter()
}
return nil
}
}
func (self *SwitchToDiffFilesController) enter() error {
ref := self.context.GetSelectedRef()
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
commitFilesContext := self.c.Contexts().CommitFiles
canRebase := self.canRebase(ref, refsRange)
commitFilesContext.ClearFilter()
commitFilesContext.ReInit(ref, refsRange)
commitFilesContext.SetSelection(0)
commitFilesContext.SetCanRebase(canRebase)
commitFilesContext.SetParentContext(self.context)
commitFilesContext.SetWindowName(self.context.GetWindowName())
commitFilesContext.GetView().TitlePrefix = self.context.GetView().TitlePrefix
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.COMMIT_FILES},
Then: func() error {
if filterPath := self.c.Modes().Filtering.GetPath(); filterPath != "" {
path, err := filepath.Rel(self.c.Git().RepoPaths.RepoPath(), filterPath)
if err != nil {
path = filterPath
}
commitFilesContext.CommitFileTreeViewModel.SelectPath(
filepath.ToSlash(path), self.c.UserConfig().Gui.ShowRootItemInFileTree)
}
self.c.Context().Push(commitFilesContext, types.OnFocusOpts{})
return nil
},
})
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
}
// PrimaryAction 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) PrimaryAction(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 primaryPatchActionFromFocusedMainView(self.c, mainViewName, firstLineIdx, lastLineIdx,
from, to, reverse, canRebase,
func() {
self.c.OnUIThread(func() error {
self.c.PostRefreshUpdate(self.context)
return nil
})
})
}
// DiscardSelection removes the selected line(s) of the whole-commit diff from the commit,
// via a rebase. Same target derivation as the patch toggle; only enabled for local
// commits (see DiscardSelectionDisabledReason), so stash and other-branch sub-commits
// don't reach the rebase.
func (self *SwitchToDiffFilesController) DiscardSelection(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 discardSelectionFromCommit(self.c, mainViewName, firstLineIdx, lastLineIdx, from, to, reverse, canRebase)
}
func (self *SwitchToDiffFilesController) DiscardSelectionDisabledReason(mainViewName string) *types.DisabledReason {
canRebase := false
if ref := self.context.GetSelectedRef(); ref != nil {
canRebase = self.canRebase(ref, self.context.GetSelectedRefRangeForDiffFiles())
}
return discardFromCommitDisabledReason(self.c, mainViewName, canRebase)
}
func (self *SwitchToDiffFilesController) canEnter() *types.DisabledReason {
refRange := self.context.GetSelectedRefRangeForDiffFiles()
if refRange != nil {
return nil
}
ref := self.context.GetSelectedRef()
if ref == nil {
return &types.DisabledReason{Text: self.c.Tr.NoItemSelected}
}
if ref.RefName() == "" {
return &types.DisabledReason{Text: self.c.Tr.SelectedItemDoesNotHaveFiles}
}
return nil
}