jesseduffield.lazygit/pkg/gui/controllers/switch_to_diff_files_controller.go
Stefan Haller cf8e5fd27e Recover diff-line identity by parsing the buffer, behind a swappable seam
The focused main view's click/enter/e/G handlers all need the same thing: given
a rendered diff row, the patch-space line it corresponds to. Until now that came
solely from delta's lazygit-edit:// hyperlinks, which only carry a path and a
single line number — no side. That's lossy: for a deletion the number is the old
line, but the consumers fed it into new-file lookups, and two consecutive
deletions (which share a new-file line number) couldn't be told apart at all.

Replace GetFileAndLineForClickedDiffLine with GetDiffLineInfo, returning the
fuller (file, type, new-line, old-line) record from diff-line-metadata-notes.md.
This is mechanism #1: parse the decolorized view buffer — walk up to the file's
"diff --git" section, reuse patch.Parse on it (splitting multi-file commit diffs
on the "diff --git" boundaries), and read the type and line numbers off the patch
arithmetic. It serves the structure-preserving renderings — no pager, git diff
--color, and delta --color-only without line numbers — with no external
dependency.

To avoid trusting a mis-parse, the parser bails when a hunk's body no longer
matches its header (Patch.IsWellFormed). That's what happens when a pager keeps
the diff/hunk headers but restructures the body: delta's line-number gutters push
the +/- marker off the start of each line, so every body line reads as context.
Such renderings fall through to the next backend rather than yielding a confident
wrong answer. (diff-so-fancy goes further and rewrites the headers too, so it
fails even earlier, on the missing "diff --git".)

GetDiffLineInfo is a seam with swappable backends: the buffer parser first, then
the old hyperlink reader as a fallback for renderings the parser can't handle
(delta's default mode, or delta with line-number gutters). The future #2 OSC
per-cell metadata reader plugs in ahead of both, behind the same record shape.

Wire the consumers to the record per that doc's field mapping:
- dive into staging/patch building lands on the exact patch line, looking a
  deletion up by its old-file line number (PatchLineForOldLineNumber) so the
  two-deletions case resolves correctly;
- `e` edits at the new-file line;
- `G` anchors the PR link on the left (old) side for a deletion, the right (new)
  side otherwise.

The hyperlink fallback can't convey the side, so it reports DiffLineOther, which
the consumers treat as a non-deletion — i.e. exactly today's behavior.

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

167 lines
4.8 KiB
Go

package controllers
import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"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) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return func(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, clickedLineIdx)
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.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
}
}
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
}
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
}