Rework the plan: diff-renderer terminology, landed threading rework

Two corrections from review:

- "Pager" is retired in favor of "diff renderer" throughout (the spec
  already made this change); a new mini PR 3 renames the pagers config
  to diffRenderers with migration and docs, and the remaining PRs are
  renumbered accordingly.

- The locked-decision list wrongly said concurrency stays mutex-based;
  the main-thread-mutation rework has landed on master and the
  prototype is rebased on top of it. The plan now states the landed
  threading contract that all PRs must honor, and PR 1 gained a
  re-validate-against-master preamble. Its reset-after-CopyContent
  commit was verified still needed (master's CopyContent still copies
  the source origin); the two locking fixes were reshaped onto the
  viewLines readers master actually has, since the prototype's
  HyperLinkInLine guard belongs to the dropped hyperlink backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-07-18 17:36:59 +02:00
parent 87701d427f
commit 12d3dac8b3

View file

@ -20,6 +20,18 @@ current: check off commits as they land, record deviations, and add findings.
`amend!` for iteration, no conventional-commit prefixes, `just generate`
after keybinding/test changes, docs in `docs-master/` only, translatable
strings via Go templates, no PRs created by agents (the user opens them).
- **Terminology: say "diff renderer", never "pager".** "Pager" is a misnomer
that leaked out of the implementation (the `GIT_PAGER` env var); the OSC 1717
spec now says "diff renderer" throughout, and so do we — in code identifiers,
commit messages, PR descriptions, docs, and user-facing strings. The
prototype's identifiers and test names still say "pager"
(`ProbePagerEmitsDiffMetadata`, `onPagerChanged`,
`stage_from_main_view_with_unsupported_pager`, …); rename them during
transcription. `GIT_PAGER` itself and the "stdin-pager invocation route" (as
opposed to `externalDiffCommand`) keep their technical names where the
mechanism is literally meant. PR 3 renames the user-facing config.
- **Threading:** master has the landed main-thread rework — see §2.8 for the
contract every PR must honor. The prototype is already rebased on top of it.
- **Prototype references** are given as *subject line* plus short SHA. The
prototype branch gets rebased; **find commits by subject, not SHA**. When a
plan item says "reference: X", read that prototype commit (message + diff)
@ -33,32 +45,32 @@ current: check off commits as they land, record deviations, and add findings.
- **Branch naming/stacking:** each PR gets its own branch; PR N+1 branches off
PR N's branch (linear stack). Rebase the stack when a PR merges.
- **Verification:** every PR runs `just build`, `just unit-test`, `just lint`,
`just e2e`. PRs touching the async render or pager paths additionally need
the interactive sign-off listed in §6 — the headless harness cannot exercise
the pty/pager path (N§13.1: cmd-path only, env allowlist blocks
`just e2e`. PRs touching the async render or diff-renderer paths additionally
need the interactive sign-off listed in §6 — the headless harness cannot
exercise the pty/renderer path (N§13.1: cmd-path only, env allowlist blocks
`LAZYGIT_SLOW_RENDER`).
## 2. Locked scope decisions (do not relitigate)
Decided with the user (2026-07-17 planning session, plus earlier locked
Decided with the user (2026-07-17/18 planning sessions, plus earlier locked
decisions from the notes):
1. **The staging and patch-building panels are removed.** `enter` on a file
(files panel and commit-files panel) focuses the main view at that file's
diff. The `Staging`/`StagingSecondary`/`CustomPatchBuilder` contexts, views,
and `patch_exploring` machinery are deleted (PR 8). The prototype kept them
and `patch_exploring` machinery are deleted (PR 9). The prototype kept them
as an A/B reference (N§21.24) — production does not.
2. **`enter` / double-click in the focused main view are dropped.** With the
explorers gone the dive gesture has no target. Enter is unbound there;
double-click behaves like a single click (select). Esc exits; space/d/e act
on the selection.
3. **Sequencing:** stacked PRs, merged in quick succession; **no release ships
with both staging UIs**. Brief coexistence on master between PR 6/7 and
PR 8 is fine.
with both staging UIs**. Brief coexistence on master between PRs 7/8 and
PR 9 is fine.
4. **The nav/selection and position-preserve features land as their own PRs
before the staging series** (PRs 4 and 5), independently releasable.
before the staging series** (PRs 5 and 6), independently releasable.
5. **Both extras are in scope** as final small PRs: alt/shift-click-to-edit
(PR 9) and open-PR-at-selected-line (PR 10).
(PR 10) and open-PR-at-selected-line (PR 11).
6. **The hyperlink backend is dropped** (N§14.5): no `lazygit-edit://`-based
line identity resolution. (Master's existing click-a-delta-hyperlink feature
in `pkg/gui/gui.go` is untouched — only the prototype's use of hyperlinks as
@ -66,14 +78,32 @@ decisions from the notes):
7. **Selection is always shown in diff main views** (N§21.6), anchored at the
first visible change line / hunk; no on-demand toggle, no config for it.
Non-diff main views (branch log, …) keep no selection.
8. **Concurrency stays mutex-based** (N§15 locked); the main-thread-mutation
rework is a separate later effort. Do not design around it now.
8. **Follow master's landed threading contract.** The main-thread-mutation
rework has landed on master (Front A #5767; "Synchronize async view
rendering" #5791; the popup, command-log, and status fronts;
`RefreshFromWorker` + captured refresh inputs; `BatchUIUpdates` replacing
BLOCK_UI), and the prototype is rebased on top of it (merge-base
`4cf12a5b7`). The contract for all new code: **model/context/selection
mutations are UI-thread-only** (asserted in `-debug` builds); **view
geometry and origin (`ox`/`oy`) are UI-thread-only**; the view's **write
buffer may be written off-thread under `writeMutex`, which is kept
permanently** (the batch-to-UI-thread attempt deadlocked and was
abandoned); task-side origin/dimension work bounces via
`ViewBufferManager.onUIThread`; worker-issued refreshes use
`RefreshFromWorker` with UI-owned inputs captured up front; incidental
display work bounces as *background* tasks so it doesn't count toward
`Busy()` and block repo switches. The prototype tree is already adapted
(atomic `loading` flag, bounced `onEndOfInput`/`onNewKey`, `OnUIThread`
hops in restore `Apply`, off-screen swap under `writeMutex`) — transcribe
it as-is; where a threading question comes up, the answer is "match the
current tree / master's contract". See memory
[[main-thread-over-mutexes-direction]] and `docs/dev/Repo_Switch_Safety.md`.
9. **Side-by-side staging includes all records on a row** — no stage-one-side
gesture (N§21.3, accepted restriction).
10. **Pager capability detection is the empty-input handshake probe**
(N§21.30), not render observation. Non-conforming pagers get the raw-diff
fallback at focus time; `useExternalDiffGitConfig` is always-raw when
focused (documented limitation).
10. **Diff-renderer capability detection is the empty-input handshake probe**
(N§21.30), not render observation. Non-conforming renderers get the
raw-diff fallback at focus time; `useExternalDiffGitConfig` is always-raw
when focused (documented limitation).
11. **The escape/snapshot machinery is never built** (`FocusedMainViewSnapshot`,
`EscapeFromPatchExplorer`, the N§12.2 escape routing): it existed only to
return *from the explorers*, which no longer exist.
@ -86,18 +116,19 @@ The branch contains superseded/reverted work. Do not transcribe any of these
| Not ported | Superseded by / reason |
|---|---|
| `gui.showSelectionInFocusedMainView` config; on-demand space-toggled selection | always-shown selection (N§21.6) |
| Middle-visible-line as the *selection* anchor ("Select the line in the middle…") | first-visible-change/hunk anchor. (`MiddleVisibleLineIdx` itself survives as the `-U`-preserve anchor when no selection shows — PR 5) |
| Middle-visible-line as the *selection* anchor ("Select the line in the middle…") | first-visible-change/hunk anchor. (`MiddleVisibleLineIdx` itself survives as the `-U`-preserve anchor when no selection shows — PR 6) |
| `enter`/double-click dive into staging/patch-building at a line; `CommitFilesHelper.EnterCommitFile` threading | gesture dropped (§2.2) |
| `FocusedMainViewSnapshot`, `EscapeFromPatchExplorer`, escape-restore-by-identity, N§12.2 routing | explorers removed (§2.11) |
| Hyperlink identity backend (`GetFileAndLineForClickedDiffLine` hyperlink parsing, `HyperLinkInLine` as a *backend*) | buffer-parse + OSC metadata only (§2.6) |
| `ScrollToOriginYForNextTask` / `thenForNextTask` / `KeepOriginForNextTask` / `LinesToRead.ApplyInitialScroll` | `RenderRestore` (PR 5) — build the final mechanism directly |
| `ScrollToOriginYForNextTask` / `thenForNextTask` / `KeepOriginForNextTask` / `LinesToRead.ApplyInitialScroll` | `RenderRestore` (PR 6) — build the final mechanism directly |
| "Hold the placeholder until first paint" + `freshViewLineCount` stale-tail guard (both reverted on the branch too) | off-screen render (PR 1) |
| Observe-at-focus pager detection (N§21.29) | handshake probe (N§21.30) |
| Observe-at-focus diff-renderer detection (N§21.29) | handshake probe (N§21.30) |
| `HighlightInset` and `selectionBgColorEdgeWidth` experiments | `narrowSelectionHighlight` (N§21.34) |
| `matchByWorktreeChange` and `AdjacentChangeLine` reveal matchers | change-line-ordinal reveal (N§21.17) |
| The three separate handler channels (`onClick`/`onStage`/`onTogglePatch FocusedMainViewFn`) | build `FocusedMainViewActions` directly in its final one-interface shape (N§21.25) |
| Unconditional gutter reset-on-preview-render + the `strings.Join(cmd.Args)` content-equality hack | focused-pair-only gutter model (N§21.22(3), N§21.35) |
| `backUpOverHeader` file-nav landing | land on the first located row; f/h header records make headers resolvable ("Parse the f/h header records…", af98be48d) |
| "Pager" naming in identifiers, strings, docs | "diff renderer" (§1 terminology; PR 3 renames the config) |
| OSC number `456`, env vars `EMIT_OSC1717_METADATA`/`OSC1717_METADATA` | OSC **1717**, env var **`OSC1717`** (final rename, 665149b11) |
| The in-repo spec file | the spec lives on the `osc-1717-spec` branch / worktree (fe3c5ac21) |
| Session-notes commits, `.claude/settings.json` commits, WIP commits | n/a |
@ -105,21 +136,22 @@ The branch contains superseded/reverted work. Do not transcribe any of these
## 4. The PR stack (overview)
PR titles become release-notes lines — they are written for users. Order is
dependency order; 15 are independently releasable; 68 merge in quick
succession (§2.3); 910 any time after their dependencies.
dependency order; 16 are independently releasable; 79 merge in quick
succession (§2.3); 1011 any time after their dependencies.
| # | Title (draft) | Depends on | Nature |
|---|---|---|---|
| 1 | Fix flicker, scroll glitches, and crashes in async diff rendering | — | fixes, gocui/tasks |
| 2 | Internal: resolve diff lines to (file, line, kind) identities | 1 | infra |
| 3 | Support pagers that emit OSC 1717 diff line metadata | 2 | infra + protocol |
| 4 | Select, navigate, edit and copy diff lines in the focused main view | 2 (3 for pagers) | feature |
| 5 | Keep your position in the diff when changing context size or switching pagers | 1, 2, 4 | feature |
| 6 | Stage, unstage and discard changes directly from the focused main view | 3, 4, 5 | feature |
| 7 | Build custom patches directly from a commit's diff view | 6 | feature |
| 8 | Replace the staging and patch-building panels with the focused main view | 6, 7 | removal + migration |
| 9 | Alt- or shift-click a diff line to open it in your editor | 2, 3 | feature |
| 10 | Open the selected diff line in the branch's GitHub PR | 4 | feature |
| 3 | Rename the "pagers" config to "diff renderers" | — | rename + migration |
| 4 | Support diff renderers that emit OSC 1717 diff line metadata | 2, 3 | infra + protocol |
| 5 | Select, navigate, edit and copy diff lines in the focused main view | 2 (4 for renderers) | feature |
| 6 | Keep your position in the diff when changing context size or switching diff renderers | 1, 2, 5 | feature |
| 7 | Stage, unstage and discard changes directly from the focused main view | 4, 5, 6 | feature |
| 8 | Build custom patches directly from a commit's diff view | 7 | feature |
| 9 | Replace the staging and patch-building panels with the focused main view | 7, 8 | removal + migration |
| 10 | Alt- or shift-click a diff line to open it in your editor | 2, 4 | feature |
| 11 | Open the selected diff line in the branch's GitHub PR | 5 | feature |
---
@ -131,6 +163,13 @@ All standalone master-worthy fixes; users benefit regardless of the rest of
the series. Everything here lives in `pkg/gocui`, `pkg/tasks`, and the gui
layout/render plumbing.
**Re-validate each fix against current master before transcribing.** The
landed rework (#5791) restructured exactly this area — the read loop now
bounces `onEndOfInput`/`onNewKey` to the UI thread, `readLines` is an atomic
pointer, several buffer accessors gained locks. The prototype commits are
rebased on top of all that and are the authoritative shapes, but a fix may
have been absorbed or may need reshaping; check before porting.
Commits (in order):
1. **Route all view origin writes through `SetOriginX`/`SetOriginY`** — pure
@ -138,50 +177,58 @@ Commits (in order):
2. **Add `LAZYGIT_SLOW_RENDER` debug knob** — sleeps N ms per written line so
async render frames become visible; inert when unset. Needed by reviewers
to see what the later commits fix. Ref: e8682b3fd.
3. **Lock the view and guard the line index when reading a hyperlink**
fixes a real master panic (`HyperLinkInLine` reads `v.lines`/`v.viewLines`
without `writeMutex` and can index a stale tail). Use the
demonstrate-then-fix pattern if a deterministic test is feasible.
Ref: 2cc42fc81.
4. **Lock the view while reading viewLines on mouse move** — same class,
`onMouseMove`/`findHyperlinkAt` panic. Ref: a44bf5d05.
5. **Fire queued ReadToEnd callbacks when the initial read reaches EOF**
3. **Lock the event-thread viewLines readers that still skip `writeMutex`**
task goroutines rebuild `viewLines` under `writeMutex`; master still reads
them unlocked on the event-handling thread in the click-path hyperlink
lookup (`pkg/gocui/gui.go` ~1686, `viewLines[newY].line[newX].hyperlink`)
and in `onMouseMove`/`findHyperlinkAt` (hover) — the rework locked
`ClearViewLines`/`IsTainted`/`Buffer` but not these; audit for any others.
The prototype fixed this class in its own `HyperLinkInLine` reader (part of
the dropped hyperlink backend) and in `onMouseMove`; transcribe the lock
pattern onto the readers master actually has. Use the demonstrate-then-fix
pattern if a deterministic test is feasible. Refs: 2cc42fc81, a44bf5d05.
4. **Fire queued ReadToEnd callbacks when the initial read reaches EOF**
the read loop abandoned queued `{Total:-1}` requests when the initial
request hit EOF, silently dropping their `Then`. Ref: b6f99abc6.
6. **Don't scroll a view up to fill blank space while its content is loading**
— the layout clamp used the partially-loaded height; add the synchronous
`loading` flag and skip the clamp while `IsLoading()`. Ref: 695842291.
7. **Reset other main views' scroll after copying content, not before**
5. **Don't scroll a view up to fill blank space while its content is loading**
— the layout clamp used the partially-loaded height; add the `loading`
flag (an `atomic.Bool` in the rebased shape) and skip the clamp while
`IsLoading()`. Ref: 695842291.
6. **Reset other main views' scroll after copying content, not before**
`refreshMainViews` zeroed the source view's origin before `CopyContent`
used it, so every cross-pair placeholder jumped to the top. Ref: c35c9316c.
8. **Bundle a view's cell buffer and write state into a `viewBuffer`** — prep.
used it, so every cross-pair placeholder jumped to the top. *(Verified
against master 2026-07-18: still needed — `CopyContent` still copies the
source's `ox`/`oy` and `refreshMainViews` still resets first; master's
`1efcfcc14` only stopped sharing the buffer slices.)* Ref: c35c9316c.
7. **Bundle a view's cell buffer and write state into a `viewBuffer`** — prep.
Ref: fd858cd98.
9. **Make the buffer-writing methods operate on a `viewBuffer`** — prep.
8. **Make the buffer-writing methods operate on a `viewBuffer`** — prep.
Ref: 2cfc0e24d.
10. **Render async content into an off-screen buffer and swap it in** — the
core mechanism: cmd/pty tasks write to `View.offscreen`; at first-paint
(or EOF) the buffer swaps in atomically; `refreshViewLinesIfNeeded`
truncates view lines on swap (kills the stale-tail class);
`clear()`/`Reset()` abandon an in-progress off-screen render. Includes the
**scrollbar height freeze** (`FreezeScrollbarHeight` at `StartLoading`,
release at EOF and in `clear()`) — in the prototype this was an `amend!`
into the same commit precisely because the off-screen render introduces
the scrollbar regression; keep them together here too. Tests:
`TestOffscreenRender`, `TestBufferLineForViewLineStaleTail`,
`TestScrollbarHeightHeldWhileLoading`,
`TestScrollbarHeightReleasedWhenContentReplaced`. Refs: 27ce0a6bc + its
scrollbar amend, N§13.5, N§13.6.
11. **Don't run end-of-input handling for a render that was stopped** — the
9. **Render async content into an off-screen buffer and swap it in** — the
core mechanism: cmd/pty tasks write to `View.offscreen`; at first-paint
(or EOF) the buffer swaps in atomically (under `writeMutex` — buffer
writes are the writeMutex domain per §2.8); `refreshViewLinesIfNeeded`
truncates view lines on swap (kills the stale-tail class);
`clear()`/`Reset()` abandon an in-progress off-screen render. Includes the
**scrollbar height freeze** (`FreezeScrollbarHeight` at `StartLoading`,
release at EOF and in `clear()`) — in the prototype this was an `amend!`
into the same commit precisely because the off-screen render introduces
the scrollbar regression; keep them together here too. Tests:
`TestOffscreenRender`, `TestBufferLineForViewLineStaleTail`,
`TestScrollbarHeightHeldWhileLoading`,
`TestScrollbarHeightReleasedWhenContentReplaced`. Refs: 27ce0a6bc + its
scrollbar amend, N§13.5, N§13.6.
10. **Don't run end-of-input handling for a render that was stopped** — the
stopped-task EOF coin-flip (`select` between stop and closed `lineChan`)
let a stopped task swap in a truncated buffer. No deterministic test (the
bug *is* the nondeterministic select) — justified skip, N§13.6. Ref:
8e3dc3eff.
12. **Reset the scroll to the top at first paint, not when the task starts**
11. **Reset the scroll to the top at first paint, not when the task starts**
with the off-screen render the old content stays visible until the swap;
resetting oy at task start made it jump first. Ref: 411681502.
Notes:
- If commit 10's stale-tail test needs `BufferLineForViewLine`, introduce that
- If commit 9's stale-tail test needs `BufferLineForViewLine`, introduce that
accessor here (PR 2 then reuses it) rather than contorting the test.
- Gotcha for the future: **fast renders unmask ordering transients that slow
renders hide** (N§20.5). Re-test at normal speed *and* under slow render.
@ -195,8 +242,8 @@ flicking through commits/files scrolled down; the 10 s auto-refresh with
The host-side primitive: rendered row → `(path, kind, new/old line)`. No
user-visible change; the PR description should say what it enables. Backends
in this PR: **buffer-parse only** (raw / `--color` / structure-preserving
pager output). Precedence seam is built here; the metadata backend slots in
via PR 3.
renderer output). Precedence seam is built here; the metadata backend slots
in via PR 4.
Commits:
@ -222,9 +269,9 @@ Commits:
one parse per file section — **O(n), never per-line**; the single-line
resolver delegates to it), `StagingHelper.resolveDiffLines` /
`GetDiffLineInfo` seam with backend precedence (metadata → buffer-parse;
metadata arrives in PR 3). Port the prototype's unit tests
metadata arrives in PR 4). Port the prototype's unit tests
(`diff_line_parser_test.go`, `diff_line_info_test.go`,
`diff_line_navigation_test.go` comes with PR 4). Refs: 7cf9b5037,
`diff_line_navigation_test.go` comes with PR 5). Refs: 7cf9b5037,
9c0bb5357, 556ba1213 (final O(n) shape — build it O(n) directly; N§20).
4. **Decode C-quoted paths in the buffer parser** — flagged as an unclosed
prototype gap (M§8): git C-quotes unusual paths in `diff --git` headers;
@ -236,12 +283,43 @@ Gotchas:
and index together (the `DiffLineContents` snapshot approach does this).
- Multi-file diffs: `fileSectionBounds` handles rows outside any file section.
### PR 3 — Support pagers that emit OSC 1717 diff line metadata
### PR 3 — Rename the "pagers" config to "diff renderers"
Reads the protocol; sets the env var so conforming pagers emit. No consumer
behavior changes yet (consumers land in PRs 47), but the PR is the public
face of the protocol on the lazygit side — write the description for pager
authors, link the spec (osc-1717-spec branch).
A small standalone PR, before the protocol/feature PRs so their user-facing
text is consistent. "Pager" is an implementation-detail name (`GIT_PAGER`);
what these programs actually are is diff renderers, and the OSC 1717 spec
already uses that term throughout. No behavior change.
Commits:
1. **Rename the config with migration**`git.pagers``git.diffRenderers`
(yaml key + struct/field: `Pagers []PagingConfig` → e.g.
`DiffRenderers []DiffRendererConfig`), and the keybindings
`keybinding.universal.cyclePagers`/`cyclePagersReverse` → e.g.
`cycleDiffRenderers`/`cycleDiffRenderersReverse` — both via lazygit's
config-migration mechanism (`pkg/config`; follow the existing migration
precedents) so user config files keep working/are rewritten. Reword the
affected i18n strings (`CyclePagers`(+Tooltip), status-bar text) in
english.go only.
2. **Rename internal identifiers** — mechanical sweep (`PagingConfig`,
`usingExternalDiff` neighbors, helper names) as far as reasonable; keep
`GIT_PAGER` and the pty "stdin-pager route" naming where the mechanism is
meant (§1 terminology).
3. **Docs** — rename/rewrite `docs-master/Custom_Pagers.md` (→
`Custom_Diff_Renderers.md`; fix inbound links; leave `docs/` alone per
AGENTS.md), `just generate` for Config.md/schema and cheatsheets.
Open question (resolve with the user at PR start): does the per-entry
`pager:` field keep its name? It specifically selects the GIT_PAGER
invocation route (vs `externalDiffCommand`), so a rename there trades
consistency against precision.
### PR 4 — Support diff renderers that emit OSC 1717 diff line metadata
Reads the protocol; sets the env var so conforming renderers emit. No
consumer behavior changes yet (consumers land in PRs 58), but the PR is the
public face of the protocol on the lazygit side — write the description for
diff-renderer authors, link the spec (osc-1717-spec branch).
Commits:
@ -253,8 +331,15 @@ Commits:
record** (N§21.30, `TestDiffLineMetadataHandshakeSwallowed`); multi-record
rows: `View.DiffLineMetadataPayloads()` returns *all* distinct payloads per
buffer line (side-by-side rows carry two — N§17.1, N§21.12). Unit tests
incl. wrapped rows (every pager-wrapped output row carries the record —
incl. wrapped rows (every renderer-wrapped output row carries the record —
M§10.8). Refs: 1cc7ecbbb, e8385b3cf, 13595f0a8, 3018289e8 (gocui half).
**Note:** at planning time (2026-07-18) the prototype has *uncommitted*
gocui work in flight extending this to **zero-width record regions**
(back-to-back records with no cells between them — e.g. difftastic's
combined file+hunk banner, or a modification pair collapsed to one column;
orphaned payloads are drained into content-less carrier cells so every
record on a row stays discoverable). Check its committed state when
transcribing and include the final shape.
2. **The metadata backend, slotted ahead of buffer-parse** — resolve
`a`/`d`/`c` records to `DiffLineInfo`; **accept the `f`/`h` header
records** (file header: no line number; hunk header: first line of its
@ -262,17 +347,17 @@ Commits:
`SamePatchLine` requires header kinds to match (a hunk header shares its
number with the hunk's first content line — af98be48d's message has the
full reasoning). Refs: 836f768cb, af98be48d.
3. **Advertise the protocol to pagers** — set `OSC1717=V1` in the environment
of pager/ext-diff invocations (pty task env + ext-diff cmd env). Ref:
9975a8fac + 665149b11 (final name: `OSC1717`).
3. **Advertise the protocol to diff renderers** — set `OSC1717=V1` in the
environment of renderer/ext-diff invocations (pty task env + ext-diff cmd
env). Ref: 9975a8fac + 665149b11 (final name: `OSC1717`).
Cross-repo note: the reference emitters live on `osc-1717-metadata` branches
in `/Users/stk/Stk/Dev/Builds/{delta,difftastic,diff-so-fancy}`; nothing is
upstreamed yet. lazygit must remain fully functional without any conforming
pager (buffer-parse + PR 6's raw fallback guarantee this). Interactive
verification of this PR needs locally built patched pagers.
renderer (buffer-parse + PR 7's raw fallback guarantee this). Interactive
verification of this PR needs locally built patched renderers.
### PR 4 — Select, navigate, edit and copy diff lines in the focused main view
### PR 5 — Select, navigate, edit and copy diff lines in the focused main view
The focused main view (already reachable via `0`/click on master) gains a
real selection and line-level interactions. After this PR: diff main views
@ -289,7 +374,7 @@ Commits:
`GetDiffMainViewType() DiffMainViewType` (`None|Staging|PatchBuilding`) on
the side-panel contexts (files=Staging; commitFiles/localCommits/
subCommits/stash=PatchBuilding; reflog=PatchBuilding **from day one**
see PR 7 reflog item; others None). In this PR only "≠ None" is read (is
see PR 8 reflog item; others None). In this PR only "≠ None" is read (is
this a diff main view). Refs: f470d870f (marker origin), a760f9ef5
(classifier final shape), N§21.25.
3. **The selection model**`DiffSelectState` on `MainContext`
@ -309,7 +394,7 @@ Commits:
content; hide when changes vanish (render-side hook in the panel's
render-to-main + focus-side check via `ViewHasChangeLines`). e2e:
`no_selection_when_no_changes`, `hide_selection_after_discarding_last_change`
(adapt: discard via files panel until PR 6). Ref: 7901de3d4, N§21.27
(adapt: discard via files panel until PR 7). Ref: 7901de3d4, N§21.27
bug 4.
5. **Drag-to-range**`dragAnchorViewLine` on MainContext; `MouseLeft` +
`ModMotion` binding re-anchors at the mouse-down line. **Includes the gocui
@ -332,9 +417,9 @@ Commits:
9. **Copy the selection (`ctrl+o`)** — map the selected *view* range to
buffer lines via `BufferLineForViewLine` (never `SelectedLines()` — it's
wrapping-unaware, N§21.28), copy each wrapped line once, trailing `\n`;
`dropDiffPrefix` only when no pager is configured. e2e:
`dropDiffPrefix` only when no diff renderer is configured. e2e:
`copy_from_main_view`. Ref: 99f14162c + its fixup.
10. **`narrowSelectionHighlight` per-pager config** — gocui
10. **`narrowSelectionHighlight` per-renderer config** — gocui
`SelectedLineBgColorWidth` (left N columns only), gui maps bool→2;
docs via `just generate`. Ref: cc90accde, N§21.34.
@ -342,14 +427,14 @@ Open item to resolve with the user during this PR: whether `n`/`N`/`f` get
proper keybinding config entries (prototype used hardcoded literals,
N§16.2) — lean: add config entries, matching lazygit convention.
Note: `space` is deliberately **not** bound here — staging arrives in PR 6.
Under a non-conforming restructuring pager, nav/e simply no-op until PR 6's
raw fallback lands; acceptable interim (same release).
Note: `space` is deliberately **not** bound here — staging arrives in PR 7.
Under a non-conforming restructuring renderer, nav/e simply no-op until
PR 7's raw fallback lands; acceptable interim (same release).
### PR 5 — Keep your position in the diff when changing context size or switching pagers
### PR 6 — Keep your position in the diff when changing context size or switching diff renderers
The `RenderRestore` mechanism plus its two standalone consumers. After this
PR: `{`/`}` (context size) and `|`/`\` (pager cycle) keep your scroll
PR: `{`/`}` (context size) and `|`/`\` (renderer cycle) keep your scroll
position and selection instead of jumping to the top.
Commits:
@ -364,8 +449,9 @@ Commits:
command-key changed`; **not cleared when a task starts** (survives
stop-and-replace by the periodic refresh), cleared in `Apply` (found or
not) — N§14.1; `Apply` work that touches gui state hops to `OnUIThread`
(it runs on the task goroutine, N§21.29 threading fix). Refs: 2e3a3ae5b
(mechanism parts), 3b597a0f2, N§14.1, N§20.5.
(it runs on the task goroutine, N§21.29 threading fix; origin writes are
UI-thread-only per §2.8 — the rebased prototype shows the exact shape).
Refs: 2e3a3ae5b (mechanism parts), 3b597a0f2, N§14.1, N§20.5.
2. **gocui: off-screen scan accessors**`OffscreenDiffLineContents` /
`OffscreenDiffLineContentsFrom(from)` (incremental — the O(n) load scan),
`OffscreenLineCount`, `MiddleVisibleLineIdx`. Refs: 792c7a294, 3e5b52b8f,
@ -383,11 +469,11 @@ Commits:
(same screen row); visibility guard (don't install on a hidden Normal
view — merge-conflict edge, N§16.1). e2e: extend
`staging/diff_context_change`-adjacent coverage. Ref: 24a95e965.
5. **Preserve position when switching pagers** — same one-liner in
`onPagerChanged`; fixes both the ext-diff top-jump and the wrong-line
"preserved by raw line number" cases (N§18.2); graceful no-op fallback for
unresolvable pagers. e2e: `diff/cycle_pagers` keeps passing. Ref:
a21c5841a.
5. **Preserve position when switching diff renderers** — same one-liner in
the renderer-cycle handler (prototype: `onPagerChanged`); fixes both the
ext-diff top-jump and the wrong-line "preserved by raw line number" cases
(N§18.2); graceful no-op fallback for unresolvable renderers. e2e:
`diff/cycle_pagers` keeps passing (rename per PR 3). Ref: a21c5841a.
6. **Preserve the selection's far end too**`selectionFarEndIdentity`
restored via `SetRangeSelectStart`; collapses to the cursor line when the
far end didn't survive. Ref: 0412046c4, N§21.32(4).
@ -395,13 +481,13 @@ Commits:
Known limitation (keep, document in PR): `NormalSecondary` is not preserved
(N§16.1, N§18.3).
### PR 6 — Stage, unstage and discard changes directly from the focused main view
### PR 7 — Stage, unstage and discard changes directly from the focused main view
The headline PR. After it: in the files panel's focused main view, `space`
stages/unstages the selected line/range/hunk (multi-file, side-by-side aware),
`d` discards, the split follows the acted-on side, the selection advances to
the next change, commit keys work there, and a non-conforming pager falls
back to a raw diff at focus time so staging always works.
the next change, commit keys work there, and a non-conforming diff renderer
falls back to a raw diff at focus time so staging always works.
Commits:
@ -453,26 +539,29 @@ Commits:
`DiffMainViewTypeStaging`; gate re-checked per press;
`IsInStack`-guarded `NextInStack` lookup for cheatsheet generation. Ref:
4b54223f4.
9. **Raw-diff fallback for non-conforming pagers + the handshake probe**
`ProbePagerEmitsDiffMetadata` (empty-input pager run / 7-arg ext-diff
convention on two empty temp files; `OSC1717=V1`; grep for the handshake);
verdict cached per pager signature; `useExternalDiffGitConfig` → always
raw when focused; `DiffMainViewShouldRenderRaw` read by every diff panel's
render-to-main; `ignoreExternalDiff` threaded through the diff-cmd
builders (`--no-ext-diff`, keep color); `types.NewMainViewDiffTask` routes
raw renders through `RunCommandTask` (bypasses `GIT_PAGER`); focus flow
9. **Raw-diff fallback for non-conforming diff renderers + the handshake
probe** — the probe (prototype: `ProbePagerEmitsDiffMetadata`; rename per
§1) runs the renderer on empty input (stdin route via `NewShell`; ext-diff
via git's 7-arg convention on two empty temp files), env `OSC1717=V1`,
greps for the handshake; verdict cached per renderer signature;
`useExternalDiffGitConfig` → always raw when focused;
`DiffMainViewShouldRenderRaw` read by every diff panel's render-to-main;
`ignoreExternalDiff` threaded through the diff-cmd builders
(`--no-ext-diff`, keep color); `types.NewMainViewDiffTask` routes raw
renders through `RunCommandTask` (bypasses `GIT_PAGER`); focus flow
installs a restore to place the selection after the raw re-render;
click-to-focus replays the clicked view-line index (best effort). e2e:
`stage_from_main_view_with_unsupported_pager`,
`build`-variant comes with PR 7, `stage_from_main_view_with_conforming_pager`
(fake handshake pager). Refs: 98881fc9e, 17cfd567e, bf18778e9; the probe
detection is N§21.30 (the observe mechanism never lands — §3).
click-to-focus replays the clicked view-line index (best effort). e2e
(rename per PR 3): `stage_from_main_view_with_unsupported_pager`, the
`build`-variant comes with PR 8,
`stage_from_main_view_with_conforming_pager` (fake handshake renderer).
Refs: 98881fc9e, 17cfd567e, bf18778e9; the probe detection is N§21.30
(the observe mechanism never lands — §3).
10. **Port the remaining prototype staging e2e tests** (whichever aren't
already in earlier commits): `stage_hunk/range/range_spanning_files…`,
`select_hunk_on_focusing_main_view`, `select_next_*`,
`advance_to_next_hunk_after_staging_shifts_line_numbers`,
`focus_follows…`/`focus_returns…`, `no_selection…`/`hide_selection…` (if
deferred from PR 4).
deferred from PR 5).
Design seam to keep (separate-lists input, §7): the focus-follow decision and
the "which side does this pane show" logic must stay **localized** (the
@ -480,12 +569,12 @@ handler + `diffSplitState`), not smeared across call sites — the parked
separate-lists design will want to re-derive "side" from list-section
membership and may want a different focus-follow rule.
### PR 7 — Build custom patches directly from a commit's diff view
### PR 8 — Build custom patches directly from a commit's diff view
After it: `space` over a commit's diff (commit-files, commits, sub-commits,
stash, reflog) toggles lines into a custom patch, a checkmark gutter shows
membership, the secondary pane previews the patch through your pager, `d`
removes lines from the commit, and moving/undoing patches keeps your
membership, the secondary pane previews the patch through your diff renderer,
`d` removes lines from the commit, and moving/undoing patches keeps your
selection.
Commits:
@ -501,7 +590,9 @@ Commits:
three call sites after the rename rebase (N§21.36(1)); production looks
up the `CommitFile` by path and passes `GetPreviousPath()`, mirroring
`toggleForPatch`/`RefreshPatchBuildingPanel`. Refs: e57135979, b4270b7d9
(accessor half), N§21.36(1).
(accessor half), N§21.36(1). Note master's `33b8d497c` added a mutex to
PatchBuilder (worker `Reset()` vs UI-thread readers) — keep the new
accessors within that locking discipline.
3. **Toggle from the commit-files main view**`space` routes to the patch
toggle (per the panel's `PrimaryAction`); decides add/remove from the
first selected line; starts the builder if inactive (discard-confirm when
@ -531,19 +622,19 @@ Commits:
(stash, other-branch sub-commits, mid-rebase) and in the secondary pane.
e2e: `discard_lines_from_commit_main_view`. Refs: eaec32b2b (commit half),
b4270b7d9 (secondary-disable).
7. **The secondary patch pane: correct removal + pager rendering** —
7. **The secondary patch pane: correct removal + renderer-based preview** —
removal by **ordinal among shown change lines** (the aggregated patch
renumbers additions — line numbers are wrong, N§21.35(1)); render the
patch as a real diff: materialize `a/`+`b/` temp trees under lazygit's
temp dir (from-side blobs; `git apply` of the patch; added files: empty
`a/<file>`, absent `b/<file>`, `PatchToApply(false,false)`), render
`git diff --no-index --no-prefix a b` through the normal pager wiring;
generation counter drives lazy rebuilds. **Open sub-item: verify/handle
renames in the temp-tree rendering** (a renamed file materializes at two
paths; check what `--no-index` shows and whether `--find-renames` is
needed) — resolve during implementation, ask the user if it's ugly.
e2e: `remove_lines_from_main_view_secondary`. Refs: b4270b7d9, e0cde9b88,
957952566, N§21.35.
`git diff --no-index --no-prefix a b` through the normal diff-renderer
wiring; generation counter drives lazy rebuilds. **Open sub-item:
verify/handle renames in the temp-tree rendering** (a renamed file
materializes at two paths; check what `--no-index` shows and whether
`--find-renames` is needed) — resolve during implementation, ask the user
if it's ugly. e2e: `remove_lines_from_main_view_secondary`. Refs:
b4270b7d9, e0cde9b88, 957952566, N§21.35.
8. **Preserve the selection across commit rewrites** — the command-agnostic
net: the four commit-diff panels install an ordinal restore before
`RenderToMainViews` when (main view focused + selection shown + no restore
@ -555,15 +646,15 @@ Commits:
9. **Allow changing context size during custom patch building** — ref:
10bb69d80 (read its message for the rationale/constraints).
Deferred, recorded not fixed (N§21.22(4)): a pager *switch* mid-build shifts
the checkmarks until the next refresh (needs a post-render recompute hook).
Note it in the PR description.
Deferred, recorded not fixed (N§21.22(4)): a renderer *switch* mid-build
shifts the checkmarks until the next refresh (needs a post-render recompute
hook). Note it in the PR description.
### PR 8 — Replace the staging and patch-building panels with the focused main view
### PR 9 — Replace the staging and patch-building panels with the focused main view
The removal PR. Also the PR whose title tells users the big story — consider
making *this* the umbrella release-notes headline ("staging now happens
directly in the diff view") since PRs 6/7 titles already describe the
directly in the diff view") since PRs 7/8 titles already describe the
mechanics.
Sequencing inside the PR (every commit green):
@ -580,13 +671,13 @@ Sequencing inside the PR (every commit green):
panel: `enter` (and double-click on the file row) pushes the focused main
view anchored at that file's diff (multi-file/directory diff → anchor at
the file's first row via the jump-to-file landing logic). Selection
anchors per PR 4 rules.
anchors per PR 5 rules.
3. **Remove the explorer machinery** — contexts (`Staging`,
`StagingSecondary`, `CustomPatchBuilder`), their views/windows in
`context/setup.go` and layout, `StagingController`,
`PatchBuildingController` (explorer half), `patch_exploring` package,
`RefreshStagingPanel`/`RefreshPatchBuildingPanel` (keep/rewire the
*secondary patch panel* update path — PR 7's pager-rendered preview stays,
*secondary patch panel* update path — PR 8's renderer-based preview stays,
fed by `secondaryPatchPanelUpdateOpts`), escape/`EscapeFromPatchExplorer`
remnants, `IPatchExplorerContext`. Multiple commits: this is the risky
demolition — go subsystem by subsystem.
@ -604,9 +695,9 @@ Risk note: this PR is where hidden couplings surface (things that push
commands, `git bisect` edge flows). Grep for every reference to the removed
contexts/views before starting; expect a long tail of small fixes.
### PR 9 — Alt- or shift-click a diff line to open it in your editor
### PR 10 — Alt- or shift-click a diff line to open it in your editor
Self-contained; after PR 3 (uses `GetDiffLineInfo`). Commits (N§19):
Self-contained; after PR 4 (uses `GetDiffLineInfo`). Commits (N§19):
1. **gocui: let a mouse binding opt into firing while a popup is focused**
(`HandleWhenPopupPanelFocused`). Ref: ac85a90ed.
@ -622,9 +713,9 @@ Self-contained; after PR 3 (uses `GetDiffLineInfo`). Commits (N§19):
Interactive sign-off: Ghostty, iTerm2, VS Code (already done once for the
prototype; re-confirm the transcription).
### PR 10 — Open the selected diff line in the branch's GitHub PR
### PR 11 — Open the selected diff line in the branch's GitHub PR
Self-contained; after PR 4. One or two commits (N§5):
Self-contained; after PR 5. One or two commits (N§5):
- `openPullRequestForSelectedLine` on `Commits.OpenPullRequestInBrowser` in
the focused main view: URL `<pr.Url>/changes/<commitSha>#diff-<sha256(relPath)>R<line>`;
@ -640,21 +731,21 @@ Self-contained; after PR 4. One or two commits (N§5):
## 6. Interactive sign-off matrix
The headless harness cannot run real pagers, `LAZYGIT_SLOW_RENDER`, or the
pty path (N§13.1), and the gutter is draw-time-only. Each PR needs a user
pass before merge:
The headless harness cannot run real diff renderers, `LAZYGIT_SLOW_RENDER`,
or the pty path (N§13.1), and the gutter is draw-time-only. Each PR needs a
user pass before merge:
| PR | What to verify interactively |
|---|---|
| 1 | Slow-render matrix (N§11/§13): flick commits/files scrolled down; 10 s auto-refresh (`refreshInterval: 3`) — no content/scrollbar flicker; **also re-test at normal speed** (N§20.5) |
| 3 | Patched delta/difftastic/diff-so-fancy emit + render cleanly; handshake swallowed (no phantom line) |
| 4 | Selection feel under delta (narrowSelectionHighlight); hunk-on-click; drag; nav under metadata delta incl. repeated `n` across files |
| 5 | `{`/`}` and pager-cycle scrolled down: no top-jump, offset preserved, both anchor cases; ext-diff pager route (difftastic) |
| 6 | Full staging matrix under no-pager / patched delta (unified + SxS) / difftastic; cross-pane focus-follow; raw fallback feel under stock delta / diff-so-fancy-without-metadata; binary-file focus stability (N§21.30 repro) |
| 7 | Gutter under delta/no-pager/difftastic; whole-commit path on LocalCommits (canRebase menu); secondary pane rendering per pager; metadata path resolves secondary removals to the right file (a/b masquerade — N§21.35 caveat) |
| 9 | Ghostty, iTerm2, VS Code |
| 4 | Patched delta/difftastic/diff-so-fancy emit + render cleanly; handshake swallowed (no phantom line) |
| 5 | Selection feel under delta (narrowSelectionHighlight); hunk-on-click; drag; nav under metadata delta incl. repeated `n` across files |
| 6 | `{`/`}` and renderer-cycle scrolled down: no top-jump, offset preserved, both anchor cases; ext-diff route (difftastic) |
| 7 | Full staging matrix under no-renderer / patched delta (unified + SxS) / difftastic; cross-pane focus-follow; raw fallback feel under stock delta / diff-so-fancy-without-metadata; binary-file focus stability (N§21.30 repro) |
| 8 | Gutter under delta/no-renderer/difftastic; whole-commit path on LocalCommits (canRebase menu); secondary pane preview per renderer; metadata path resolves secondary removals to the right file (a/b masquerade — N§21.35 caveat) |
| 10 | Ghostty, iTerm2, VS Code |
Patched pager builds: `cargo build` in delta/difftastic worktrees
Patched renderer builds: `cargo build` in delta/difftastic worktrees
(`osc-1717-metadata` branches); diff-so-fancy is a script.
## 7. Compatibility with the parked separate-lists design
@ -684,48 +775,52 @@ review with the user when the relevant PR starts:
| Gap | Disposition |
|---|---|
| Rename support in the from-main-view patch paths (N§21.36(1)) | **Fix in PR 7 commit 2** (mandatory — regression vs master otherwise) |
| Rename support in the from-main-view patch paths (N§21.36(1)) | **Fix in PR 8 commit 2** (mandatory — regression vs master otherwise) |
| patch pkg rename-aware Parse/Transform/FormatView (N§21.36(2)) | **Fix in PR 2 commit 1** (mandatory; `renamed_file_whole` guards it) |
| Reflog patch-building (N§21.24) | **Fix in PR 7 commit 5** |
| Renames in the custom-patch temp trees (new, this plan) | Resolve during PR 7 commit 7 |
| Diffing mode (`W`) not wired to the raw fallback → not stageable (N§21.29) | Defer; note in PR 6 description ("diffing-mode staging is its own question") |
| Reflog patch-building (N§21.24) | **Fix in PR 8 commit 5** |
| Renames in the custom-patch temp trees (new, this plan) | Resolve during PR 8 commit 7 |
| Diffing mode (`W`) not wired to the raw fallback → not stageable (N§21.29) | Defer; note in PR 7 description ("diffing-mode staging is its own question") |
| `useExternalDiffGitConfig` always-raw when focused (N§21.30) | Keep; document |
| Per-pane selection memory on `<tab>` (re-anchors each switch, N§21.9) | Defer; follow-up candidate |
| `IsSingleHunkForWholeFile` hunk-default refinement (N§21.11) | Defer; follow-up candidate |
| `a` on a context line below the last hunk doesn't snap back like staging did (N§21.11) | Fix cheaply in PR 4 commit 3 if trivial (`ChangeBlockBounds` falls back to the block above); else defer |
| Deleted-file `MD`-vs-`D` staging special case (N§21.13) | Defer; record as follow-up in PR 6 description |
| `NormalSecondary` not preserved on `-U`/pager change (N§16.1) | Keep as documented limitation |
| `a` on a context line below the last hunk doesn't snap back like staging did (N§21.11) | Fix cheaply in PR 5 commit 3 if trivial (`ChangeBlockBounds` falls back to the block above); else defer |
| Deleted-file `MD`-vs-`D` staging special case (N§21.13) | Defer; record as follow-up in PR 7 description |
| `NormalSecondary` not preserved on `-U`/renderer change (N§16.1) | Keep as documented limitation |
| Gutter marks for not-yet-loaded lines of huge diffs (N§21.20) | Keep (marks appear on next recompute); note |
| Pager switch mid-patch-build shifts checkmarks (N§21.22(4)) | Defer; note in PR 7 |
| Copy: metadata-typed prefix stripping under column-preserving pagers (N§21.28) | Defer |
| Renderer switch mid-patch-build shifts checkmarks (N§21.22(4)) | Defer; note in PR 8 |
| Copy: metadata-typed prefix stripping under column-preserving renderers (N§21.28) | Defer |
| Nav only sees loaded content (deep targets in huge diffs, N§16.4) | Keep; note (openSearch-style ReadToEnd is the known shape if wanted) |
| Toggle auto-advance: no "skip already-included" smarts (N§21.35) | Keep plain next-hunk |
| difftastic token-vs-line `c`-at-new-line mismatch (M§10.2) | Protocol v2 candidate; nothing to do host-side |
## 9. Open questions (resolve before/during the marked PR)
1. **PR 4:** proper keybinding config entries for `n`/`N`/`f`? (lean: yes)
2. **PR 8:** new names for `useHunkModeInStagingView` / `wrapLinesInStagingView`
1. **PR 3:** does the per-entry `pager:` config field keep its name (it
selects the GIT_PAGER invocation route, vs `externalDiffCommand`), or is
it renamed too?
2. **PR 5:** proper keybinding config entries for `n`/`N`/`f`? (lean: yes)
3. **PR 9:** new names for `useHunkModeInStagingView` / `wrapLinesInStagingView`
+ config migration.
3. **PR 7:** renames in the custom-patch temp-tree rendering (see §8).
4. **PR titles**: drafts in §4 — the user finalizes wording at PR-open time
4. **PR 8:** renames in the custom-patch temp-tree rendering (see §8).
5. **PR titles**: drafts in §4 — the user finalizes wording at PR-open time
(they're the release-notes lines).
5. **Cross-repo timing** (outside this plan): circulating the OSC 1717 spec,
upstreaming the three pager patches. lazygit ships fully functional
without them; revisit pitching once PRs 17 exist as evidence.
6. **Cross-repo timing** (outside this plan): circulating the OSC 1717 spec,
upstreaming the three renderer patches. lazygit ships fully functional
without them; revisit pitching once PRs 18 exist as evidence.
## 10. Progress
- [ ] PR 1 — async render fixes
- [ ] PR 2 — diff-line identity primitive
- [ ] PR 3 — OSC 1717 support
- [ ] PR 4 — selection & navigation
- [ ] PR 5 — position preserve
- [ ] PR 6 — staging from the main view
- [ ] PR 7 — custom patches from the main view
- [ ] PR 8 — panel removal
- [ ] PR 9 — alt/shift-click edit
- [ ] PR 10 — open PR at line
- [ ] PR 3 — rename pagers → diff renderers
- [ ] PR 4 — OSC 1717 support
- [ ] PR 5 — selection & navigation
- [ ] PR 6 — position preserve
- [ ] PR 7 — staging from the main view
- [ ] PR 8 — custom patches from the main view
- [ ] PR 9 — panel removal
- [ ] PR 10 — alt/shift-click edit
- [ ] PR 11 — open PR at line
(Add per-commit checkboxes inside each PR section as work starts; record
deviations from this plan inline, dated.)