Design notes: mapping rendered diff rows to patch coordinates

Captures a design discussion about the remaining big problem behind the
focused-main-view feature: recovering a diff row's patch-space identity
(file, type, source line) when the rendering came from a pager. Records
the two complementary mechanisms (a host-side parser for
structure-preserving renderings, and a pager-emitted OSC protocol for
ones that restructure), the per-cell carrier with its keyboard/mouse
access rules, the type + old?/new-line payload and why each field is
load-bearing, and the version-negotiation handshake. Design only; no
implementation yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-05 14:31:59 +02:00
parent 0b11400a9d
commit b026fb2b93

285
diff-line-metadata-notes.md Normal file
View file

@ -0,0 +1,285 @@
# Diff line metadata — design notes
Mapping a **rendered diff row (and column)** back to its **patch-space
identity**, so lazygit can act on the line the user is pointing at.
> Status: **design only**, nothing implemented. This is a starting point for a
> future session, born out of a long design discussion. Two mechanisms are
> described (#1 a host-side parser, #2 a pager-emitted OSC protocol); they are
> complementary, not alternatives. Start with #1.
---
## 1. The primitive and its consumers
Every feature below needs the *same* one thing: given a row in a rendered diff
(and, for a mouse click, a column), recover **(file, type, source-line)** — the
exact line in the unified diff it corresponds to. It is one primitive with
several consumers, not a click-to-stage helper:
1. **Dive into staging / patch building** (`enter` on the selected line, or a
double-click) — needs the patch line to land on.
2. **Edit the line** (`e`) — needs the new-file line to open the editor at.
3. **Open the line in the branch's GitHub PR** (`G`) — needs the side
(`L`/`R`) and line number for the anchor. Today we always emit `R<line>`
because we can't tell the side.
4. **Jump by hunk in the focused main view** (`<`/`>`-style, like the staging
view already has) — needs hunk boundaries.
5. **Preserve scroll position when diff parameters change** (`{`/`}` changing
the `-U` context size; today it jumps to the top via `onNewKey`) — remember
the patch line at the top/middle, re-render, scroll it back into view. This
reuses the first-paint scroll-restore machinery already built on this branch
(`ScrollToOriginYForNextTask`, commits `054d139fe`/`625e7dbad`). Anchor on
the **nearest change line**, which survives any `-U` change (context lines
don't).
Because it's one primitive, it's worth building as a clean standalone
capability rather than welding it to staging.
---
## 2. Two mechanisms, disjoint coverage
### #1 — Host-side parsing (lazygit parses the rendered buffer)
Parse the **decolorized view buffer** (gocui already exposes plain text per
line via `View.Line(y)` / `View.BufferLines()`; the cell buffer stores runes
with color stripped, so `utils.Decolorise` isn't strictly needed). Walk *up*
from the target row to the nearest `@@` (gives the hunk's new-file start) and
the nearest `diff --git a/… b/…` (gives the file), then count added/context
lines down to the row. The first character (`+`/`-`/space) gives the side.
- Reuses the `patch` package arithmetic (`LineNumberOfLine`,
`PatchLineForLineNumber`, hunk headers). The only new piece is multi-file
splitting (the commit diff spans files; `patch.Parse` is single-file).
- **Inherently high-fidelity**: parsing *is* working in patch space, so it
knows the side and exact line directly — none of delta's hyperlink lossiness.
- **Works for structure-preserving renderings**: no pager, `git diff --color`,
`delta --color-only`, `diff-so-fancy --patch`. You don't branch on which
pager is configured — you just try to parse what's on screen; if it isn't a
unified diff, the parse fails and we fall back.
- **Cannot** serve renderings that restructure the diff (delta's default mode,
difftastic, side-by-side) — there's no unified-diff line structure left to
parse.
`git diff --color` is squarely a #1 case (it only injects ANSI into a standard
unified diff), **not** a #2 target. `git --word-diff` is the genuine odd one
out (inline `[-…-]{+…+}` markup, no per-line `+`/`-`); it breaks #1 and would
need #2**out of scope for now**, acceptable to leave unsolved.
### #2 — Pager-emitted metadata (an OSC protocol)
For pagers that restructure the diff (and to avoid re-parsing in general): the
pager annotates its output with per-line metadata that lazygit reads. This is
the only path for difftastic-class pagers, and the only way to get full side
fidelity out of delta's default rendering.
### Coverage
| Rendering | #1 (parse) | #2 (emit) |
|---|---|---|
| no pager / `git diff --color` | ✅ | n/a (git won't emit) |
| `delta --color-only`, `diff-so-fancy --patch` | ✅ | ✅ if patched |
| delta default (color-conveyed side, gutters) | ❌ | ✅ if patched |
| difftastic / side-by-side | ❌ | ✅ if patched |
| `git --word-diff` | ❌ | ✅ if patched (out of scope) |
`#2 attachment present → use it; else parse the buffer (#1); else give up
(no selection).`
---
## 3. #2 design
### 3.1 Carrier: per-cell attachment (host stays layout-agnostic)
The metadata is attached **per cell**, like OSC-8 hyperlinks, **not** per line:
- The pager emits a metadata sequence at the **start of each line** — and
**multiple times per line** when a single row shows multiple source regions
(twice for side-by-side, N times for difftastic).
- lazygit attaches each record to the **following cell**. If there is no
following cell (a genuinely empty rendered line), it adds a content-less cell
to hold the attachment — an established gocui pattern (cf. the `\n` sentinel
cell removed in `7dc18f3eb7a4`).
- **The host never reasons about layout** — no `v.width/2`, no column math, no
knowing whether it's side-by-side. It just reads the nearest attachment. This
is the key property: all layout knowledge stays in the pager, which is the
only thing that has it.
**Access rules** (a principled mirror of the app's keyboard-vs-mouse split, not
two special cases):
- **`enter`** addresses a *row* (the cursor is row-granular) → use the **first
attachment on the row**. In side-by-side this is the left column; fine, since
the two sides of a change are one hunk for staging purposes.
- **click** addresses a *point* → use the **nearest attachment at or to the
left of the click x** → lands in the column actually clicked.
**Emit-position spec (interop detail):** the pager emits each region's
attachment at the **start of that region**, and everything from there until the
next region's attachment belongs to that record — *including* any line-number
gutter or other embellishments the pager considers part of the region. Where a
region "really" starts is the pager's call, not the host's (e.g. in delta's
side-by-side view everything past the `|` separator is the right side, its line
numbers included). The only firm requirement: the attachment must precede the
region's first cell, so search-left lands in the right region.
> Usage note: lazygit is keyboard-centric. Most users press `space` then
> `enter`, not click; and staging is the only click-reachable op (`e`/`G` are
> keyboard-only). So column-fidelity is a property the per-cell model gives us
> for free, not a requirement we paid much for.
### 3.2 Payload
Fields per attachment:
| field | presence | meaning |
|---|---|---|
| `version` | always | self-describing (see §3.3) |
| `type` | always | `file-header \| hunk-header \| context \| added \| deleted \| other` |
| `file` | always | absolute or repo-root-relative path (the host normalizes — pagers may emit whichever is convenient); on **every** attachment so search-left yields a complete answer without scanning back to the file header |
| `new-line` | always (content lines) | new-file line number, in the **diff's** new-file space |
| `old-line` | **only** when `type = deleted` | old-file line number |
**`type` is load-bearing and cannot be inferred.** Under the coordinate rules,
`added` and `context` *both* carry `{new-line present, old-line absent}`, so
presence can't distinguish them — and we must (scroll-preservation anchors on
change lines, so it has to tell `added` from `context`). Hence an explicit type.
**Why the side must be carried at all** (record this so it isn't "simplified"
away later): in delta's default rendering there are no `+`/`-` glyphs — side is
conveyed purely by background color. So a consumer cannot recover it from the
decolorized row; the pager has to state it.
**`new-line` is in the diff's new-file space**, so the host still runs it
through `Diff.AdjustLineNumber` before opening the editor, exactly as today
(the diff may be against staged content rather than the working tree).
### 3.3 Negotiation & extensibility (versioning, not key/value)
- **Env-var handshake:** `EMIT_OSC<n>_METADATA=V1[,V2,…]`. The **host advertises
the versions it understands**; the pager emits the highest mutually-understood
version. Outside a host the var is unset → the pager emits nothing → trivially
harmless in a raw terminal / `less` / `tmux`.
- **Build the handshake in v1 even with a minimal payload.** Negotiation is the
one piece that's *impossible to retrofit* — without it you can't introduce a
v2 without a flag day. The payload itself is easy to change later *because*
the handshake exists.
- **Versioning over key/value.** Ignore-unknown key/value only buys *additive*
growth; it can't reinterpret an existing field's format. A version field
(also carried in each payload, so attachments are self-describing) lets a
future v2 redefine the payload wholesale, safely. Keep v1 small and bet on
never needing v2. A cheap escape valve short of a version bump: allow optional
*trailing* fields within a version (consumers stop at the fields they know) —
a supplement, not the strategy.
- **Wire-safe by construction.** It's a well-formed OSC sequence, so any
terminal that doesn't recognize it skips it (gocui already does this via
`stateOSCSkipUnknown` in `pkg/gocui/escape.go`; real terminals skip unknown
OSC the same way). The metadata flowing through a real terminal must be
harmless — this is a hard requirement, since pagers run outside lazygit too.
### 3.4 The OSC number
No central registry, but allocations are rare; pick a **high, distinctive**
number (the iTerm2 `1337` convention) and **verify against the sources of
xterm, VTE, kitty, foot, WezTerm, iTerm2, Windows Terminal** before committing.
`456` is a placeholder. Known-used slots to avoid (from memory, against a
knowledge cutoff — re-check): `0/1/2` (title/icon), `4`+`104` (palette),
`7` (cwd), `8` (hyperlinks), `9` (notifications; `9;4` progress), `1012`+
`110119` (colors), `50` (font), `52` (clipboard), `99` (kitty notifications),
`133` (semantic prompt), `633` (VS Code shell integration), `777` (urxvt),
`1337` (iTerm2).
### 3.5 Pagers to patch
Small universe, so standardization cost is low and we can supply the patches
ourselves rather than lobbying: **delta, diff-so-fancy, ydiff, difftastic,
diffr, riff, git-split-diffs**. delta and difftastic are the high-value targets
(difftastic because #1 categorically can't serve it). Patching git's own
`--color` is unnecessary (#1 covers it).
---
## 4. Consumer → field mapping
| consumer | `deleted` | `added` | `context` |
|---|---|---|---|
| staging / `enter` (find patch line) | `old-line` | `new-line` | `new-line` |
| edit `e` (editor target) | `new-line` | `new-line` | `new-line` |
| PR link `G` (anchor) | `L` + `old-line` | `R` + `new-line` | `R` + `new-line` |
| hunk-jump | `hunk-header` type (or coordinate discontinuity) | | |
| scroll-preserve | anchor on a change line via its native coord (`old`/`new`); `type` selects change lines | | |
So `old-line` is used **only** for finding the patch line of a deletion and for
its PR `L` anchor; `new-line` does everything else (editor for all types,
patch-find for added/context, PR `R` anchor).
---
## 5. #1 implementation sketch (the host-side fallback — do this first)
- Read the decolorized buffer (`View.Line(y)` upward from the target).
- Walk up to the nearest `@@` (new-start) and nearest `diff --git` (file);
count `+`/space lines down to the target → `new-line`; first char → `type`.
- Reuse the `patch` package arithmetic; add multi-file (`diff --git`) splitting.
- Same `(file, type, new-line[, old-line])` result shape as the #2 payload, so
the two are interchangeable behind one accessor and the call sites
(`GetFileAndLineForClickedDiffLine` and friends) don't care which produced it.
---
## 6. Open questions
- **`new-line` for a deleted line:** exact convention (presumably `newStart` +
#added/context above it in the hunk, i.e. the new-file position the deletion
sits at). All pagers must agree.
- **Do headers carry line numbers?** `hunk-header` *could* carry old-start /
new-start, but hunk boundaries are also derivable from coordinate
discontinuities in the content lines, so it may be unnecessary. `file-header`
needs no line numbers (the `file` field already attributes every line).
- **difftastic specifics:** how many regions per row in practice, and where
exactly it should emit each attachment.
- **v1 wire format:** delimiter and encoding of an absent `old-line` (positional
vs. a minimal key/value within the version — note this is a *within-version*
choice, orthogonal to the versioning-vs-key/value extensibility decision).
- **Should the pager always emit, or only when the env var is set?** Leaning
env-var-gated (zero cost when no consumer wants it; harmless outside a host).
---
## 7. Suggested build order
The prototype is a learning vehicle, not production code: its two jobs are to
**inform the final OSC spec** (which we want to publish for pager-developer
feedback) and to **inform a from-scratch production plan**. Sequence:
1. **#1 prototype first** (§5) — the buffer parser plus the
`(file, type, new-line, old-line?)` accessor seam, wired to the
focused-main-view consumers, verified across the structure-preserving
renderings. Do this first because it:
- validates the payload data model cheaply, with **no external deps** — if a
field is wrong or missing you find out before writing the spec or a delta
patch (the most direct "inform the spec" lever);
- establishes the **shared accessor seam** that #2 plugs into as a second
backend;
- gives a **reference implementation** to validate delta's emitted metadata
against later;
- **ships independently** (the feature stops depending on delta-with-
hyperlinks for the common cases).
2. **Pin the v1 wire format** — the concrete OSC bytes, payload encoding,
version field, env-var name (§3.23.3), once #1 has confirmed the fields.
3. **#2 consumer side** — the gocui per-cell metadata mechanism + new-OSC parser
behind the same accessor; testable against synthetic OSC output **before**
delta exists (§3.1).
4. **#2 emitter side** — the delta patch; this is what stress-tests the spec
against reality, side-by-side being the hard case (§3.5).
5. **End-to-end → finalize the spec (publish for feedback) → write the
production plan.**
**Parallel de-risking (any time, doesn't block #1):** confirm by reading delta's
source that it can produce the fields per region — for `-` lines and in
side-by-side mode — at render time. It's the biggest *external* unknown; if delta
structurally can't emit something, the spec must adapt. Read-only research, so it
can run alongside #1. Still do #1 first: know what you *need* before checking what
delta *can do*.