Stage both sides of a side-by-side row from the focused main view

A side-by-side rendering (patched delta in side-by-side mode) carries more than
one diff-line record per rendered row: the deletion on the left, the addition
replacing it on the right. DiffLineContent.Metadata keeps only the first payload
— enough to identify a single-column row, but it drops the right side — so a
range staged from such a view would miss half the changes.

Add a gocui accessor for all the distinct metadata payloads a row's cells carry,
and have the range collector resolve every one of them, so staging a
side-by-side row includes both sides. You can't stage just one side of a
side-by-side row; that's an accepted restriction (switch to a single-column
rendering to do it). Rows without metadata (no pager, or the buffer-parse /
hyperlink backends) keep falling back to their single resolved record, so
single-column staging is unchanged.

The full delta-side-by-side chain can't run in the integration harness (no real
pager), so it's covered by a gocui unit test for the multi-payload accessor;
end-to-end behaviour needs interactive verification with the patched delta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-18 13:40:09 +02:00
parent a5a0506913
commit 4a33b42d19
3 changed files with 63 additions and 1 deletions

View file

@ -1832,6 +1832,33 @@ func (v *View) DiffLineMetadataInLine(y int) (string, bool) {
return "", false
}
// DiffLineMetadataPayloads returns, per unwrapped buffer line, the distinct
// OSC-1717 metadata payloads carried by that line's cells, in left-to-right order.
// A single-column rendering tags every cell of a line with the same payload (one
// entry); a side-by-side rendering tags each side differently, so a changed row
// yields one payload per side (and a context row, where both sides match, still
// one). It is the multi-record counterpart of DiffLineContent.Metadata, which keeps
// only the first payload — enough to identify a single-column row, but it drops the
// other side of a side-by-side row. Staging a selection uses this to act on every
// change a row covers. Taken under the write lock in one pass so the payloads stay
// consistent with the buffer even if a concurrent re-render is rebuilding it.
func (v *View) DiffLineMetadataPayloads() [][]string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
result := make([][]string, len(v.buf.lines))
for i, line := range v.buf.lines {
var payloads []string
for _, c := range line.cells {
if c.metadata != "" && !slices.Contains(payloads, c.metadata) {
payloads = append(payloads, c.metadata)
}
}
result[i] = payloads
}
return result
}
// DiffLineContent is the raw per-line material the diff-line backends parse to
// recover a rendered row's patch-space identity (see diff-line-metadata-notes.md):
// the decolorized text (for host-side parsing, mechanism #1), the OSC-1717

View file

@ -196,6 +196,27 @@ func TestDiffLineMetadata(t *testing.T) {
assert.Equal(t, "@@ a header line with no metadata @@", v.BufferLines()[3])
}
func TestDiffLineMetadataPayloads(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
osc := func(payload string) string { return "\x1b]1717;" + payload + "\x1b\\" }
v.writeString(strings.Join([]string{
// A single-column row: one payload tags the whole line.
osc("1;c;1;;foo.txt") + "context",
// A side-by-side change row: the deletion tags the left half and the
// addition replacing it tags the right half of the same rendered line.
osc("1;d;2;2;foo.txt") + "old2 " + osc("1;a;2;;foo.txt") + "new2",
// A header line with no metadata.
"@@ header @@",
}, "\n"))
assert.Equal(t, [][]string{
{"1;c;1;;foo.txt"},
{"1;d;2;2;foo.txt", "1;a;2;;foo.txt"},
nil,
}, v.DiffLineMetadataPayloads())
}
// When a re-render produces fewer view lines than the previous one,
// refreshViewLinesIfNeeded must truncate viewLines to the new content. If it
// didn't (it used to overwrite in place and keep the tail), a reader could map a

View file

@ -170,6 +170,12 @@ func (self *StagingHelper) GetDiffLineInfo(windowName string, viewLineIdx int) (
// are skipped (Transform emits context regardless of the included set, so only
// change lines need collecting — see §21.3), and view lines that wrap to the same
// buffer line are de-duplicated.
//
// A side-by-side rendering carries more than one record per row — a deletion on the
// left, the addition replacing it on the right — and staging includes both (you
// can't stage one side of a side-by-side row; accepted restriction). So each row's
// metadata payloads are all resolved; rows without metadata (no pager, or the
// buffer-parse / hyperlink backends) fall back to their single resolved record.
func (self *StagingHelper) ChangeLinesInViewRange(windowName string, first int, last int) []types.DiffLineInfo {
v, _ := self.c.GocuiGui().View(self.windowHelper.GetViewNameForWindow(windowName))
if v == nil {
@ -177,6 +183,7 @@ func (self *StagingHelper) ChangeLinesInViewRange(windowName string, first int,
}
resolved := self.resolveDiffLines(v.DiffLineContents())
payloadsByLine := v.DiffLineMetadataPayloads()
var infos []types.DiffLineInfo
lastBufferLine := -1
for viewLine := first; viewLine <= last; viewLine++ {
@ -185,7 +192,14 @@ func (self *StagingHelper) ChangeLinesInViewRange(windowName string, first int,
continue
}
lastBufferLine = bufferLine
if bufferLine < len(resolved) && resolved[bufferLine].ok && resolved[bufferLine].info.IsChange() {
if bufferLine < len(payloadsByLine) && len(payloadsByLine[bufferLine]) > 0 {
for _, payload := range payloadsByLine[bufferLine] {
if info, ok := self.diffLineInfoFromMetadata(payload); ok && info.IsChange() {
infos = append(infos, info)
}
}
} else if bufferLine < len(resolved) && resolved[bufferLine].ok && resolved[bufferLine].info.IsChange() {
infos = append(infos, resolved[bufferLine].info)
}
}