mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 15:46:26 -04:00
gocui: parse OSC 456 per-line diff metadata and attach it per cell
A patched pager (delta) prefixes each diff line with an OSC 456 sequence carrying that line's patch-space identity (see diff-line-metadata-notes.md), so the host can map a rendered row back to (file, type, new-line, old-line) without re-parsing -- the only way to recover the side for renderings that drop the +/- markers (delta's default mode). Recognize it in the escape interpreter and stamp the payload onto each cell, mirroring how OSC-8 hyperlinks are handled, exposing it via DiffLineMetadataInLine. To do so, generalize the OSC dispatch to accumulate the (possibly multi-digit) OSC number before branching, rather than matching the single character '8'; the OSC-8 path is unchanged for well-formed input. Unlike a hyperlink, the metadata sequence is never closed -- the pager re-emits one per line -- so clear it at each line boundary to keep it from bleeding onto a following line that has none (e.g. a hunk header). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0e35ebbb55
commit
00df5177e5
|
|
@ -20,6 +20,12 @@ type escapeInterpreter struct {
|
|||
instruction instruction
|
||||
hyperlink strings.Builder
|
||||
|
||||
// the OSC number being accumulated while we don't yet know which OSC this is
|
||||
oscNumber strings.Builder
|
||||
// the payload of an OSC 456 per-line diff-metadata sequence (see
|
||||
// diff-line-metadata-notes.md), accumulated like hyperlink
|
||||
metadata strings.Builder
|
||||
|
||||
// ConPTY emits cursor-positioning escapes (CUP) to skip over blank
|
||||
// rows rather than emitting LFs for them. To convert those into row
|
||||
// advances the view can act on, we track where in the pseudo-terminal
|
||||
|
|
@ -82,9 +88,9 @@ const (
|
|||
stateParams
|
||||
stateCSIDiscard
|
||||
stateOSC
|
||||
stateOSCWaitForParams
|
||||
stateOSCParams
|
||||
stateOSCHyperlink
|
||||
stateOSCMetadata
|
||||
stateOSCEndEscape
|
||||
stateOSCSkipUnknown
|
||||
|
||||
|
|
@ -427,27 +433,40 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
|||
}
|
||||
return true, nil
|
||||
case stateOSC:
|
||||
if characterEquals(ch, '8') {
|
||||
ei.state = stateOSCWaitForParams
|
||||
ei.hyperlink.Reset()
|
||||
// Accumulate the OSC number until its terminating ';', then dispatch on
|
||||
// it. (The previous code only recognised the single-digit '8'; a number
|
||||
// like 456 needs more than one character.)
|
||||
switch {
|
||||
case len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9':
|
||||
ei.oscNumber.WriteByte(ch[0])
|
||||
return true, nil
|
||||
case characterEquals(ch, ';'):
|
||||
switch ei.oscNumber.String() {
|
||||
case "8":
|
||||
ei.hyperlink.Reset()
|
||||
ei.state = stateOSCParams
|
||||
case "456":
|
||||
ei.metadata.Reset()
|
||||
ei.state = stateOSCMetadata
|
||||
default:
|
||||
ei.state = stateOSCSkipUnknown
|
||||
}
|
||||
ei.oscNumber.Reset()
|
||||
return true, nil
|
||||
default:
|
||||
// Not a recognized OSC; skip to its terminator (handling the case
|
||||
// where this character already is one).
|
||||
ei.oscNumber.Reset()
|
||||
switch {
|
||||
case characterEquals(ch, 0x07):
|
||||
ei.state = stateNone
|
||||
case characterEquals(ch, 0x1b):
|
||||
ei.state = stateOSCEndEscape
|
||||
default:
|
||||
ei.state = stateOSCSkipUnknown
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
ei.state = stateOSCSkipUnknown
|
||||
return true, nil
|
||||
case stateOSCWaitForParams:
|
||||
if !characterEquals(ch, ';') {
|
||||
// Malformed OSC 8 (expected ';' after '8'). Rather than
|
||||
// erroring — which would reset state mid-OSC and cause the
|
||||
// rest of the sequence to leak as literal text — treat the
|
||||
// whole OSC as one we don't understand and skip to its
|
||||
// terminator.
|
||||
ei.state = stateOSCSkipUnknown
|
||||
return true, nil
|
||||
}
|
||||
|
||||
ei.state = stateOSCParams
|
||||
return true, nil
|
||||
case stateOSCParams:
|
||||
if characterEquals(ch, ';') {
|
||||
ei.state = stateOSCHyperlink
|
||||
|
|
@ -463,6 +482,16 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
|||
ei.hyperlink.Write(ch)
|
||||
}
|
||||
return true, nil
|
||||
case stateOSCMetadata:
|
||||
switch {
|
||||
case characterEquals(ch, 0x07):
|
||||
ei.state = stateNone
|
||||
case characterEquals(ch, 0x1b):
|
||||
ei.state = stateOSCEndEscape
|
||||
default:
|
||||
ei.metadata.Write(ch)
|
||||
}
|
||||
return true, nil
|
||||
case stateOSCEndEscape:
|
||||
ei.state = stateNone
|
||||
return true, nil
|
||||
|
|
|
|||
|
|
@ -502,6 +502,9 @@ type cell struct {
|
|||
width int // number of terminal cells occupied by chr (always 1 or 2)
|
||||
bgColor, fgColor Attribute
|
||||
hyperlink string
|
||||
// per-line diff metadata from an OSC 456 sequence (see
|
||||
// diff-line-metadata-notes.md); empty unless a pager emitted it
|
||||
metadata string
|
||||
}
|
||||
|
||||
type cells []cell
|
||||
|
|
@ -866,6 +869,10 @@ func (v *View) write(p []byte) {
|
|||
if v.wy >= len(v.lines) {
|
||||
v.lines = append(v.lines, lineType{})
|
||||
}
|
||||
// An OSC 456 diff-metadata sequence applies only to the line it prefixes
|
||||
// (the pager re-emits one per line and never closes it), so drop it at the
|
||||
// line boundary rather than letting it carry onto a line with no metadata.
|
||||
v.ei.metadata.Reset()
|
||||
}
|
||||
|
||||
if v.pendingNewline {
|
||||
|
|
@ -1064,6 +1071,7 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
|
|||
fgColor: v.ei.curFgColor,
|
||||
bgColor: v.ei.curBgColor,
|
||||
hyperlink: v.ei.hyperlink.String(),
|
||||
metadata: v.ei.metadata.String(),
|
||||
chr: string(ch),
|
||||
width: width,
|
||||
}
|
||||
|
|
@ -1666,7 +1674,7 @@ func (v *View) HyperLinkInLine(y int, urlScheme string) (string, bool) {
|
|||
return "", false
|
||||
}
|
||||
|
||||
for _, c := range v.lines[linesY].cells {
|
||||
for _, c := range v.lines[linesY].cells {
|
||||
if strings.HasPrefix(c.hyperlink, urlScheme) {
|
||||
return c.hyperlink, true
|
||||
}
|
||||
|
|
@ -1675,6 +1683,39 @@ func (v *View) HyperLinkInLine(y int, urlScheme string) (string, bool) {
|
|||
return "", false
|
||||
}
|
||||
|
||||
// DiffLineMetadataInLine returns the OSC 456 per-line diff metadata payload
|
||||
// attached to the given (wrapped) view line, if a pager emitted one. In the
|
||||
// single-column case every cell of the line carries the same payload, so the
|
||||
// first non-empty one is the answer. See diff-line-metadata-notes.md.
|
||||
func (v *View) DiffLineMetadataInLine(y int) (string, bool) {
|
||||
// Take the lock so we don't race a concurrent re-render that is rebuilding the
|
||||
// buffer.
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.refreshViewLinesIfNeeded()
|
||||
|
||||
if y < 0 || y >= len(v.viewLines) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// refreshViewLinesIfNeeded overwrites viewLines in place without truncating,
|
||||
// so while a shorter re-render is loading, the tail of viewLines can still
|
||||
// hold stale entries pointing past the (shrunk) v.lines. Guard against that.
|
||||
linesY := v.viewLines[y].linesY
|
||||
if linesY >= len(v.lines) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
for _, c := range v.lines[linesY].cells {
|
||||
if c.metadata != "" {
|
||||
return c.metadata, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// BufferLineForViewLine maps a view line index (which counts wrapped lines) to
|
||||
// the index of the corresponding line in the unwrapped internal buffer (as
|
||||
// returned by BufferLines). Several view lines can map to the same buffer line
|
||||
|
|
|
|||
|
|
@ -159,6 +159,43 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
|
|||
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
|
||||
}
|
||||
|
||||
func TestDiffLineMetadata(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// Synthetic delta-style output: each content line is prefixed with an
|
||||
// OSC 456 sequence carrying version;type;new;old;file (old empty unless a
|
||||
// deletion), and the OSC bytes themselves must not become visible cells. The
|
||||
// final line is a header with no OSC, to prove the metadata doesn't bleed.
|
||||
osc := func(payload string) string { return "\x1b]456;" + payload + "\x1b\\" }
|
||||
v.writeString(strings.Join([]string{
|
||||
osc("1;c;1;;foo.txt") + "line1",
|
||||
osc("1;d;2;2;foo.txt") + "old2",
|
||||
osc("1;a;2;;foo.txt") + "new2",
|
||||
"@@ a header line with no metadata @@",
|
||||
}, "\n"))
|
||||
|
||||
type result struct {
|
||||
payload string
|
||||
ok bool
|
||||
}
|
||||
got := make([]result, len(v.lines))
|
||||
for y := range v.lines {
|
||||
payload, ok := v.DiffLineMetadataInLine(y)
|
||||
got[y] = result{payload, ok}
|
||||
}
|
||||
|
||||
assert.Equal(t, []result{
|
||||
{"1;c;1;;foo.txt", true},
|
||||
{"1;d;2;2;foo.txt", true},
|
||||
{"1;a;2;;foo.txt", true},
|
||||
{"", false}, // the header line carries no metadata (no bleed)
|
||||
}, got)
|
||||
|
||||
// The OSC sequence is consumed as an escape, so the visible text is intact.
|
||||
assert.Equal(t, "line1", v.BufferLines()[0])
|
||||
assert.Equal(t, "@@ a header line with no metadata @@", v.BufferLines()[3])
|
||||
}
|
||||
|
||||
func TestContainsColoredText(t *testing.T) {
|
||||
hexColor := func(text string, hexStr string) []cell {
|
||||
cells := make([]cell, len(text))
|
||||
|
|
|
|||
Loading…
Reference in a new issue