Add identity-based line accessors to PatchBuilder

The patch builder works in patch-line-index space, which the patch
explorer can supply directly from its parsed-patch state. The focused
main view can't: it only knows a selection as diff-line metadata
identities (file line number + deletion?), and those indices differ
between the raw diff and however a pager renders it.

Add the conversion at the boundary so the main view stays in identity
space: PatchLineIndicesForLines maps identities to indices (for
toggling) and IncludedLineIdentities reports the included change lines
as identities (for the inclusion gutter to match rendered rows against).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-19 09:27:45 +02:00
parent 12523c8a20
commit 4c123f9414
2 changed files with 122 additions and 0 deletions

View file

@ -299,6 +299,76 @@ func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath strin
return info.includedLineIndices, nil
}
// LineIdentity identifies a change line (an addition or deletion) by its file line
// number and whether it's a deletion, independently of the line's index in the parsed
// patch. It is the identity the diff-line metadata resolves a rendered row to (see
// types.DiffLineInfo.PatchSelectLine), and lets the focused main view toggle patch
// membership and drive the inclusion gutter without dealing in patch-line indices —
// which differ between the raw diff and however a pager renders it.
type LineIdentity struct {
LineNumber int
IsDeletion bool
}
// changeLineIndexByIdentity scans a parsed diff and returns, for each change line, its
// index in the patch keyed by the line's identity. An addition is keyed by its new-file
// line number, a deletion by its old-file line number, so each change line has a
// distinct identity (two consecutive deletions share a new-file number but differ in
// the old-file one).
func changeLineIndexByIdentity(parsed *Patch) map[LineIdentity]int {
byIdentity := map[LineIdentity]int{}
for idx, line := range parsed.Lines() {
switch {
case line.IsAddition():
byIdentity[LineIdentity{parsed.LineNumberOfLine(idx), false}] = idx
case line.IsDeletion():
byIdentity[LineIdentity{parsed.OldLineNumberOfLine(idx), true}] = idx
}
}
return byIdentity
}
// PatchLineIndicesForLines maps the given change-line identities to their indices in
// filename's parsed diff — the index form that AddFileLineRange / RemoveFileLineRange
// and GetFileIncLineIndices work in. Identities that don't correspond to a change line
// (e.g. a context line) are skipped. It is how the focused main view, which knows a
// selection only as metadata identities, drives patch building.
func (p *PatchBuilder) PatchLineIndicesForLines(filename string, lines []LineIdentity) ([]int, error) {
info, err := p.getFileInfo(filename, "")
if err != nil {
return nil, err
}
byIdentity := changeLineIndexByIdentity(Parse(info.diff))
indices := make([]int, 0, len(lines))
for _, line := range lines {
if idx, ok := byIdentity[line]; ok {
indices = append(indices, idx)
}
}
return indices, nil
}
// IncludedLineIdentities returns the identities of the change lines currently included
// in the patch for filename — the identity space the inclusion gutter matches rendered
// rows against. Empty when the file isn't part of the patch.
func (p *PatchBuilder) IncludedLineIdentities(filename string) []LineIdentity {
info, ok := p.fileInfoMap[filename]
if !ok || info.mode == UNSELECTED {
return nil
}
includedIdx := make(map[int]bool, len(info.includedLineIndices))
for _, idx := range info.includedLineIndices {
includedIdx[idx] = true
}
var identities []LineIdentity
for identity, idx := range changeLineIndexByIdentity(Parse(info.diff)) {
if includedIdx[idx] {
identities = append(identities, identity)
}
}
return identities
}
// clears the patch
func (p *PatchBuilder) Reset() {
p.mutex.Lock()

View file

@ -0,0 +1,52 @@
package patch
import (
"testing"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
// newTestPatchBuilder returns a PatchBuilder whose files all resolve to the given
// diff, started for a dummy commit.
func newTestPatchBuilder(diff string) *PatchBuilder {
pb := NewPatchBuilder(logrus.New().WithField("test", "test"),
func(from, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
return diff, nil
})
pb.Start("from", "to", false, true)
return pb
}
// In simpleDiff the deletion "-orange" is patch line index 6 (old-file line 2) and the
// addition "+grape" is index 7 (new-file line 2).
func TestPatchLineIndicesForLines(t *testing.T) {
pb := newTestPatchBuilder(simpleDiff)
indices, err := pb.PatchLineIndicesForLines("filename", []LineIdentity{
{LineNumber: 2, IsDeletion: true}, // -orange
{LineNumber: 2, IsDeletion: false}, // +grape
{LineNumber: 1, IsDeletion: false}, // " apple" — a context line, no change index
})
assert.NoError(t, err)
assert.Equal(t, []int{6, 7}, indices, "context-line identity is skipped; change lines map to their indices")
}
func TestIncludedLineIdentities(t *testing.T) {
pb := newTestPatchBuilder(simpleDiff)
// Nothing included yet.
assert.Empty(t, pb.IncludedLineIdentities("filename"))
// Include only the deletion: it comes back as its identity, the addition does not.
assert.NoError(t, pb.AddFileLineRange("filename", "", []int{6}))
assert.Equal(t,
[]LineIdentity{{LineNumber: 2, IsDeletion: true}},
pb.IncludedLineIdentities("filename"))
// Including the addition too yields both identities (order-independent).
assert.NoError(t, pb.AddFileLineRange("filename", "", []int{7}))
assert.ElementsMatch(t,
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
pb.IncludedLineIdentities("filename"))
}