Compare commits

...
Author SHA1 Message Date
Stefan HallerandClaude Opus 5.5 a8afcc9ea2 Record the flaky long-commit-message test and its fixup
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:02 +02:00
Stefan HallerandClaude Opus 5.5 970d2d27b8 Record the stack's rebase onto master after #6061–#6071
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 12:22:33 +02:00
Stefan HallerandClaude Fable 5.1 55122552ec Record the OverwriteLines fix added to PR 2 round 3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 09:47:38 +02:00
Stefan HallerandClaude Fable 5.1 2b9bf522e1 Record PR 2 round 3, on a path with a space in it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 09:17:29 +02:00
Stefan HallerandClaude Opus 5 127a8e1389 Correct what PR 8 round 2 costs a diff renderer, and record its Windows pass
Delta and difftastic both rendered the preview normally with the trees a
line ending apart, so only the warning was ever visible. The setting
behind it was core.autocrlf in the system config Git for Windows
installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 19:48:35 +02:00
Stefan HallerandClaude Opus 5 62879dd982 Record PR 8 round 2, on the line endings of the custom patch's trees
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 19:30:27 +02:00
Stefan HallerandClaude Fable 5.1 a27583c200 Record PR 6's first round
The lint failure at PR 6's tip came out of the 2026-09-19 replay, and the
fixups for it sit on two PRs. Write down why the wrapper leaves PR 6 and
comes back in PR 7, and that the PR 7 commit between the two fixups is
meant to stay unbuildable until they are folded in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 09:38:39 +02:00
Stefan HallerandClaude Fable 5.1 9a45724e6a Record PR 4's first round
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 15:12:21 +02:00
Stefan HallerandClaude Fable 5.1 7526f4d9cd Record the review of the branch rendering without a pty on Windows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 21:25:08 +02:00
Stefan HallerandClaude Opus 5 49d59cf29d Record PR 2 round 2's second fixup
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:05:43 +02:00
Stefan HallerandClaude Opus 5 f5af3b6204 Record PR 2's second round
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:14:33 +02:00
Stefan Haller eb341e5a76 fixup! Record PR 11's first round 2026-09-17 20:15:55 +02:00
Stefan HallerandClaude Opus 5 893358211f Record PR 11's first round
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:09:18 +02:00
Stefan HallerandClaude Opus 5 8786b2295a Record the copy PR being split out, and its first round
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 21:06:49 +02:00
Stefan HallerandClaude Opus 5 bb580a03de Record that the scroll refinement moved down to PR 5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:26:56 +02:00
Stefan HallerandClaude Opus 5 4a67efe1d1 Record PR 12's first round
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 15:59:01 +02:00
Stefan HallerandClaude Opus 5 540911d04d Record PRs 11 and 12
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:02:28 +02:00
Stefan HallerandClaude Opus 5 ccc48bdb46 Record the assertion that keeps the conflict-hint click on the record
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 12:15:29 +02:00
Stefan HallerandClaude Opus 5 8b5c6ce29e Record that the modified click may open a conflict hint after all
The click names its own line, so it needs none of the gate the edit
keybinding needs, and for a modify/delete conflict the file it opens is
in the working tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 12:02:18 +02:00
Stefan HallerandClaude Opus 5 a509f46311 Record the first review round of the modified-click branch
The branch's opening commit belongs in PR 5, and moving it there proved
much cheaper than the note in the plan had assumed. Four more findings
needed code, two of them in gocui.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 20:55:52 +02:00
Stefan HallerandClaude Opus 5 e6e7d910da Record why the conflict-hint fix is a commit and not a fixup
A fixup on "Show git's own diff when the renderer's can't be acted on"
would have folded two decisions into one commit: which diff to render,
and whether the content can be pointed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 18:06:06 +02:00
Stefan HallerandClaude Opus 5 c53b72a3fa Record the round that came of testing the wrap option
Two problems from testing PR 9: wrapLinesInDiffView governed everything
the two main panes showed, and the hint for a conflict that has to be
resolved by picking a side had a selection over it. Both are answered by
having a render say whether it holds the panel's diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 13:08:00 +02:00
Stefan HallerandClaude Opus 5 6c720558a6 Record the three rounds that came of testing the focused main view
Two bugs reported from using the stack and a third found while reproducing the
first (PR 2 round 1, PR 5 round 5, PR 8 round 1), then two more rounds as each
fix in turn was tested and found short: the pane answered "is there anything to
select here?" one screenful too early (round 6), and then took the answer over
from the commit before while it still couldn't tell (round 7).

Round 6 carries the measurements from the reported commit, which say what each
fix is worth. §8 gains the diffstat row the user has since decided to keep, and
loses the row round 7 closes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 14:59:08 +02:00
Stefan HallerandClaude Opus 5 10ae7e4c20 Record the searched-main-view branch at the foot of the stack
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:57:19 +02:00
Stefan HallerandClaude Opus 5 f5de72780a Record the refresh race the Edit hunk test uncovered
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:47:41 +02:00
Stefan HallerandClaude Opus 5 6dbcd28f0e Record that the Edit hunk port belongs to PR 7
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 20:01:21 +02:00
Stefan HallerandClaude Opus 5 bb0297be1c Record PR 9, its review round, and the branch below the stack
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 10:30:19 +02:00
Stefan HallerandGitHub Copilot cf24d5cf3d Record the modified-click implementation
PR 10 landed independently of the explorer removal and gained visible feedback for non-suspending editors. Capture its actual dependency, six-commit shape, pending terminal matrix, and the separately deferred click-selection behavior.

Co-authored-by: GitHub Copilot <noreply@github.com>
2026-08-20 17:07:29 +02:00
Stefan HallerandClaude Opus 5 7dce049eae Correct PR 8's commit counts
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:46:45 +02:00
Stefan HallerandClaude Opus 5 a87aa06611 Record PR 8 as implemented, with its deviations
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:45:20 +02:00
Stefan HallerandClaude Opus 5 2b5975375d Record round 5's sign-off and its two further fixes
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:26:16 +02:00
Stefan HallerandClaude Opus 5 0a01a1792d Record the round on the main section changing hands
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:35:26 +02:00
Stefan HallerandClaude Opus 5 1f334e1d4d Record the round on scroll preservation and focus handling
Four problems, the one root cause behind three of them, the prep refactor the
focus rule needed, and the two gaps left open. Also corrects a claim made
while the round was being scoped, that a diff renderer is re-laid-out
whenever the view width changes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 08:03:08 +02:00
Stefan HallerandClaude Opus 5 c65733ab22 Record the review round on non-conforming diff renderers
Two defects and their fixes, the testing setup that reproduces them, and
the rebase traps the round turned up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 19:51:24 +02:00
Stefan HallerandClaude Opus 5 47ad0f029d Sign off PR 7 in the plan
Record the four review comments the interactive pass came with, what was
decided for each, and where their fixups landed — three of them belong to
PRs 5 and 6 rather than to PR 7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 20:24:20 +02:00
Stefan HallerandClaude Opus 5 e4d462eab8 Record PR 7's first review round
The staged-side branch grew out of it, PR 7 was rebuilt on top, and the
selection-visibility rule became a question about content rather than about
which kind of task rendered it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 15:06:22 +02:00
Stefan HallerandClaude Opus 5 ad852cdb22 Record the branch that moved the staged side to the lower pane
It sits below PR 7, which is rebased onto it and simpler for it: which side a
pane shows is now a property of the pane, and the focus-follow rule is
symmetric.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:51:34 +02:00
Stefan HallerandClaude Opus 5 b000dc3a81 Record PR 7 in the plan
Seventeen deviations, of which the ones that bind later work are the split
seam (PR 8's panels satisfy the extension interface), the stale timing fact
about what a refresh has done by the time it returns, and the restore's new
Done hook.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 08:30:28 +02:00
Stefan Haller cad2e9be9d Sign off PR 6 in the plan 2026-08-15 22:56:57 +02:00
Stefan Haller d7ae6c12ca Record PR 6 in the plan
Two things bind later work: a restore keeps the task reading to the end of
its input, because a rendering that has to be parsed as a diff can only be
parsed whole, and identities match through a key in which every kind of
content line collapses together - which is what lets a change that becomes
a context line still count as the same place, and a hunk header not.
2026-08-15 22:08:58 +02:00
Stefan Haller a19f7705ce Sign off PR 5 and give PR 6 a whitespace-toggle consumer
The interactive pass on PR 5 found nothing to fix; refining the behaviour
of a few special cases can wait until the remaining PRs are written.

Toggling "ignore whitespace" re-renders the diff just like a context-size
change or a renderer switch does, so it belongs in PR 6 as a third
consumer. It is the first one whose anchor can vanish along with its hunk
or its whole file, which invalidates the candidate walk's reason for
stopping at the first change line; the walk goes unbounded instead, so the
restore lands on the nearest surviving line wherever in the diff that is.
And once staging happens in the main view, master's refusal to ignore
whitespace while staging no longer catches it - parked as an open question
for PR 7.
2026-08-15 20:52:55 +02:00
Stefan Haller 87f652b7e6 Reorder sections so that they are in chronological order 2026-08-15 20:27:27 +02:00
Stefan Haller 6017a38098 Note PR 5's diff-line file split in the plan 2026-08-15 19:45:54 +02:00
Stefan Haller 26e069637a Record PR 5's third review round in the plan
The selection commit is split in four, comments no longer point at the
staging view, and the selection commands are described and enabled only
where they apply.
2026-08-15 18:59:29 +02:00
Stefan Haller 0385c85f52 Record PR 5's second review round in the plan
The narrowSelectionHighlight config is dropped in favour of an unconditional
narrow bar, and the selection is clamped to the content at end of input.
2026-08-11 16:27:47 +02:00
Stefan Haller a172026728 Record PR 5's first review round in the plan
Four bugs from the user's pass plus one found while fixing them; also the
open question of whether narrowSelectionHighlight should be a config option.
2026-08-11 07:47:52 +02:00
Stefan Haller 488f742431 Record PR 5 in the productionization plan
Nine commits landed; the jump-to-file menu is skipped and copy moves to
PR 7, where the interface it belongs on gets built.
2026-08-10 07:34:34 +02:00
Stefan Haller 0acbaadcd6 Correct the spec's location and the real-renderer test conditions 2026-08-09 18:10:40 +02:00
Stefan Haller bfb4a4f621 Record PR 4's sign-off and the real-renderer test question 2026-08-09 17:40:21 +02:00
Stefan Haller 9d8e9f6929 Record PR 4 as implemented, with its deviations 2026-08-09 15:53:31 +02:00
Stefan Haller 40bf5fb399 Record PR 2 as implemented, with its deviations 2026-08-09 13:54:45 +02:00
Stefan Haller 81ee437f3a Say newContentPending rather than resetOriginPending
resetOriginPending was the old name that we initially used, it has been
renamed to newContentPending meanwhile.
2026-08-09 12:01:53 +02:00
Stefan HallerandClaude Opus 5 db1734d0ff Record PR 1's final history: no commit repairs an earlier one
The two findings from the interactive pass were folded back, which took the
loading-indicator gate to before the off-screen render — where the blanking
would otherwise arrive — and made the amend! for the origin reset
unnecessary, since the flag it needs now already exists by then.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:43:52 +02:00
Stefan HallerandClaude Opus 5 7c136c5755 Sign PR 1 off, and say one thing about the reset flag rather than two
Deviation 6 still described the TaskOpts field as the shape PR 6 should
build on while deviation 8 said it was gone, so a reader hitting 6 first
would follow a design that no longer exists. Deviation 6 now describes the
final shape and what it means for PR 6, and PR 6's own commit 1 says the
same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:09:49 +02:00
Stefan HallerandClaude Opus 5 8e27ed2ff4 Record PR 1's first interactive pass and the branch below the stack
The regression it found retires the PR 6 note from deviation 6, and the
race fix establishes where unrelated bugs found during this work go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 08:20:19 +02:00
Stefan HallerandClaude Opus 5 8283e997f5 Record PR 1 as implemented, with its deviations
Two of them constrain later work: TaskOpts.ResetOrigin replaces the planned
LinesToRead.ResetOrigin that PR 6 was going to extend, and the screenColMax
gap PR 1 had to fix is still live on the prototype branch that later PRs
transcribe from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 22:57:01 +02:00
Stefan Haller 4ca47482ec Plan for git being able to emit the metadata itself
The plan assumed git would never emit the records, so PR 4 skipped the
advertisement for rawGit renderers and PR 7 listed "how should rawGit
entries decide the raw fallback" as undecided, with a static args-present
rule or a well-formedness gate as the candidates. git can emit them now,
which answers that question better than either candidate: probe a rawGit
entry like any other renderer and the fallback collapses to a single rule
for every renderer type.

Record the two traps as well, since neither is visible from the outside --
the advertisement has to precede newPtyTask's no-pty early return, and the
probe's cache signature has to include the args.

Say explicitly that none of this waits on git upstream. The probe asks the
installed git what it can do, so a stock git degrades to exactly the
behaviour of a non-conforming renderer, and a fork covers the users who
want the feature if the patch is never accepted.
2026-08-08 12:59:00 +02:00
Stefan Haller 001d85153a fixup! Support git's own word diff as a metadata-emitting diff renderer
Probe git for the handshake like the other renderers, not for a record.

git announces itself for exactly the formats it describes, so asked with
the renderer's own arguments the handshake already answers the question,
and on empty input at that. Looking for a record was over-specified and
made git the one renderer probed differently for no reason.

The two probes that now both stand a diff up from two empty temp files
share that setup.
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 5 07ec8a72b6 Advertise the metadata protocol to git as well, not only to a pager
A rawGit diff renderer needs no pty -- git renders the diff itself, and
only a pager needs a terminal to be spawned at all -- so newPtyTask hands
that case straight to newCmdTask. But the OSC1717 advertisement was set
forty lines further down, past that early return, so git was never asked
to annotate its output and the word-diff renderer we just started
trusting emitted no records.

Set it before the branch, next to LAZYGIT_COLUMNS, which is there for the
same reason. Nothing else changes: a renderer that doesn't know the
variable ignores it, and git says nothing for the formats it doesn't
annotate, which is every format a rawGit renderer without word-diff
arguments produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 5 473f83ec49 Support git's own word diff as a metadata-emitting diff renderer
git now emits the diff line metadata records itself, for the word-diff
formats -- the ones whose output can't be read back from its own text,
which is the same reason we need records out of delta and difftastic. So
a rawGit renderer configured with --color-words is no longer a diff we
have to give up on: it names every row it shows, as a patched pager does.

Two things stood in the way. The probe only knew how to ask a stdin
filter or an external diff driver and reported false for anything else,
so git's own records were never looked for; and the focused main view
treated a rawGit renderer with arguments as unresolvable by definition,
re-rendering it raw whatever the probe said.

So probe git the way we will run it, with the renderer's own arguments,
and look for an actual record rather than the handshake the other probes
settle for. git annotates only some of its formats, so an installed git
that doesn't speak the protocol and arguments that select no word diff
both leave us without records, and looking for a record answers both at
once. Arguments that aren't a word diff (-U10, --stat) therefore keep
rendering raw when focused, as they did before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 5 450e295ec3 Correct the diff-metadata notes where they contradict the spec
We stopped updating these notes once the real work moved to
focused-main-view-notes.md and the production plan, but two things in them
have since become actively misleading rather than merely stale: they say in
five places that the protocol is content-lines-only, and they use the env
var name from two renames ago.

Mark the document historical, name the spec as the authority, and correct
§11's outcome banner in place rather than rewriting it, so the reasoning
that led to dropping `f`/`h` is still on record next to the reason it was
reversed -- which is the very cost that banner judged acceptable.

Don't touch the `EMIT_OSC1717_METADATA` spellings further down: they are
what those prototypes were built with, and the status block now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 d0a0046f1c Update the plan for the rebase onto master and the landed config rework
The prototype was rebased onto master past #5854 (gocui mouse gestures)
and #5870 (diff-renderer config rework), so the SHAs quoted in the plan
now resolve only on the pre-rebase copy at
fold-staging-functionality-into-main-view-plan; note that in §1 and in a
dated log in §10.

PR 3 is done: #5870 landed the rename with a bigger config restructure
than planned (per-entry 'type' field, unified 'command', 'rawGit' type,
DiffRendererConfigManager). Rewrite the PR 3 section as a summary of
what landed and its consequences, key the affected decisions in PRs 4-7
off GetDiffRendererType(), add rawGit as a new case for PR 7's raw
fallback, and resolve the open question about the 'pager:' field name.

PR 10 commit 3 is superseded: the rebase dropped the press-only
modifier fix, and #5854's gesture model makes press-only insufficient
anyway; the re-implemented prototype commit carries the press-time
modifiers through the whole gesture, and the plan now points at that
shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 adaca25fa9 Carry the press-time keyboard modifiers through the whole mouse gesture
A click is reported on the button press, but the press never set the
event's modifier, so a modified click (alt/shift/ctrl+click) reached
handlers stripped of its modifier, indistinguishable from a plain
click. An earlier fix for this ("Carry the keyboard modifier on mouse
click events") was lost while rebasing onto master's mouse gesture
handling (#5854) — and that work also changes what the right shape is:
it isn't enough for the modifier to ride the press alone.

Drag events and the release are delivered to the view that owns the
gesture, and bindings match modifiers exactly. If the press carried alt
but the drags carried plain ModMotion, an alt-press that no binding
consumed would start matching unmodified drag bindings mid-gesture
(drag-select), and the release of a modified gesture would look like a
plain one. So snapshot the modifiers at press time and stamp them on
every event of the gesture: the press, each drag event (combined with
ModMotion), and the release; modifier changes while the button is held
don't alter the gesture. TestMouseReleaseDoesNotKeepPressModifiers
asserted the opposite for the release and is inverted accordingly.

As before, this means a modified click that nothing binds is a no-op
rather than silently acting as a plain click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 b50990a8f6 Fix gofumpt formatting slip from the rebase conflict resolution
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 4bcb733b5c Upgrade five prototype-deferred gaps to mandatory production scope
Review clarified that "deferred" in the gap table meant "not addressed
while prototyping", not "optional". Five items are mandatory:

- IsSingleHunkForWholeFile (PR 5 commit 3): regression vs master
  otherwise. Single-file-ness comes from the side panel selection; the
  single-block computation's lean is patch-space over the raw diff
  fetched at focus, with ReadToEnd-on-focus as the accepted fallback.
- Nav targets beyond the loaded content (PR 5 commit 6): ReadToEnd and
  re-scan like openSearch; free if the above reads to end on focus.
  Covers the jump-to-file menu too.
- Copy (PR 5 commit 9): reframed — copy the corresponding raw diff
  lines, never the renderer's output. Dissolves the can't-tell-if-the-
  renderer-preserves-the-prefix-column problem instead of solving it.
- Deleted-file MD-vs-D (new PR 7 commit 5): staging a deleted file's
  entire content stages the file deletion itself.
- Renderer switch shifting the inclusion checkmarks mid-build (new
  PR 8 commit 10): recompute from the new buffer at render completion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 bf221a7a7a Update the plan for the difftastic secondary-removal diagnosis
A parallel session committed the zero-width OSC-record carrier
(fe8022827) and diagnosed the secondary patch pane's removal as broken
under difftastic: the ordinal bridge assumes displayed change lines
match the patch's change lines in order and multiplicity (difftastic
reorders d/a per hunk and collapses modification rows), and an external
diff tool over the temp trees emits the literal a/-b/ paths in its
records, so the patch-builder file lookup no-ops. PR 8 commit 7 now
prescribes the identity bridge (all payloads per row, matched against
identities from the raw temp-tree diff, k-th match -> included[k]) and
records the path-leak as an open sub-item to decide with the user; PR 4
commit 1 references the now-committed carrier, which the bridge relies
on for the d half of collapsed modification rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 589c0822ba Keep OSC 1717 records whose region is zero-width
A rendered row can carry several diff-metadata records back-to-back with
nothing painted in between: difftastic's per-hunk banner emits the
file's f immediately followed by the hunk's h, and a modification row
collapsed to a single column emits its d immediately followed by its a
(spec 6.1/6.2). The escape interpreter kept only one accumulating
payload and reset it when the next record started, so every record but
the last was silently dropped -- the banner resolved as a bare hunk
header, and staging a collapsed modification row staged only the
addition half.

Orphan an unconsumed payload instead of dropping it: when a new record
starts (or the line ends) before any cell consumed the current payload,
hand it to the write loop, which materializes it as a content-less
zero-width carrier cell -- the same trick finishLine already used for
delta's metadata-only blank lines, now generalized. Carrier cells hold
their place in left-to-right payload order and are invisible: drawing
paints a transient space that the next cell immediately overwrites at
the same x, and their zero width keeps wrap accounting unchanged.

DiffLineMetadataPayloads thus reports every record of such a row, so
ChangeLinesInViewRange stages both halves of a collapsed modification
row; DiffLineMetadataInLine reports the first (the f of a banner, the d
of a collapsed row), matching the two-column convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 12d3dac8b3 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>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 87701d427f Add the productionization plan
The prototype is complete and signed off; this is the plan for
re-implementing it as a stack of clean PRs off master. It divides the
work into ten PRs (grouped for release-notes value as much as for
technical cohesion), outlines the commits of each, records the scope
decisions made in the planning session (panels removed, enter/dive
gesture dropped, stacked PRs within one release, both extras in scope,
nav/preserve as early standalone PRs), lists everything from the
prototype that must NOT be ported, and carries the sign-off matrix,
separate-lists compatibility seams, and known-gap dispositions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 18e4dba0be Change env var from OSC1717_METADATA to OSC1717 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 1fcbc4d5a5 Parse the f/h header records a conforming pager emits
The spec regained file-header (f) and hunk-header (h) records: f never
carries a line number, h always does (the first line of the hunk it
heads). Accept them in the OSC metadata backend, so a conforming pager's
header rows resolve to the same DiffLineFileHeader/DiffLineHunkHeader
identities the buffer parser already reports for raw diffs.

With header rows located, next/previous file navigation and the
jump-to-file menu land on a file's header row, and header rows become
usable scroll-restore anchors. The consumers need a few adjustments:

- File navigation used to reach a file's top by backing up over the
  untagged rows above its first located row. With tagged headers that
  overshoots onto the blank separator row above the file header, so drop
  the back-up (backUpOverHeader) and land on the first located row
  itself: the header for any conforming source, or the first content
  line under a pager that leaves its headers untagged — an accepted
  degradation for non-conforming pagers, now that the spec makes f/h
  mandatory.

- SamePatchLine now requires headers to match headers of the same kind.
  A hunk header shares its line number with the hunk's first content
  line (and a file header shares "0" with a deleted file's hunk header),
  so without this a position restore aiming at one could land on the
  other. This also applied to raw diffs before, but headers used to be
  unlikely restore targets; now that navigation deliberately lands on
  them, the ambiguity would bite.

- Editing a file-header row opens the file without jumping to a line,
  like pressing edit on a whole file in a side panel. (It used to open
  at line 1 for raw diffs, where headers resolved already.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 3b8446d762 Delete the spec from here
We maintain it in the osc-1717-spec branch; I want to avoid confusion as
to which one is newer, so remove it from here.
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 1e6e55a755 Add a jump-to-file menu to the focused main view's diff
n / N step through the files of a multi-file diff one at a time, which
is tedious when the diff spans many files. Add `f`, which pops up a menu
listing every file in the diff — in the order they appear, as
repo-relative paths — so you can jump straight to one.

Picking a file reuses the exact landing logic n / N use (navigate to the
file's first row), computed from the same backUpOverHeader machinery
AdjacentFile uses, so the menu and the step keys agree on where each
file begins and land identically.

The menu title and the keybinding description are hard-coded English for
now; this is a prototype.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 97b1b4cf7f Offer commit and find-fixup-base in the focused main view
The focused main view now plays the role the staging panel used to,
so the working-tree commands the staging panel offered — commit (and
its variants) and find-base-commit-for-fixup — need to be reachable
there as well.

Gate them to when the focused main view actually shows the working-tree
diff (DiffMainViewTypeStaging), which is exactly the role the staging
panel filled. Over a commit's or stash's diff these commands would
operate on the working tree, unrelated to what's on screen, so the keys
are a no-op there and the bindings don't clutter its keybinding menu.

The gate is re-checked on each press rather than captured at
registration time, since one keybinding set serves the main view over
every panel. That requires reading the panel beneath the main view from
GetKeybindings (to decide whether to show the descriptions), which runs
for off-stack contexts too — at startup and during cheatsheet
generation — so a panic-safe IsInStack guards the NextInStack lookup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 140cc03fd1 Session notes: rebased onto master's rename support — §21.36
Records the two rename-handling gaps the rebase onto f84ada494 leaves
for productionization: the previousPath="" conflict resolutions in the
focused-main-view patch code, and the failing renamed_file_whole e2e
(a view-rendering regression in the prototype's patch package, not a
build regression and not the previousPath issue).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller f8fe248678 Shorten the diff-metadata env var to OSC1717_METADATA
Pager authors reviewing the protocol asked to drop the EMIT_ prefix: the
OSC number already names the protocol, so EMIT_ was redundant. Rename the
handshake variable to OSC1717_METADATA in the spec and in the two places
lazygit advertises it (the pager PTY and the metadata probe).
2026-08-08 12:59:00 +02:00
Stefan Haller 7f5296205d Allow changing context size during custom patch building 2026-08-08 12:59:00 +02:00
Stefan Haller 6e5e2b4612 Session notes: §21.35 follow-ups — temp dir, gutter-on-secondary, toggle auto-advance 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 7f66f7620d Keep the inclusion gutter visible when the secondary patch pane is focused
The inclusion gutter (the ✓ markers on the commit diff showing which lines are
in the custom patch) is painted on the Normal pane but is an affordance of the
whole focused-main-view pair. It was gated on the Normal pane specifically being
current, so tabbing to the secondary (custom-patch) pane hid it — even though
both panes are visible and you're still building the patch.

Show it whenever either pane of the focused main view holds focus, finding the
side panel beneath whichever pane is current. GetOnFocusLost now re-evaluates the
gutter (rather than unconditionally hiding) so it persists across a pane switch
but still hides when focus leaves the pair; the new context is already current by
then, so it decides correctly and doesn't flicker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 ece5f0fb66 Advance to the next hunk after toggling one into a custom patch
Staging a hunk from the focused main view advances the selection to the next
hunk, because staging removes the acted-on lines from the diff so the preserved
change-line ordinal lands on the next change. Toggling a hunk into a custom
patch left the selection sitting on the just-toggled hunk instead — the toggle
doesn't change the diff (only the inclusion set), so the same ordinal lands back
where it was, and you had to navigate by hand to build a patch hunk by hunk.

Give the toggle the same feel by advancing the reveal ordinal past the toggled
change lines (RevealSelectionAfterStaging gains an advanceBy arg, fed the toggled
change-line count). Staging and removal still pass 0 (their lines are consumed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 49df8caf82 Create the custom-patch temp dir under lazygit's configured temp dir
Use osCommand.GetTempDir() (lazygit's own per-session temp dir, which it
creates and cleans up) as the parent for the custom-patch diff trees, instead
of the OS default — so it honors the configured temp dir and is cleaned up with
the rest of lazygit's temp files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller c3d6b39aeb Session notes: patch-building secondary pane — space-removes + pager rendering (§21.35) 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 da4dfa17d5 Render the custom-patch secondary pane through the pager, via a real git diff
The secondary pane showed the custom patch with a bespoke in-memory render
(PatchBuilder.RenderAggregatedPatch / FormatView). That had two problems: it
could never be fed to a pager the way the main view's diff is (a stdin pager
might have worked, but an external diff tool like difftastic, which diffs two
files rather than a unified diff, could not), and its hand-rolled hunk/context
handling differed subtly from git's.

Materialize the patch instead as two real file trees under a temp dir — a/ holds
each patched file's "from"-side content, b/ that content with the patch applied —
and render it with `git diff --no-index`, reusing the exact pager wiring the main
view uses (stdin pager, external diff, or git's own colour as the raw fallback).
`--no-index` honors both GIT_PAGER and --ext-diff, so every pager type now renders
the custom patch like any other diff, and git computes the context, fixing the
quirks. Because the secondary is now an async diff task, the post-removal
selection reveal (which rides a task's restore) finally takes effect.

The trees are named a/b so that with --no-prefix the diff shows the real
repo-relative paths; added files are seeded empty in a/ so they pair up and show
their real paths rather than git's directory-comparison "added in b" form. The
patch builder owns the temp dir's lifetime (created on Start, removed on Reset)
and bumps a generation counter on every change, so the trees are rebuilt only
when the patch actually changes — covering the focused-main view and the old
explorer alike, without rebuilding on mere navigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 f7e26fec4e Make the patch-building secondary pane's space remove the right lines, disable its discard
The focused-main-view rework made the secondary pane actionable, but the old
patch-building explorer's secondary was inert, so its actions were never thought
through. Pressing space there routed through the same toggle handler as the main
pane, resolving the selection against the secondary's diff and mapping it to
patch-builder indices by line number. But the secondary shows the *aggregated*
custom patch, which renumbers included additions whenever an earlier addition in
the same hunk is excluded (Transform recomputes each hunk's +start). So the
shifted number resolved to the wrong line in the original diff — often adding an
unrelated line instead of removing the selected one.

Resolve the secondary selection by its *ordinal* among the change lines shown
instead: the custom-patch view renders exactly the included change lines in
order, so the k-th change line of a file is that file's k-th included change line
(PatchBuilder.IncludedChangeLineIndices), independent of the renumbering. Space
in the secondary now only ever removes, mirroring how space in the staging view's
staged pane unstages.

Discarding from the commit (the remove key) makes no sense in the custom-patch
preview — it would act on lines shown only as the patch, and space already
removes them — so it's disabled there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 6f83f02d92 Session notes: delta-vs-selection bg fight resolved (§21.34) 2026-08-08 12:59:00 +02:00
Stefan Haller f4384add58 Try yet another solution for the fight over the bg color (delta vs. selection) 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 d355cb58a3 Session notes: selection-preserve net across commit rewrites (§21.33)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 3375637d93 Drop the bespoke commit-discard selection reveal
Discarding lines from a commit via the focused main view installed its own
revealSelectionAfterPrimaryAction before the rebase. That rebase rewrites
the commit, so the selection-preserve net now re-establishes the selection
as the diff re-renders — with the same anchor (the selection's first line),
making this install redundant. Files-discard keeps its own reveal: its diff
command is stable, so the net never fires there, and it has staging's
focus-follow to the secondary pane besides.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 fdb16a1c7e Preserve the focused main view's selection across commit rewrites
Dropping a hunk with `d` re-establishes the focused main view's selection
on the next surviving change, which feels great. But other operations that
rewrite the commit under the focused main view — moving a custom patch out
into the index, undoing right after a discard or a patch move — don't run
through the focused-main-view action handlers, so nothing was preserving
the selection. The stale gocui selection was left painted over the new
content, often as a large, now-meaningless range.

Rather than teach every such command to capture and restore the selection
(move-patch, undo, redo, and any future one), the focused main view now
preserves it itself, by its change-line ordinal, as the diff re-renders —
the command-agnostic counterpart of revealSelectionAfterPrimaryAction. The
diff side panels call it from their render-to-main before triggering the
render, so the restore rides the re-render.

It stands down unless the focused main view is current and shows a
selection, no precise restore is already pending (escape / post-stage
reveal / context-size place the selection more precisely), and the diff
command is actually changing. That last gate matters: a plain background
refresh re-renders the same commit's diff unchanged, and there the
selection — range and all — must be left alone; only a command change
(e.g. a rebase rewriting the commit's hash) means the content moved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 49e2fbc076 Session notes: drop commit SHAs from §21.32
SHAs go stale on every history rewrite of this throwaway branch; describe
commits by what they did instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller b3ac8fc259 Session notes: correct the patch-explorer claim for the driver fix (§21.32)
The driver fix is a no-op for the old staging panel, not a tightening: that
panel anchors its drag at the click on mouse-down, so it never exposed the
bug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 0f99ba625a Session notes: first-drag-event driver fix (§21.32)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 6481ee615a Session notes: drag-to-range anchoring fix (§21.32)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 96fa098845 Anchor a drag-selection at the clicked line in the focused main view
Click-and-drag in the focused main view turns a hunk selection into a range,
but it was anchored at the change block's far end — where selecting a hunk
leaves the range anchor — rather than the clicked line. And on a context
line, where the click leaves no range anchor at all, dragging just moved the
single selected line instead of opening a range. Remember the line each
mouse-down lands on (the click can show a whole hunk, so it can't be read
back from the view) and, as the drag proceeds, anchor the range there while
the cursor end follows the mouse as gocui already moves it. Works for a click
that focuses the view and for one while it's already focused, on change and
context lines alike.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 79ce7f3382 Session notes: two hunk-mode click/pager bug fixes (§21.32)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 3e4207d7fa Preserve a whole selection's extent across a pager or context-size change
When the diff re-renders for a new pager (or a changed context size),
PreserveDiffPositionOnRerender restored the cursor by patch identity but left
the selection's other end — the range anchor — pinned to its old view line. A
pager that restructures the diff (delta side-by-side is the clearest case)
then left the selection spanning the wrong range of patch lines. Remember the
far end by patch identity too and put it back the same way, so the selection
covers the same lines however the new pager lays them out. One mechanism now
restores both ends, covering hunk and range selections alike; a single-line
selection still needs only the cursor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 e03f4b1321 Carry hunk mode into the other pane on a cross-pane click
Clicking the staged/unstaged pane you weren't focused on selected a single
line the first time, even in hunk mode, because that pane's select mode was
still its default until it had been focused once (tabbing to it and back was
the workaround). Seed the clicked pane's mode from the one we're leaving so
the very first click there behaves like every later one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 0aab64e362 Session notes: hunk-on-click UX tweaks in the focused main view (§21.32)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 d80802c1f9 Keep hunk mode when clicking another hunk in the focused main view
Once in hunk mode, clicking another change line reset the selection to a
single line, so you had to press `a` again for each block you wanted to
stage (a behaviour the main view inherited from the staging and patch-
building panels). Preserve hunk mode across clicks instead: a click on a
change line re-selects that whole block, while a click on context — or any
click when we weren't in hunk mode — drops to a single line, where the click
points precisely (e.g. to edit it).

The two click handlers shared an identical selection body, so unify them
into one helper and make the change in a single place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 8395658e75 Select the clicked hunk when clicking into the main view in hunk mode
Focusing the main view by keyboard already selects a whole change block in
hunk mode; a click, though, only ever placed a single-line selection, so the
common "click the diff to stage this block" gesture still needed a follow-up
`a`. Now a click on a change line selects that line's block too. A click on
context still selects just that line, since the click points at it precisely
(e.g. to edit it with `e`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 6ac8c47091 Try another solution for the fight over the bg color (delta vs. selection) 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 3f3139de71 Spec: add diff-so-fancy as a third reference implementation
§10 now lists diff-so-fancy alongside delta and difftastic: the same #2 case as
delta's default (strips +/- markers, conveys side by color) but a line-oriented
Perl filter, unified-only, and -- the one wrinkle worth stating in the spec --
it strips terminal escapes from its content, so the record is prepended to the
line rather than embedded. Fix the now-stale "all three"/"both pagers" counts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 9273bb7e23 Design notes: diff-so-fancy emitter prototype (§12)
Record the third pager emitter (diff-so-fancy, commit c397cd6 on its
prototype-osc-metadata branch): the simplest of the three -- a line-oriented
Perl filter, unified single-column only, the same #2 category as delta's
default. Captures the diff-so-fancy-specific findings: sanitize_display strips
OSC so the record is prepended not embedded; the path is derived from
$file_1/$file_2 (not $last_file_seen, which is empty for a noprefix deletion);
combined diffs skipped; counters are file-scope globals so a chunk-spanning hunk
keeps counting; classification mirrors strip_leading_indicators so the
no-newline marker is correctly skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller fc134d643f Session notes: patched the three pager emitters + the spec (§21.31) 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 a6fedd0255 Spec: define the version-only handshake record
Add §4.4: a conforming pager emits a version-only OSC 1717 record (no further
fields) as its first output, so a host can probe it on an empty diff — which
emits no per-line records — and tell "speaks the protocol" apart from
"unsupported pager", content-independently. Used by the focused main view's
raw-diff fallback to decide whether a pager's output is actionable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 40c035fd79 Session notes: detection reworked observe → probe (§21.30) 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 c0ee7de776 gocui: swallow the version-only diff-metadata handshake OSC
A metadata-aware pager emits a version-only OSC 1717 record (no fields) as its
first output, to announce it speaks the diff-line-metadata protocol so lazygit
can probe for it. It isn't per-line metadata, so on a real render it must be
swallowed whole rather than lingering in the accumulator and attaching to the
following diff-header line (or, if newline-terminated, producing a phantom blank
line). Drop any OSC 1717 payload with no fields at its terminator; per-line
payloads always have fields, so they're kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller f3ef02556a Session notes: (D) raw-diff fallback for unsupported pagers done (§21.29) 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 ae1ab5ca1e Extend the raw-diff fallback to the commit/stash/patch-building panels
Wire the remaining diff panels — local commits, sub-commits, commit files,
stash, and reflog — into the focused main view's raw-diff fallback, the same way
the files panel already is: each computes DiffMainViewShouldRenderRaw and renders
its diff raw (no pager) when focused under a pager whose output we can't resolve,
so its selection stays toggleable into a custom patch (or, for reflog, navigable
and copyable).

The commit-diff panels build their diff through the diff helper, which can't
reach the staging helper, so the panel computes renderRaw and passes it in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 10f41bed50 Thread ignoreExternalDiff through the remaining diff-cmd builders
Prep for extending the focused main view's raw-diff fallback beyond the files
panel. ShowCmdObj, ShowFileDiffCmdObj, ShowStashEntryCmdObj and DiffCmdObj gain
the ignoreExternalDiff arg WorktreeFileDiffCmdObj already has — forcing git's own
coloured diff regardless of a configured external diff command. All callers pass
false, so behaviour is unchanged.

Also lift the pty-vs-command task choice out of the files controller into
types.NewMainViewDiffTask(WithPrefix), so the commit/stash/patch-building panels
(whose diffs are built in the diff helper, which can't reach the staging helper)
can select the same raw-fallback task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 2a69d4c140 Fall back to the raw diff when focusing a main view under an unresolvable pager
The focused main view is the staging surface, so it has to resolve the diff it
shows to patch-space. A pager that restructures the diff without emitting our
metadata (stock delta-default, plain difftastic, `cat -n`) produces output the
buffer parser can't read, which would leave its diff unstageable.

When such a diff is focused, re-render it raw — git's own colour, no pager — the
same content a no-pager setup shows, which the buffer parser handles. Browsing
keeps the pretty pager output; only focusing to act switches to raw.

Whether the pager is usable is decided by probing it: run it on empty input and
look for the version-only OSC 1717 handshake a metadata-aware pager emits first.
This is a pager-level, content-independent fact (a binary file, which has no
change lines under any pager, can't mislead it) and it's known before we render,
so we never render pretty only to discover mid-flight that we should have rendered
raw. The verdict is cached per pager (reset when the pager changes). No PTY is
needed — git needs a terminal to decide to invoke a pager, but the pager emits the
handshake regardless. A git-config external diff driver (useExternalDiffGitConfig)
is chosen per file via .gitattributes and a single diff can mix drivers, so
there's no one pager to probe; it's treated as unsupported (always raw).

Bypassing the pager needs two things, since a pager reaches the diff by two
routes: an external diff command (suppressed in the cmd via the new
ignoreExternalDiff arg, keeping git's colour, unlike plain) and a stdin pager
(GIT_PAGER, applied by the pty task — so the raw render uses a plain command
task instead).

This wires the files panel; the commit/stash/patch-building panels follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 482acff66d Session notes: (C) ctrl+o copy done (§21.28)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 f0a997705a Copy the selected diff line(s) from the focused main view
`ctrl+o` in the focused main view copies the selection to the clipboard,
mirroring the staging view's copy. Unlike stage / discard, copying is the same
for every diff panel — it just reads the text shown — so it's a direct
MainViewController command rather than a FocusedMainViewActions method. That
keeps it free of per-panel duplication and lets it work over panels that have
no actions, notably the reflog.

The +/- prefix handling is metadata-aware where it matters: with no pager the
main view shows the raw diff, so a homogeneous selection has its +/-/space
column stripped (dropDiffPrefix) to ease pasting into code. With a pager
configured the rendered lines carry no such column, so stripping a leading
character would eat real content — copy verbatim instead. (Refining the raw
case to classify header lines via the diff-line metadata, fixing the
'--- a/file' edge case noted in 159bbb0825, is left for later.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 a0af1b1d9a Session notes: discard bug fixes + selection visibility (§21.27)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 252a26cbc4 Hide the focused main view's selection when there's no diff to act on
The focused main view always showed a selection while it held focus, even when
its content was a placeholder ("No changed files", a merge-conflict message)
rather than a diff — so pressing a navigation key conjured a highlighted line
over "No changed files", and discarding the last change (or changes vanishing
outside lazygit) left a stale selection behind.

A selection should only show when there are change lines to act on. Two
moments establish or change the content:

- Focus: showInitialDiffSelection now leaves the selection off when the view
  has no change lines (ViewHasChangeLines), rather than highlighting a stray
  line at the top.
- Render: the side panel's render-to-main is where it decides between a diff
  and a placeholder, so that's where the selection's visibility is set —
  updateFocusedMainViewSelectionVisibility shows it only on the focused pane
  and only when a diff is being rendered. This covers the refresh cases that
  focus can't: discarding the last change, and changes disappearing or
  reappearing outside lazygit, all hide or restore the selection on the next
  refresh. (Focusing reuses the already-rendered content rather than
  re-rendering, which is why focus needs its own check.)

Wired into the files panel, where placeholders occur; the commit panels always
render a diff. Adds a SelectionIsShown/SelectionIsHidden test assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 26dcab23ae Session notes: (B) d discard a hunk done (§21.26)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 e9c4425df6 Discard the selected diff line(s) from the focused main view
Wire the focused main view's `d` binding to a DiscardSelection action on the
side panel beneath, the discard counterpart of the primary action. As with
stage/toggle, what discard means is the panel's business:

- the files panel discards from the working tree, mirroring the staging
  view's `d` — reverse-apply not cached on the unstaged side (destructive, so
  confirmed), reverse-apply cached on the staged side (i.e. unstage);
- the commit panels (commit files, and the whole-commit diff of the local
  commits / sub-commits / stash panels) remove the selected lines from the
  commit via a rebase, mirroring the patch builder's "discard lines from
  commit" — building a one-off patch from the selection and deleting it.

The commit-discard backend and its guard are shared free functions in
patch_building_from_main_view.go, paralleling the patch toggle. Discarding
from a commit is only possible on a local branch, so DiscardSelection grows a
disabled-reason companion on the FocusedMainViewActions interface: the `d`
binding is greyed with a reason on stash and other-branch sub-commits (never
rebaseable) and while a rebase is in progress, exactly as the patch builder
greys it. The files panel is always available (a zero-context diff is the one
error it reports inline, matching the staging view).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 99f80b1bc8 Generalize FilesController.stageDiffLines to applyDiffLines
The focused main view's discard command (coming next) needs to read either
side's diff and apply a patch with an arbitrary direction and target: discard
from the working tree reads the unstaged diff but applies a reverse patch that
is not cached. stageDiffLines couldn't express that — it used a single reverse
flag both to pick which diff to read and to reverse the apply, and hardcoded
Cached: true.

The read side and the apply direction are independent (they coincide only for
staging and unstaging), so split them: applyDiffLines now takes sourceCached
(which diff to read) and a git_commands.ApplyPatchOpts (how to apply). Renamed
from stageDiffLines since it now covers stage, unstage, and discard. Staging
passes the same values as before, preserving behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 2d5d149f5e Session notes: (A) dispatch refactor done (§21.25)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 88dae2f2c6 Collapse the focused-main-view handler channels into one
Each command a side panel handles in the focused main view needed its own
delegation channel: a HasKeybindings getter, an IBaseContext Add method, a
BaseContext field + getter, a baseController nil default, and an attach.go
registration — roughly five touch points per command. With three commands
(click, stage, toggle-patch) that was already a lot of boilerplate, and it
doesn't scale to the discard / copy commands coming next.

Replace the three channels with one: a side panel exposes a single
FocusedMainViewActions interface via GetFocusedMainViewActions (nil when its
diff offers no actions), and the controllers implement that interface
directly. The stage and toggle-patch handlers, already unified to a plain
error return, become one PrimaryAction method whose meaning is the panel's
business (stage for the files panel, patch toggle for the commit panels);
the click handler becomes OnClick. MainViewController is now a thin
dispatcher: fetch the actions from the panel beneath, call the method.
Adding a command is now one interface method, an implementation in the two
or three controllers, and a keybinding — no plumbing.

The toggle-patch channel did double duty as the "this panel builds a custom
patch" signal for the inclusion gutter (shown only beneath a patch-building
panel, not the staging files panel). Collapsing the channels removes that
proxy, so make the classification explicit on DiffMainViewContext, whose
marker method now returns a DiffMainViewType (none / staging / patch-building)
instead of being a bare marker. The gutter shows only beneath a panel whose
type is patch-building (commit files, local commits, sub-commits, stash).

Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 96b09bb41d Push the staging focus-follow into the side-panel handler
The focused main view's space binding delegates to the side panel beneath
it, but the two handlers were asymmetric: the patch-toggle handler did its
own re-render and re-established the selection itself, while the staging
handler returned a focusViewName so the dispatcher (stageRange) could do the
reveal-and-focus dance on its behalf. That split blocks collapsing the
per-command handler channels into one, since the two have different return
types.

Make staging match the toggle: GetOnStageFocusedMainView returns plain
error, and the FilesController handler does the post-staging reveal and pane
focus itself. The reveal/select-mode logic the two handlers shared (collapse
a range to a line, preserve the change-line ordinal across the re-render,
re-expand a hunk) is extracted into revealSelectionAfterPrimaryAction, with
mainContextForViewName resolving a main view name to its context. The
dispatcher is now uniform: read the selected range, hand it to whichever
handler the panel registered.

Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 1b02020022 Session notes: revised forward plan (§21.24) — drop steps 7/8, add commands + dispatch refactor + pager fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 4fe5211da0 Session notes: crash fix for resetting a patch built from the commits main view
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 f4e180c128 Session notes: step 6c done (patch building from the whole-commit main view)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 86ce3ba798 Build a custom patch from the commits / sub-commits / stash main views
Until now space toggled lines into a custom patch only from the commit files
main view (the per-file diff). Register the same toggle handler on the commits,
sub-commits and stash panels, so a patch can be built straight from the
whole-commit (multi-file) diff their main view shows, without first diving into
the commit files panel.

The handler lives on SwitchToDiffFilesController, which is already bound to
exactly those three panels and already knows how to derive the patch target
(from/to/reverse/canRebase) for the selected ref — pulled out of enter() into a
shared canRebase helper and the reused FromAndToForDiff. The post-toggle refresh
is the cheap one: these panels have no per-file patch indicator to update, so we
just re-render their own main + secondary views rather than reloading the whole
commit list on every keystroke.

Sub-commits and stash didn't render the secondary patch view at all; give them
the same secondaryPatchPanelUpdateOpts the commits panel uses, and recompute the
inclusion gutter as their diff (re-)renders, so the cumulative patch and the
gutter track the toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 26b27ed69b Extract the patch-toggle back end out of CommitFilesController
The next commit adds patch building from the commits / sub-commits / stash
main views, which toggle the same way as the commit files main view; only the
patch target and the post-toggle refresh differ. Pull the panel-agnostic back
end (the toggle skeleton, the line toggle, the post-toggle reveal, the patch
filename mapping) into shared free functions so both controllers reuse it.

The skeleton now takes the patch target (from/to/reverse/canRebase) and a
refresh callback as parameters rather than reading them from the commit files
context and hardcoding Refresh({COMMIT_FILES}). The commit files handler passes
exactly what it did before, so behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 e074c40cc9 Session notes: first interactive feedback on the gutter (4 of 5 fixed)
Records the visibility model (gutter visible iff patch-active AND main
focused), the selection re-select after the split re-render, the
all-wrapped-segments marker, and the deferred pager-switch bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 c14d9432cb Session notes: toggle refreshes normally; scroll preserved for free
Records the fix for the §21.20 live-update gap: the patch toggle now
refreshes (updating the browser indicators + secondary patch summary +
the split), scroll/selection are preserved automatically because the
re-render uses the same diff command, and the gutter rides the same
re-render via the pipeline's own content-equality test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 115582e9bc Session notes: step 6 built — inclusion gutter + patch toggle (6a/6b)
Records what landed, the confirmed sync (no re-render) insight, what
needs interactive sign-off, the known limitations, and the 6c/step-7
next steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 67ba1899b4 Build a custom patch from the focused main view of a commit's files
Pressing space in the commit-files focused main view now toggles the
selected line(s)/hunk into or out of the custom patch, instead of being a
no-op. It's the patch-building counterpart of working-tree staging from
the main view: the selection is resolved to change-line identities the
same way (ChangeLinesInViewRange), then mapped to the patch builder's
per-file line indices and added or removed.

The crux is that the commit's diff is unchanged by a toggle — only the
inclusion set changes — so unlike staging there's no async re-render to
ride. Membership is shown by an on-demand inclusion gutter: a reserved
left column painted with a marker on every change line currently in the
patch, recomputed synchronously after each toggle and on focusing the
main view, over the existing pager output. This is what lets patch
building work over a restructuring pager at all, where the old green
first-char overlay (which rewrites the rendered bytes) can't.

space routes to staging or patch building based on which handler the
panel beneath registers, via a new onTogglePatchFocusedMainView channel
(kept separate from staging because the post-action differs: sync gutter
repaint vs async reveal). Scoped to the commit-files panel for now;
commits/sub-commits/stash and the whole-commit multi-file diff follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 2fee7653f2 Extract stageRange from stageSelectedLine in the main view
Prep for routing space to custom-patch building when the panel beneath
the focused main view is a commits/commit-files panel. stageSelectedLine
becomes the router; the staging-specific body (async re-render + reveal)
moves to stageRange. Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 4c123f9414 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>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 12523c8a20 Add an on-demand inclusion gutter to gocui views
A reserved left-hand column that draws a per-line marker glyph and shifts
the content right past it, for the custom-patch inclusion overlay (which
needs to show which change lines are in the patch over arbitrary pager
output, where it can't touch the rendered bytes).

It's pure draw-time decoration: the content buffer, and so the diff-line
metadata, click resolution and wrapping inputs, are untouched. The marker
is drawn on a line's first wrapped segment only, and the gutter narrows
the content wrap width while shown. Off by default; SetInclusionGutter
turns it on per view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 01882cf2fd Session notes: step 6 design — the on-demand inclusion gutter
Records the plan for patch-building from the main view: the sync (no
re-render) vs async (staging) split, the included-set mapping, the
user's gutter decisions (on-demand, inclusion-only, checkmark), the
draw-time gocui rendering approach, and the 6a/6b/6c decomposition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 22e1c82405 Session notes: prototype-branch callout; step 5 signed off; step 6 next 2026-08-08 12:59:00 +02:00
Stefan Haller 2421e19f5d Session notes: reveal reworked to preserve change-line ordinal 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 cf89ef9d4f Preserve the change-line ordinal in the post-stage reveal
The reveal advanced the selection by capturing the next/previous change as a
patch identity and finding it again after the re-render. No line-number
identity is stable and unique across a stage, though: a deletion shares its
new-file number with the rest of its block (so staging one deletion of a block
landed back on the block's first line), and in the staged pane the new-file
number is the index, which staging/unstaging shifts (so unstaging a
modification's deletion missed its replacement line and fell back to an earlier
block). The previous worktree-line and adjacent-change-line matching each fixed
one case and left another.

Do instead what the staging view does: preserve the selection's ordinal among
change lines. Read the acted-on line's ordinal from the pane it was in before
the op; after the re-render, select the change line at that ordinal in the
target pane (clamped to the last). Since the op removes the acted-on change
line(s), that ordinal then holds the next surviving change — the next line of
the same block, or the next block when a whole block was staged. This needs no
reasoning about which side's line numbers are stable, so it handles deletions
and the staged pane uniformly.

The matching machinery (matchByWorktreeChange, AdjacentChangeLine) is gone; the
identity-matched restore (escape, -U preserve) and the new positional restore
now share installDiffLineRestore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 133dabb394 Session notes: line-mode reveal advances to next line, not next block 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 f461378598 Advance to the next change line, not the next block, in the reveal
The post-stage reveal picked its primary candidate with AdjacentChangeBlock,
which skips the rest of the acted-on line's block. That's right in hunk mode,
where the whole block is staged, but wrong in line mode: staging the first
line of a multi-line block left the rest of the block in place, yet the
selection jumped past it to the next block instead of landing on the next
(now first) line of the same block.

Use AdjacentChangeLine — the first change line strictly after/before the
selection — for the next/previous candidates. It unifies both modes: after a
whole block (whose last line is followed by context) the next change line is
the next block's, so hunk mode is unchanged; after a single line the next
change line is the next line of the same block. place stays mode-aware
(hunk-expand vs single line), so nothing else changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller df09df1940 Hack: inset the selection highlight by 3 in the main view
This makes it possible to see what lines delta renders as red or green.

It's not pretty, but makes testing easier for now. Needs a better
solution eventually.
2026-08-08 12:59:00 +02:00
Stefan Haller 377a352075 Session notes: rebased onto wrapped-fill branch; blank-line metadata + reveal worktree-match fixes 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 dcdc919a0e Match the post-stage reveal by worktree line, not old-file line
The reveal that advances the focused main view's selection after staging
captured the next/previous change block as a patch identity and found it
again in the re-rendered diff via SamePatchLine. SamePatchLine keys a
deletion on its old-file (index-side) line number, which staging shifts:
staging a hunk that changes the line count moves the index-side numbers of
every hunk below it. So a deletion-led "next hunk" candidate no longer
matched, and the reveal fell back to a worse candidate — often colliding
with a header or context row (SamePatchLine doesn't require a change line)
and, in hunk mode, snapping to the first change block. The visible result
was the selection jumping to an *earlier* hunk after staging.

Match the reveal's candidates by their worktree (new-file) line number,
which staging never moves, and require a change line of the same side. This
is safe here because the reveal only ever targets change blocks and
selectHunkAround expands to the whole block, so the new-file number's
ambiguity between two consecutive deletions (the reason SamePatchLine uses
the old-file number) doesn't matter.

The escape restore and the -U context-size preserve keep SamePatchLine:
they re-render the same staged/unstaged state, so no index-side shift, and
the latter deliberately anchors on context lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 131a315b7f Keep a metadata-carrying cell for a blank changed line
delta, fed a colored diff, renders some blank changed lines (an empty
deleted/added line) as just the OSC 1717 metadata followed by an empty
line — either with no fill at all, or as a background plus an ESC[0K that
the buffer now turns into a draw-time trailing fill rather than content
cells. Either way the line ends up with no cells, so its metadata had
nowhere to live and the line resolved to "not a change".

In the focused main view that broke change-block detection: a blank
deleted line in the middle of a hunk split it in two, so pressing 0
selected only the lines above the blank and the down arrow jumped to the
rest below it.

Restore the content-less sentinel cell finishLine used to add, but only
when there is pending OSC metadata to carry — so an empty line stays
resolvable as the change it is, without reintroducing the unconditional
sentinel (and its prevFgColor coupling) that was removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 2088408f43 Session notes: step 5 part 2 done — focus follows the acted-on side to the right pane 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 05c7b82a84 Follow the acted-on side to the right pane after staging from the main view
After staging or unstaging from the focused main view, focus stayed on the
pane the user acted in even when the side they were acting on moved to the
other pane. Two cases got it wrong:

- Unstaging the first hunk of an only-staged file splits the diff: the main
  half flips to show the just-unstaged change, and the staged remainder jumps
  to the secondary half — but focus stayed on the main half, away from the
  staged content the user was working through.
- Unstaging the last staged hunk from the secondary half empties the staged
  side and collapses the split, hiding the secondary half — leaving focus
  stranded on a hidden pane.

The rule is the same in both: focus the staged side while it survives. The
stage handler now reports which focused-main pane should hold focus —
the secondary half when unstaging leaves the file split, the main half
otherwise — and the controller re-selects the revealed change in that pane
and focuses it. The split is read from the model, which Refresh has already
updated synchronously by the time the handler returns; the re-render it
queues is what the reveal rides.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 a2e1254a96 Let the post-stage reveal target a different pane than the one acted on
Staging or unstaging can move the acted-on side to the other half of the
focused main view — unstaging the first hunk of an only-staged file splits
it, pushing the staged remainder to the secondary pane. The reveal that
re-selects the nearest surviving change after the op then has to ride the
re-render of that other pane, not the one the user acted in.

Split RevealSelectionAfterStaging's single view into a source (where the
candidate lines are read) and a target (where they're found again and
re-selected), and get-or-create the target's buffer manager so the restore
can be installed on a pane that hasn't rendered yet. Behavior-preserving:
the sole caller passes the same view for both, as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 e58f6d43ad Session notes: part 1 signed off; part-2 focus switch unifies with only-staged unstage bug; deleted-file note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 c2dc9c329f Session notes: step 5 part 1 (post-stage reveal) done; part 2 (empty-pane focus switch) scoped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 4882760690 Advance the selection to the next change after staging from the main view
Staging or unstaging a selection re-renders the focused main view's diff (the
staged lines move to the other side), which left the selection at a now-stale
view position — often off the new, shorter content, so it looked like there was
no selection at all.

Install a restore before the re-render that lands the selection on the change
nearest the one just acted on, the same way the staging view advances: the
change block after the selection, else the one before it, else the acted-on line
itself (which only survives when the whole side was staged and the view flips to
the other side). It re-selects in the current mode, so hunk staging walks hunk to
hunk. A range selection collapses to a single line first, since staging consumes
it. This rides the existing restore-by-identity machinery (as the escape restore
does), so it works over any conforming rendering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 01b2f621f9 Session notes: step 3-4 signed off (SxS works incl. difftastic); unstage focus issues -> step 5
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 8b04f49c2b Session notes: step 4 done (SxS multi-record + multi-file staging)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 4a33b42d19 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>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 a5a0506913 Stage a selection spanning several files from the focused main view
The focused main view of a directory node shows a multi-file diff, so a range
selected in it can span more than one file. Group the selected change lines by
file and apply one patch per file, logging the action and refreshing once around
the whole batch. The stage/unstage direction is uniform — the whole diff is
rendered on one side — so it's decided once for the batch.

Each change line is mapped back to its file by the path its diff-line metadata
carries, which also lets a single-file selection go through the same path
instead of relying on the selected node being the file; staging from the focused
main view no longer bails on a directory selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 ec1bdaafb3 Session notes: record session-13 interactive feedback on step 3
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 3acbaac361 Session notes: step 3 done (range + hunk selection + range staging)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 26a784ef74 Select the first hunk when focusing the main view in hunk mode
With hunk mode the default (gui.useHunkModeInStagingView, on by default),
focusing the main view now selects the whole first visible change block rather
than a single line, matching what entering the staging view does and what the
just-added hunk staging expects. In line mode it still anchors on a single line,
as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 4b57cc9efe Select and stage ranges and hunks from the focused main view
Step 1 made the focused main view a staging surface for a single line. This
adds the range and hunk selection the staging view has, so you can stage (or
unstage) a range or a whole hunk in place — `v` starts a range, shift-up/down
extend one, `a` toggles hunk selection — without diving into the staging view.

The selection mode (line / range / hunk, plus sticky-range and "the user turned
hunk mode on themselves") lives on MainContext rather than the controller,
because three places need a pane's mode: the main view controller that drives
it, the focus controller that resets it when you focus the view, and togglePanel
which sets it on the *other* pane. The selected line and the range anchor stay
in the gocui view itself (its cursor and rangeSelectStartY), so the existing
native range-select rendering draws the highlight — no new highlight machinery.
Hunk bounds come from the diff-line metadata (the isChange run around the
cursor, via ChangeBlockBounds), not from a parsed patch, so they work over any
conforming rendering. The up/down keys are mode-aware: hunk mode steps hunk to
hunk, a non-sticky range collapses on a plain move, a sticky range extends.

GetOnStageFocusedMainView now takes the selected view-line range instead of one
line; the files handler collects the change lines in the range and applies a
single patch. It identifies each change line's patch line by scanning the parsed
patch and matching (file line number, deletion?) identities, rather than looking
each number up with PatchLineForLineNumber. That lookup can't tell an addition at
the very start of a hunk from the deletion above it, nor an addition from the
deletion it replaces on a modified line — both of which a range routinely spans.
As a side effect this also fixes staging a change on the first line of a file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 ee0077a157 Session notes: step 3 design settled, ViewSelectionController fold done
Record §21.10: step 1 is committed (always-show selection + single-line
staging), step 3's prep (the ViewSelectionController fold) is done, and the
step-3 feature design is fully settled — mode state mirrored from the staging
machine in view-line space, gocui's native range-select for rendering, ↑/↓ made
hunk-aware, config-driven hunk default, and range-aware staging. Captured so the
work resumes cleanly in a new session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 8c7a41c559 Fold ViewSelectionController into MainViewController
ViewSelectionController owned the focused main view's line navigation (up/down
move the selection or scroll, page up/down, goto top/bottom). It was extracted
long ago in case other scrollable views (e.g. a focused command log) would reuse
it, but none ever did — it's only attached to Normal and NormalSecondary, the
two focused main views MainViewController already controls.

Keeping the navigation in a separate controller is now in the way: the next
change makes up/down hunk-aware (move by hunk when hunk-select mode is on),
which needs the selection mode that lives in MainViewController. Rather than
teach the generic-looking ViewSelectionController about that mode, fold its
bindings and handlers into MainViewController and delete it. Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 843543b596 Session notes: plan to merge staging into the main view, and step 1
Record the session-11 reframe (use the diff-line-metadata primitive to fold
staging and custom-patch-building into the focused main view, dropping the
separate explorer views) with its feasibility analysis and an 8-step plan, plus
the session-12 findings from implementing step 1 (always-show selection +
single-line staging): the DiffMainViewContext predicate, the first-visible-change
anchor, the tab-selection fix, and two carried-forward concerns — the
per-command handler-channel won't scale, and delta's background-conveyed side is
hidden under the selection highlight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 494b9ce7cf Stage the selected diff line from the focused main view
The focused main view already lets you point at a diff line and act on it
(dive into staging, edit it, open it in a PR). This makes it a staging surface
in its own right: press space to stage — or unstage — the selected line without
diving into the separate staging view.

To make that usable, the selection is now shown automatically whenever the main
view holds a diff, anchored on the first change line already visible (so the
view doesn't jump), rather than being toggled on demand from the middle of the
view. That frees space for staging and means there's always a line to act on.
Which contexts show a diff is marked by a new types.DiffMainViewContext, because
"shows a diff" is the right signal, not "is stageable": reflog shows a diff (its
selection drives edit/PR/navigation) but can't be staged, while a branch's log
or the status dashboard show no diff and get no selection at all.

Staging is delegated to the side panel beneath via a GetOnStageFocusedMainView
handler mirroring GetOnClickFocusedMainView, so what "stage" means stays the
panel's concern (the working tree stages/unstages; commits will later add to a
custom patch). The files handler resolves the line's patch identity from the
diff-line metadata, maps it to a patch line, and applies a one-line patch,
choosing stage vs unstage from whether the shown diff is the unstaged or staged
side (diffSplitState).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 d23fe24100 Extract diffSplitState from the files diff renderer
The files diff renderer decides, from the selected file's staged/unstaged
status and the splitDiff config, whether the focused main view is split into
unstaged/staged halves and — when not split — whether the single view shows the
staged diff. A second consumer is about to need the same decision (staging a
line directly from the focused main view must know whether the shown diff is
staged or unstaged, to pick apply vs apply --reverse). Pull it into a method so
the two can't drift from each other.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 9bb070be76 Session notes: the context-change flicker, pinned and fixed
Updates §20.5 with the pinned cause (the restore scan ran after the swap, so
the new content was briefly shown at the stale scroll) and the fix, plus
productionization notes: the resolve-then-swap ordering is an invariant of the
restore mechanism, the irreducible post-swap mapping window, and that fast
renders unmask latent transients slowness was hiding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 fced2c616a Scan for the restore target before revealing the new content
On a position-preserving re-render the first paint swapped the off-screen
content in and only then ran Apply, which for the buffer-parse backend (no
pager) scans the whole diff to locate the line to land on. That scan takes
tens of milliseconds on a large diff, during which the new content was already
displayed at the *old* scroll position — a layout draw landing in that window
showed a frame at the stale (and now out-of-range) scroll, a pronounced
flicker when changing context size while scrolled down. The metadata/hyperlink
backends didn't show it because they resolve the target during the load, so
their Apply is instant.

Let Apply own the swap: it locates the target against the still-off-screen
(and, at end of input, complete) buffer first, then calls swapIn and settles
the scroll. The scan now runs while the previous content is still displayed, so
the new content is revealed already at the right position — matching what the
early-resolving backends already did.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 7f8c6f9a89 Session notes: the O(n2) diff-line scan, batched to O(n)
Records the scan-cost diagnosis and the batch-resolver fix (§20), and the
context-change flicker the speedup unmasked (§20.5), still to be pinned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 ecc8b177fa Resolve diff lines in one batch pass instead of once per line
Changing the -U context size or switching pagers preserves the scroll
position by scanning the re-rendered diff for the line to land on. Each scan
resolved every buffer line through the per-line resolver, whose buffer-parse
backend re-parses that line's whole file section on every call — so a scan was
O(n2) in the diff length. On a 9600-line diff, changing context took ~33s with
no pager (worse with delta); the file/hunk navigation scans had the same cost.

Route the whole-buffer scans (the position restore's nearbyDiffLines and
end-of-load resolution, and the file/hunk navigation) through a new
resolveDiffLines, which parses each file section once for the whole buffer and
applies the metadata/buffer/hyperlink precedence on top — O(n). The incremental
restore scan now resolves only the rows that loaded since it last looked, using
the per-row backends (metadata/hyperlink) that don't need surrounding context;
the buffer-parse case still resolves once the diff is complete. The single-line
resolver (clicks) is unchanged.

Measured on a synthetic single-file diff, the whole-buffer scan drops from
1.7s to 1ms at 1700 lines and from ~107s to 15ms at 6800 lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 b2685cae95 Add gocui accessor for the newly-loaded rows of an off-screen render
The restore scan polls the off-screen render after each line read to find its
target. OffscreenDiffLineContents rebuilds a snapshot of every loaded row each
call, so polling per line is O(n2) on a large diff. Add
OffscreenDiffLineContentsFrom, which returns only the rows from a given index
onward, so a scan that remembers how far it has read can process just the new
lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 9a5c1ac82d Add a batch buffer-diff parser that parses each file section once
The diff-line buffer-parse backend (mechanism #1) resolves one line at a
time, re-parsing that line's whole file section on every call. The position-
restore and navigation scans resolve every line of the buffer, so they pay
that whole-section parse once per line — O(n2) on a large single-file diff.

Extract the per-section parse (parseFileSection) and the section-bounds
search (fileSectionBounds) out of parseDiffLineFromBuffer, and add
parseAllDiffLinesFromBuffer, which walks the file sections once and resolves
every line in a single O(n) pass. Behavior-preserving: parseDiffLineFromBuffer
now delegates to the same per-section parser, so a single-line lookup is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan Haller 3c9a30c620 Drop f/h types again 2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 39556e80f3 Keep the middle line stable across a context-size change
When {/} change the -U context size with no selection showing, we re-anchor
the re-rendered diff on the top visible line. Anchor on the middle visible
line instead, so the diff appears to pivot around the line you're most
likely looking at rather than around its top edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 4c6af62424 Select the line in the middle of the content, not the viewport
Pressing space in the focused main view starts the selection at the middle
row of the viewport. When the diff is shorter than the viewport that row is
empty space below the content, so the selection clamps onto the last line
instead of landing somewhere useful. Anchor on the middle of the visible
content instead; once the content fills the view this is the same row as
before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 9db3094e80 Drop the synthesized-deletion limitation from the OSC spec
Re-verified empirically: a pure deletion's new-line is exact at default context
(difftastic emits d;4;4) and only drifts at --context 0 (d;1;4, low by the elided
count); old-line is exact either way. lazygit renders at default context, so the
drift isn't reachable in normal use, and even at zero context the only effect is
that 'e' on a deleted line opens the new file a few lines off — inherently
approximate, since the line isn't in the new file. Too marginal to keep as a v1
limitation, so §8 collapses to the single token-vs-line note. Re-verification
recorded in the design notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 1eb032abe8 Revise the OSC spec draft after the first review pass
Incorporate the round of feedback before circulating to pager developers. The
substantive decisions:

- Make f and h (file-header / hunk-header) mandatory v1 record types and drop the
  speculative o; a header carries new-line = its hunk's first line so e lands near
  what the user is looking at, and a multi-row header block (delta's box) re-emits
  the record on every row, mirroring the wrapped-row rule. This replaces the old
  reserved-and-unused framing and the host-side backUpOverHeader guessing.
- A host must ignore an unrecognized type rather than reject the record, so the
  type set can grow without a version bump.
- Drop sections that were redundant or over-stated: §5.4 (the side is already
  motivated in §1), §8.1 (c/a old-line is a host concern with no consumer), §8.4
  (word-diff), and the at-most-two-records and trailing-fields paragraphs.
- Tighten the difftastic token-vs-line note (now §8.1): the c-on-a-pure-addition
  old line only happens in difftastic's AST mode and the record faithfully matches
  what difftastic renders (dim context); its line/Text fallback emits d/a normally.
- Document the rename rule for the file field, and reframe §7 as the host access
  model the carrier is designed for rather than a description of the prototype.

Also record in the design notes the one user-visible bite of the token-vs-line
model: cross-pager selection preservation (delta d vs difftastic-AST c) — a host
concern kept out of the spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 a1eec2f3b6 Session notes: alt/shift-click a diff line to open it in the editor
Record session 9 (§19): the modifier-click interaction, the per-terminal
probe findings that drove the alt+shift choice, the two gocui pieces, and
a planning hint that it lands as a separate PR at the very end of the
productionization stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 4b5858cf7a Alt- or shift-click a diff line to open it in the editor
delta's clickable line-number hyperlinks let you jump to the editor from
the diff, but only on the line-number gutter (a small, fiddly target,
present only on added/context lines and costing horizontal space). Add a
modifier-click that opens whatever diff line is under the cursor — the
whole line is the target, deletions included, and the gutter is no longer
needed.

Both alt- and shift-click are bound because no single modifier survives
every terminal's mouse handling: Ghostty forwards alt (and keeps shift
for text selection), iTerm2 forwards only shift, and VS Code forwards
both. Whichever a terminal delivers triggers the edit; the one it keeps
for itself never reaches us. Right-click and ctrl-click were ruled out —
terminals variously claim them for context menus, promote ctrl-click to a
secondary click, or strip the modifier.

Unlike the `e` keybinding it doesn't require focusing the main view or
holding a selection, and being registered with HandleWhenPopupPanelFocused
it works while a popup covers the view — so you can jump to code shown
behind the commit-message panel, just like the hyperlinks did.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 5b0091f650 Extract editDiffLine from editLine
The `e` keybinding resolves the file/line of the selected diff line and
opens it in the editor. A forthcoming right-click handler needs the same
resolve-and-edit step for the clicked line rather than the selected one,
so split it out behind a view-line-index argument. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 aa4ff00898 Let a mouse binding opt into firing while a popup panel is focused
Mouse clicks on a view other than the focused popup panel are normally
swallowed by the ShouldHandleMouseEvent gate, so a registered click
handler can't run while a modal is up. Hyperlink clicks already dodge
this by being handled in an earlier phase; generalize that to ordinary
mouse bindings via a HandleWhenPopupPanelFocused flag, dispatched before
the gate. No binding sets it yet, so behavior is unchanged.

This is what lets a click on the main view stay live behind a popup
(e.g. opening a diff line in the editor while the commit-message panel is
in front), the way the wheel already scrolls it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 a31755de22 Rename the diff-line-metadata OSC from 456 to 1717
The terminal-allocation audit settled the protocol's OSC number on 1717 (unused
by every surveyed terminal — see diff-line-metadata-osc-spec.md), retiring the
456 placeholder. Rename the host side to match: the gocui carrier that accumulates
and reads back the sequence, the parser, and the handshake env var the pager
subprocess is given (now EMIT_OSC1717_METADATA). Flip the design notes and spec
from 'rename pending' to done.

The delta and difftastic emitters are renamed in their own repos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 4572f5ebfb Draft the diff-line-metadata OSC spec and settle the OSC number
Resolve the OSC number from the 456 placeholder to 1717. There is no central
registry for OSC numbers, so the audit reduces to: pick a high, distinctive
number that no real terminal *acts on* — an unknown OSC is skipped harmlessly,
but a recognized one can fire a visible side-effect (OSC 555 flashes foot, OSC
777 raises a desktop notification), and the metadata flows through real
terminals whenever the pager runs outside a host. Audited the live OSC
allocations of xterm, VTE, kitty, foot, WezTerm, iTerm2, Windows Terminal,
Ghostty, VS Code, ConEmu and urxvt; 1717 collides with none and sits in the
empty 1400-5000 band (only iTerm2's 1337 is nearby).

Write the spec as a standalone draft to circulate to pager developers for
feedback — separate from the internal session notes, with motivation, the v1
wire format, the env handshake, semantics, emit rules (including the per-row
wrapping correction and the side-by-side two-records-per-row case), and the
known v2 candidates (both-numbers-always, the difftastic token-vs-line
mismatch). The prototype code still emits the 456 placeholder; the 456->1717
rename across delta/difftastic/gocui/lazygit and the EMIT_OSC<n>_METADATA env
var is a tracked follow-up, not part of this commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 4701aca3fe Design notes: pager-switch scroll preservation (a new diff-line consumer)
Record session 8 — preserving scroll/selection when cycling pagers, as
the seventh consumer of the identity-based restore. Captures why both the
plain-pager (GIT_PAGER, line-number kept) and externalDiffCommand
(command changes, reset to top) cases needed it, the "main"-window-only
scope, the graceful no-op fallback, and that the side-by-side<->inline
structural win awaits the §17.4 row+column resolver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 de8b19c247 Preserve the diff scroll position when switching pagers
Cycling pagers re-renders the diff into the main view. Until now that
either lost the scroll position outright — an entry with its own
externalDiffCommand changes the actual git command, so the re-render
reset the view to the top — or kept it only by raw line number, which a
plain pager swap got for free because the git command was unchanged.

Raw line number is the wrong anchor: two pagers can structure the same
diff very differently (side-by-side vs inline), so the same screen line
means something different afterwards. Reuse the identity-based restore
(PreserveDiffPositionOnRerender, already driving the -U context-size
consumer) to re-anchor on the same patch line instead. That both keeps
the position meaningful when the structure changes and covers the
externalDiffCommand case the line-number approach couldn't.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 0cceaecfda Design notes: delta wrapped-row metadata bug now fixed
The same pager-level wrapping gap found in difftastic (§10.8) was fixed in delta
too. Delta only wraps in side-by-side mode, so the bug was SxS-only; each wrapped
continuation row now re-emits the record of the primary line it continues,
without advancing delta's line-number counters (so subsequent lines keep correct
numbers). Update §9.3, §10.8, and the §17.1 correction note accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 08f2105636 Design notes: wrapped rows must carry metadata (a spec correction)
Testing the difftastic prototype in lazygit exposed that the "wrapped
continuation rows carry no attachment" convention is a bug whenever the pager
itself wraps a long line (difftastic side-by-side, delta with wrap-max-lines):
each wrapped row is a distinct host buffer line, so e/enter and hunk navigation
break on the un-tagged continuation rows. The fix -- emit the line's record on
every output row, not just the first -- was applied to difftastic; delta needs
the same. Record the underlying distinction (terminal/host wrapping vs
pager wrapping) and state the spec rule positively (§10.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 3189c7535a Design notes: record the difftastic OSC metadata prototype findings
difftastic is now prototyped as an OSC-metadata emitter in both its modes
(side-by-side and inline), the one pager mechanism #1 categorically cannot
serve. The notes capture what was built and, more importantly, what it reveals
about the v1 format: it holds, but difftastic's token-granular structural model
surfaces a model mismatch the unified-diff pagers hid -- an aligned row whose
old side has no novel tokens is faithfully a context cell plus an addition cell,
with no deletion record for the old side. Also resolves the "how many regions
per row" open question (two, not N) and amplifies the §17.3 "context/added carry
no old-line" limitation, since difftastic's old/new line numbers diverge with no
constant offset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 b87607a5b5 .claude/settings.json: allow access to difftastic repo
The OSC-metadata prototype now extends to difftastic (the categorical #2-only
pager), checked out at /Users/stk/Stk/Dev/Builds/difftastic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 d4cdeca8ca Design notes: record the side-by-side delta prototype findings
A parallel session added per-line OSC metadata to delta's side-by-side mode
(in the delta repo, not lazygit) and recorded its findings here as §17, with
the §15 roadmap updated to mark step 3 done.

The headline, which feeds the OSC spec draft: side-by-side needs no change to
the v1 wire format — `type` already implies the column (a→right/new, d→left/
old) and context is symmetric, so no side discriminator is warranted. This
corrects §15's earlier guess that side-by-side would likely add payload, and
leaves the OSC number as the spec's only remaining open item. Also flagged for
productionization: the host reader becomes row+column→identity (a changed row
carries two records), which the §16 consumers haven't yet been exercised
against.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan Haller 312d5710c2 .claude/settings.json: allow access to delta repo 2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 1479a51cb9 Design notes: record the two showcase consumers (session 7)
Hunk/file navigation (#4) and -U-context-change scroll preservation (#5) are
built; document the design, decisions (main-view-only scope, offset-preserving
placement, file nav landing on the file top, the visibility guard) and the
pending interactive sign-off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 a967fc3099 Navigate the focused main view by file and hunk
Add file and change-block ("hunk") navigation to the focused main view,
mirroring the staging view's hunk keys: `<left>`/`<right>` jump to the
previous/next hunk and `n`/`N` to the next/previous file. A "hunk" here is
lazygit's notion — a run of consecutive added/deleted lines separated by
context, not a git `@@` section — matching what the staging view jumps
between.

This is a consumer of the diff-line primitive in its forward direction:
resolve each rendered row's patch identity, then scan for the next/previous
change block (by the line type) or file boundary (by the path changing). The
file scan lands on the top of the neighbouring file even when a restructuring
pager leaves the header rows untagged, by backing up over them from the
file's first identifiable row — which is impossible without the per-line
metadata once the pager stops emitting a parseable unified diff. The
boundary arithmetic is pulled out into pure functions and unit-tested.

The anchor is the selected line if a selection is showing, else the top
visible line. With a selection we move it to the target and scroll it into
view, like the staging view; with none we stay in scroll mode, bringing the
target to the top without creating a selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 3b6568cf9f Preserve the diff scroll and selection when the context size changes
Increasing or decreasing the diff context size (the `{`/`}` keybindings)
re-renders the diff with a different `git diff -U<n>` command. Because the
command key changes, the render reset the main view to the top — losing the
spot the user was reading, which is exactly the spot the context change is
about.

Preserve it instead, reusing the identity-based restore built for the escape
path. This is its sibling consumer: capture the lines around the anchor (the
selection, or the top visible line when there's none) as restore candidates,
and after the re-render land on the nearest one that survived, put back at the
same screen row. Prefer the anchor line itself, falling back outward only when
it didn't survive: a context line vanishes when the context size shrinks,
whereas additions and deletions always survive, so expansion stops at the
first change line in each direction and the candidate list always contains a
survivor. Landing on the nearest survivor keeps scrolling to a minimum, and a
context line that is still in the patch stays put (or stays selected).

This generalizes the shared restore helper to take a prioritized candidate
list (the escape path passes a single candidate). It covers the focused main
view and every side panel's diff, since they all render into the same "main"
view through the same path. A showing selection is re-established on the landed
line; otherwise the view stays in scroll mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 ef01d66412 Extract the identity-based restore into a context-neutral helper
The escape restore's mechanism — set a RenderRestore that scans the
re-rendering content for a target patch identity and, once it loads,
positions the view on the matched row — is about to gain a second caller:
preserving a diff view's scroll/selection when its -U context size changes
re-renders it (the sibling consumer of the diff-line primitive). That caller
positions the row differently (put it back where it was, rather than scroll to
and select it), so split the positioning out behind a `place` callback and
keep the scan/swap machinery shared.

Behaviour-preserving: RestoreFocusedMainViewOnEscape passes the same
FocusPoint-and-select closure the inline Apply used.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan Haller ed72851579 Reset the scroll to the top at first paint, not when the task starts
When a main view re-renders content different from what it last showed, the
scroll resets to the top. That reset fired synchronously when the task started —
but with the off-screen render the previous content stays displayed until the
swap, so resetting the origin up front scrolled that still-visible content to the
top before the new content replaced it: a distracting jump when switching commits
(or any item) while scrolled down.

Defer the reset to the task's first paint (the swap), alongside the restore that
already runs there: the previous content stays at its scroll until the new content
takes its place, then the new content appears at the top. A same-content re-render
keeps its scroll (no reset); a restore places the scroll itself. The "loading..."
indicator path also resets the origin now, since it clears the previous content to
show the message and must put it at the top.

The reset moves out of NewTask (it no longer needs the task key or the pending
restore for this) into the read loop, driven by LinesToRead.ResetOrigin, which the
cmd/pty wrappers set from the key comparison the reset used to do. The manager's
onNewKey callback is renamed resetOrigin to match its now-decoupled timing.
2026-08-08 12:58:59 +02:00
Stefan Haller af147d49f7 Lock the view while reading viewLines on mouse move
onMouseMove (and findHyperlinkAt, which it calls) read v.viewLines without
holding writeMutex, unlike every other reader. They run on the event-handling
goroutine, so a re-render on the task goroutine can shrink or rebuild viewLines
between onMouseMove's bounds check and findHyperlinkAt's indexing, causing an
out-of-range panic (observed: "index out of range [60] with length 0" while
hovering during a diff re-render).

Take writeMutex for the duration, like the other viewLines readers do, so the
check and the access see the same slice. Pre-existing, but the off-screen
re-render rebuilds viewLines on the task goroutine more often, widening the window.
2026-08-08 12:58:59 +02:00
Stefan Haller 35c3bbdee5 Design notes: record the identity-based restore (part 3, session 6)
§14: the escape restore now anchors on a patch identity scanned from the loading
re-render (items 1+3 of the part-3 plan, which collapsed into one mechanism), the
partial §12.2 routing fix, and the analysis for the deferred pieces (the (b)
no-clobber lever's interaction with the entry origin reset, NormalSecondary
routing, the hyperlink-backend match limitation, the O(n^2) scan). Records that
interactive sign-off — including the still-pending session-5 scrollbar/stopped-task
checks — remains, since the agent couldn't drive the TUI.
2026-08-08 12:58:59 +02:00
Stefan Haller 63b794b278 Return to the focused main view when escaping the staged half too
Diving into staging from a focused main view records a snapshot so escape can
return there. But it was recorded only on the half we entered (unstaged), and
staging the last unstaged hunk moves the selection to the staged half
(RefreshStagingPanel pushes StagingSecondary when the unstaged state goes empty).
Escaping from there found no snapshot and fell back to the files panel, instead
of returning to the focused main view.

Record the snapshot on both staging halves at entry, and clear both on escape.
Escaping from either half now returns to the focused main view, and the
identity-based restore lands on the line the explorer ended up selecting — which,
with the last unstaged hunk gone, is shown in the main view (the file now has only
staged changes, so no split). Routing the split-and-tabbed-to-staged case to the
secondary focused main view is left for follow-up; see focused-main-view-notes.md
§14.3.
2026-08-08 12:58:59 +02:00
Stefan Haller d3bf88c52c Restore the focused main view by patch identity on escape
Escaping a patch explorer (staging / patch building) back to the focused main
view it was entered from used to replay a numeric scroll position and selection
index captured on the way in. But the reason to escape after staging or dropping
a hunk is that the content changed, so a saved index points at the wrong line —
and the host auto-advances the explorer's selection to a still-valid line anyway,
which is the line the user actually cares about returning to.

Restore by *patch identity* instead. On escape, read the (file, type, source
line) the explorer currently has selected, then have the main view's re-render
land on the row that matches it: scan the incoming content as it loads (the
inverse of the diff-line primitive), and once the matching row plus a screenful
below it have loaded, swap the off-screen render in and scroll to / select that
row in one step. FocusPoint with scrollIntoView centres the row only if it's
off-screen, so the common unchanged-content escape — where the row is already
where it was — doesn't move at all. If the line is gone (the content really
changed), nothing is forced.

This generalizes the scroll restore from a fixed origin to a predicate
(RenderRestore: FirstPaintReady decides when the saved position is reachable,
Apply re-establishes it), folding the separate selection restore into the same
first paint — so it no longer rides a post-load callback that could fire early.

The restore also now survives task replacement, which the numeric version did
not: a periodic refresh can stop the escape's re-render before it first-paints.
The pending restore is held on the buffer manager and is *not* cleared when a
task starts, so the replacement task picks it up. It is not gated on the command
key — staging the last unstaged hunk re-renders `git diff` as `git diff --cached`,
a different command, yet the line to land on is still in the new content — but
validates itself: the scan finds the target line only when the content still
contains it, so applying it to a different item is a harmless no-op. A task
clears it once it has applied it (found or not), so it lives for exactly one
re-render. Because the restore is anchored on content identity and is idempotent,
"survive replacement" and "restore by identity" are one mechanism, not two.

With the identity in hand the snapshot no longer needs the captured scroll/index;
they're derived from the explorer's live selection.
2026-08-08 12:58:59 +02:00
Stefan Haller a24196077d Add gocui accessors for scanning a loading off-screen render
The escape restore (next commit) finds the row in a re-rendering focused main
view that matches a target patch identity, and it has to do so while the content
is still loading — i.e. against the off-screen buffer, before it is swapped in,
since the displayed buffer still shows the previous render.

Add the two primitives that scan needs:

- OffscreenDiffLineContents exposes the per-line diff material (text, metadata,
  hyperlink) of the rows read so far into the off-screen render, so the resolver
  built last commit can run against the incoming content.
- ViewLineForBufferLine maps a matched (unwrapped) buffer line back to the first
  view line that renders it — the inverse of BufferLineForViewLine — so the
  restore can scroll to and select that line once the render is swapped in.
2026-08-08 12:58:59 +02:00
Stefan Haller cfe356b08d Resolve a diff line's identity from a content snapshot, not the live view
The diff-line primitive (recover a rendered row's patch-space identity) is about
to gain a second, inverse consumer: the escape restore scans a focused main
view's rows as it re-renders, looking for the row that matches a target patch
identity. That scan runs over the *loading* off-screen buffer, not the displayed
view, so the resolver can't be tied to the displayed view's per-view-line readers.

Pull the three backends (OSC metadata, buffer parse, lazygit-edit hyperlink) onto
a single buffer-agnostic resolver that takes a snapshot of a diff's per-line
content — text, metadata and hyperlink per unwrapped buffer line — and the line
to resolve. The forward consumers (click/enter/edit/PR) feed it a snapshot of the
displayed buffer (gocui.DiffLineContents) after mapping the wrapped view line to
its buffer line; the upcoming scan will feed it the off-screen buffer's loaded
rows. Behavior is unchanged.
2026-08-08 12:58:59 +02:00
Stefan Haller 239426d46b Design notes: record the stopped-task EOF-finalize bug and its fix 2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 0e3d3d3182 Don't run end-of-input handling for a render that was stopped
When a task is stopped to make way for a newer one, stopping closes
opts.Stop, and the scanner goroutine then closes lineChan. The read loop's
select between those two channels is therefore non-deterministic: it can
land on the closed lineChan (ok == false) instead of the opts.Stop case,
sending a stopped task into the end-of-input branch.

There it runs the full finalize — swapping its half-read off-screen buffer
in, applying the saved scroll, clamping the origin to the truncated
content, and clearing the loading flag — all of which corrupt what the
incoming task is about to render. The most visible symptom is a brief frame
of truncated content with the scroll yanked to the top, seen when re-renders
overlap rapidly (e.g. the periodic background refresh re-rendering a main
view faster than it can load, very easy to hit under LAZYGIT_SLOW_RENDER).

The underlying bug predates the off-screen render (the EOF branch always
clamped the origin via onEndOfInput), but that change made it far worse by
also swapping a truncated buffer into the display. Fix it at the source: in
the EOF branch, check whether we were stopped and, if so, bail out like the
explicit stop case, leaving the view entirely to the task that replaces us.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan Haller 63dac0d77d Design notes: escalate the escape/autoRefresh race and record flicker-avoidance limits 2026-08-08 12:58:59 +02:00
Stefan Haller 6dead7cdb4 Design notes: record the scrollbar regression fix (hold the height during load) 2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 600b3eeae4 Design notes: record the scrollbar regression and the 10s-refresh vs restore race
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 80c82d4bb4 Design notes: record the off-screen render replacing the two flicker patches
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 b4fb49acce Render async content into an off-screen buffer and swap it in
A cmd/pty re-render used to overwrite the displayed buffer from the top
down as lines arrived, relying on keeping the previous render's view-line
tail to avoid a blank frame. That left the view showing a mixture of old
and new content while loading, and any reader (draw, the diff-line
mapping, clicks) could observe a half-written buffer at the wrong scroll —
the §11 Race A flicker and the §8 stale-tail mapping both came from this.

Instead, build the new content in a second, off-screen viewBuffer: until
the task has read enough to paint, writes go there and the displayed
buffer — and so everything every reader sees — is left untouched. Once the
task reaches its first-paint point (InitialRefreshAfter, or EOF for short
content) it swaps the off-screen buffer in atomically and applies the
saved scroll in the same step, so the view jumps straight from the
previous render to the new one with no intermediate frame. Subsequent
lines append to the now-displayed buffer.

Swapping at the first-paint point means the displayed buffer is only a
viewport tall when it appears and then grows as the rest streams in toward
the count needed for an accurate scrollbar. The scrollbar is sized from the
displayed buffer's height, so left to itself the thumb would shrink and
snap back during that growth (most visibly: the files panel's periodic
refresh making the thumb jump while scrolled down). The total height the
scrollbar needs is a strictly later quantity than the viewport-fill paint,
so no single early swap can have both right. FreezeScrollbarHeight therefore
records the view's height when a load begins and the scrollbar is held there
— growing only if the new content turns out taller — until the load ends; a
synchronous render superseding the load releases it. This mirrors the layout
clamp, which already ignores the partial content height while a view loads.

With the swap doing a wholesale replace, refreshViewLinesIfNeeded can
truncate the view lines to the current buffer: there is no longer a
half-loaded shorter buffer whose tail we must keep showing, so the stale
tail (§8) never forms. clear()/Reset() abandon any in-progress off-screen
render so a synchronous SetContent after a stopped task writes to the
display.

This replaces the holdViewLines and freshViewLineCount patches reverted in
the previous two commits with one mechanism. The swap holds writeMutex for
now; it could later move to the main thread. Flicker behaviour still needs
interactive verification (LAZYGIT_SLOW_RENDER + a real pager).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 34566357f4 Revert "Stop mapping stale-tail view lines to the wrong buffer line"
This reverts the freshViewLineCount stale-tail guard (§8), restoring the
demonstrate-the-bug test state. The upcoming off-screen render rebuilds
the displayed buffer wholesale on swap and lets refreshViewLinesIfNeeded
truncate, so the stale tail never forms — a cleaner fix than tracking a
fresh-count. Removing the guard on its own keeps that change focused; the
bug it guarded against is re-fixed by the truncation in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 45af0aac30 Revert "Hold the placeholder until first paint when restoring a scroll position"
This reverts the holdViewLines flicker patch (Race A). It is about to be
superseded by rendering a re-render into an off-screen buffer and swapping
it in atomically, which keeps the displayed buffer (and so every reader)
untouched until the new content is ready — a cleaner mechanism than
suppressing the view-line rebuild. Removing it on its own keeps that
upcoming change focused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 0d7d16df19 Make the buffer-writing methods operate on a viewBuffer
write, writeCells, makeWriteable, parseInput and
autoRenderHyperlinksInCurrentLine produced cells into v.buf; move them onto
viewBuffer so they can write into any buffer, not just the displayed one.
The display-side effects that don't belong to content production —
tainting, clearing hover, updating search positions — stay behind in the
View.write wrapper, which delegates the actual writing to v.buf.write(v).
Render config the writer needs (Editable, colors, width, tab width,
hyperlink auto-render) is read from the passed View. Behaviour-preserving:
the wrapper still always targets v.buf.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 64e3d3e049 Bundle a view's cell buffer and write state into a viewBuffer
The fields that make up a view's content and the act of writing to it —
the cell buffer (lines), the write cursor (wx/wy), the escape-sequence
decoder (ei) and the held-newline flag (pendingNewline) — were loose
fields on View. Bundle them into a viewBuffer struct that View holds by
pointer. This is a behaviour-preserving prep refactor: every access just
goes through v.buf now. It sets up rendering into a second, off-screen
viewBuffer that can be swapped in atomically, so an async re-render never
exposes a half-written buffer to readers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 d8c58ef007 Design notes: record the §11 race fixes as implemented (pending interactive verification)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 91585c534f Hold the placeholder until first paint when restoring a scroll position
When escaping to a focused main view scrolled down, the re-render keeps
the previous content as a placeholder and the task scrolls to the saved
position at its first paint. But the task's writes mark the view tainted,
so any layout pass landing between the first write and the first paint
rebuilds the view lines from the half-loaded buffer and draws them at the
placeholder's scroll — a brief frame of the wrong content at the wrong
scroll before it snaps into place. Intermittent, and only visible when the
load is slow enough for a layout pass to fall in that window.

Give the view a hold: while set, refreshViewLinesIfNeeded keeps the
current view lines instead of rebuilding from the buffer, so the view goes
on drawing the coherent placeholder. The re-render task sets it only when
restoring a scroll position (so the normal load-from-top case is
untouched) and releases it at its first paint, which applies the saved
scroll in the same step — so the loaded content appears at the restored
scroll with no intermediate frame. While held, the displayed view lines
need not match the loading buffer, so the view-line→buffer-line mapping
reports no result.

This needs interactive verification (LAZYGIT_SLOW_RENDER + a real pager);
see focused-main-view-notes.md §13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 23474716ad Restore the focused main view's selection via the re-render task, not a post-hoc ReadToEnd
Escaping to a focused main view restored the selection by scheduling, on
the next UI tick, a ReadToEnd whose callback re-selected the saved line.
But ReadToEnd fires its callback synchronously when the manager has no
live read channel, and the re-render task triggered by the push creates
that channel later, inside its own goroutine (after stopping the previous
task). If the UI tick won that race, the restore ran before any content
was loaded, FocusPoint no-oped against the unloaded line, and the
selection was silently dropped — intermittently, and more often under
load.

Thread the restore through the task instead: a thenForNextTask hook on
the buffer manager, folded into the next cmd/pty task's initial-read Then,
mirroring scrollToOriginYForNextTask. It runs once the task has read
enough to place the selection, and can't fire before the task exists. The
scroll restore already applies at the task's first paint, which precedes
the initial read's end, so the origin is in place when the selection is
restored.

This needs interactive verification (LAZYGIT_SLOW_RENDER + a real pager);
see focused-main-view-notes.md §13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 3af439b17f Design notes: characterize the §11 timing races (bounded vs fundamental)
Pin the two escape-restore races to concrete mechanisms: Race A (layout
draws reveal partial content at the placeholder scroll before the task's
first paint) and Race B (the selection restore can fire before the
re-render task's readLines channel is live). Classify both as bounded
interleavings, sitting over the one fundamental constraint (no flicker-free
first paint of the target scroll without buffering that screenful), and
record the prescribed fixes and their implications for part 3. Documents
why faithful repro is the interactive app, not the headless harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 fb4f7aef06 Design notes: record the §8 staleness fix and the remaining #1 atomicity constraint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 e3214a34dd Stop mapping stale-tail view lines to the wrong buffer line
Track how many leading viewLines entries the most recent refresh built
from the current buffer (freshViewLineCount) and bound the view-line→
buffer-line mapping on it. The entries past that count are the stale tail
refreshViewLinesIfNeeded leaves in place for flicker-avoidance; they
belong to a previous, longer render and must not be mapped. The old
in-range guard was insufficient: with wrapping a stale entry's buffer
index can still be in range of the shrunk buffer. Within the fresh range
every entry was just built from the current buffer, so its index is
guaranteed in range and the guard is no longer needed.

This is the §8 correctness fix the identity-based escape restore depends
on, since that read scans the view buffer while it is still loading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 93286c6a93 Demonstrate that a stale-tail view line maps to the wrong buffer line
refreshViewLinesIfNeeded keeps the previous render's view lines in the
tail when the new render is shorter, so the view can go on showing old
content without flicker until the new content catches up. The mapping
readers guard only against a buffer index that has gone out of range of
the shrunk buffer — but with wrapping, a stale tail entry's index can
still be in range, so the guard passes and a view line that no longer
exists is mapped onto the wrong buffer line. See diff-line-metadata-notes.md §8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 60debda326 Unify the view-line→buffer-line readers onto one helper
HyperLinkInLine, DiffLineMetadataInLine and BufferLineForViewLine each
repeated the same preamble: take the lock, refresh the view lines, range-
check the view line, and guard against a stale viewLines entry pointing
past a shrunk buffer. They all need that mapping to stay consistent with
the buffer they then read, so the logic belongs in one place. Extract it
into bufferLineForViewLine and have all three call it. Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 ae3fe52cd1 Design notes: identity-based escape restore + plan to solve the three entangled problems
Capture a design discussion (no code yet; implementation is a future session):

- The escape-from-staging restore should anchor on the explorer view's current
  patch identity at escape time, not a saved numeric scroll/index, since staging
  or dropping hunks changes the content. This is the inverse direction of the
  diff-line-metadata primitive (identity -> rendered row) and the same operation
  the -U scroll-preservation consumer needs; record it as consumer #6 and split
  the consumer list into forward (1-4) and inverse (5-6) directions.
- Record the escape-routing cases the current prototype gets wrong (staging the
  last hunk should land in the staged half; <tab> between staged/unstaged; the
  empty-view and custom-patch-builder cases).
- Decide to solve the new restore mechanism, the §11 timing races, and the
  BufferLineForViewLine staleness trap together in the prototype rather than
  defer them to productionization (you can't plan around unsolved entangled
  mechanisms), with a dependency-first attack order (§8 fix, characterize the
  races, then the predicate-scroll restore).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 36aefdc454 Design notes: record the #2 prototype as built & verified end-to-end
The emitter (delta), carrier (gocui per-cell attachment), and consumer (the
GetDiffLineInfo metadata backend + env-var handshake) are now built for the
normal unified case and verified end-to-end — including in the running app with
delta's default mode, where clicking/enter/e/G resolve via #2 and deletions get
the correct side. Update §9 from "in progress" to a "built & verified" record
(what was built, how it was verified) mirroring §8 for #1, and mark the build
order (§7) accordingly. The remaining step-5 deliverables — finalizing/publishing
the spec and the production plan — and side-by-side/difftastic are still open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 e6bfb2c720 Use pager-emitted OSC diff metadata as the highest-fidelity line-info backend
Add mechanism #2 to GetDiffLineInfo: when a patched pager annotated each diff
line with OSC 456 metadata, read it back as the first backend, ahead of the
buffer parser (#1) and the lazygit-edit hyperlink. It is strictly higher
fidelity -- it carries the side explicitly, so it serves the renderings #1
cannot parse (delta's default mode, --line-numbers, diff-so-fancy) and conveys
deletions, which the hyperlink can't.

The host advertises the protocol versions it understands by setting
EMIT_OSC456_METADATA on the pager subprocess; a pager that doesn't understand
it ignores the variable, so this is safe to set unconditionally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 00df5177e5 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>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 0e35ebbb55 Design notes: pin the #2 v1 wire format and record the delta de-risk
Mechanism #2's emitter side is now prototyped on this branch (delta, normal
unified mode only) and the bytes are verified. Capture what that settled, since
the spec is meant to be published for pager-developer feedback:

- The single per-line emit point in delta and which fields are reachable there
  (the gotcha: delta only maintains its line-number counters with --line-numbers
  on, so the patch tracks its own), and why a dedicated additive emitter beats
  reusing LineNumbersData.
- The pinned v1 wire format (positional, file last so it may contain ';',
  empty old-line unless deleted) and the EMIT_OSC456_METADATA env-var handshake.
- Deferred items: the OSC-number terminal audit, wrapped continuation rows, and
  header-row attachments.

Also resolves the §6 wire-format open question accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 6a14634040 Design notes: resolve #1's open questions and correct the coverage from verification
Record what the #1 prototype settled. The two open questions #1 touches are now
answered (the deleted-line new-line convention is patch.LineNumberOfLine's, and a
deletion carries both line numbers; multi-file diffs split on "diff --git" and
the section parses 1:1 with patch.Parse). Add a §8 capturing what landed, how it
was verified, and the implications for #2.

Two coverage corrections came out of verifying against real pager output, both
worth pinning before the spec is written: delta --color-only qualifies for #1
only *without* line numbers (the gutter pushes the +/- marker off column 0, so a
naive parse is confidently wrong — handled by an integrity check that falls back,
not by teaching the host delta's gutter), and diff-so-fancy strips the +/- markers
entirely, so it's a #2 case, not #1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 cf8e5fd27e Recover diff-line identity by parsing the buffer, behind a swappable seam
The focused main view's click/enter/e/G handlers all need the same thing: given
a rendered diff row, the patch-space line it corresponds to. Until now that came
solely from delta's lazygit-edit:// hyperlinks, which only carry a path and a
single line number — no side. That's lossy: for a deletion the number is the old
line, but the consumers fed it into new-file lookups, and two consecutive
deletions (which share a new-file line number) couldn't be told apart at all.

Replace GetFileAndLineForClickedDiffLine with GetDiffLineInfo, returning the
fuller (file, type, new-line, old-line) record from diff-line-metadata-notes.md.
This is mechanism #1: parse the decolorized view buffer — walk up to the file's
"diff --git" section, reuse patch.Parse on it (splitting multi-file commit diffs
on the "diff --git" boundaries), and read the type and line numbers off the patch
arithmetic. It serves the structure-preserving renderings — no pager, git diff
--color, and delta --color-only without line numbers — with no external
dependency.

To avoid trusting a mis-parse, the parser bails when a hunk's body no longer
matches its header (Patch.IsWellFormed). That's what happens when a pager keeps
the diff/hunk headers but restructures the body: delta's line-number gutters push
the +/- marker off the start of each line, so every body line reads as context.
Such renderings fall through to the next backend rather than yielding a confident
wrong answer. (diff-so-fancy goes further and rewrites the headers too, so it
fails even earlier, on the missing "diff --git".)

GetDiffLineInfo is a seam with swappable backends: the buffer parser first, then
the old hyperlink reader as a fallback for renderings the parser can't handle
(delta's default mode, or delta with line-number gutters). The future #2 OSC
per-cell metadata reader plugs in ahead of both, behind the same record shape.

Wire the consumers to the record per that doc's field mapping:
- dive into staging/patch building lands on the exact patch line, looking a
  deletion up by its old-file line number (PatchLineForOldLineNumber) so the
  two-deletions case resolves correctly;
- `e` edits at the new-file line;
- `G` anchors the PR link on the left (old) side for a deletion, the right (new)
  side otherwise.

The hyperlink fallback can't convey the side, so it reports DiffLineOther, which
the consumers treat as a non-deletion — i.e. exactly today's behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 165afd4652 Add View.BufferLineForViewLine to map a view line to its buffer line
The diff-line parser needs to walk the unwrapped diff buffer upward from a
clicked/selected row, but the row index it's handed is a view line index (which
counts wrapped lines). Expose the existing internal mapping (viewLines[y].linesY)
so callers can translate, with the same lock and stale-tail guard that
HyperLinkInLine uses against a concurrent shorter re-render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 c9c1fb6a0b Add old-file-space patch arithmetic for landing on deletions
The patch package can already map a patch line index to its new-file line
number and back (LineNumberOfLine / PatchLineForLineNumber). The diff-line
parser being built on top of this needs the old-file equivalents: a deletion
has no distinct new-file position (two consecutive deletions share one), so to
land on the exact deletion when diving into staging we have to look it up by
old-file line number.

Add OldLineNumberOfLine and PatchLineForOldLineNumber as direct mirrors of the
new-file functions, counting DELETION+CONTEXT lines instead of ADDITION+CONTEXT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 b026fb2b93 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>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 0b11400a9d Use the default select mode when diving into a patch explorer from a focused main view
Diving into staging or patch building from a focused main view (by
double-clicking a line, or pressing enter on the selected line) always
landed on a single-line selection. Entering the same views through the
side panel honours the UseHunkModeInStagingView config and selects the
whole hunk by default. The two ways in should agree, so that diving in
from the main view feels like the established flow.

A non-negative line index in NewState was overloaded for two intents:
clicking directly on the patch explorer view (where a single-line range
is the start of a drag) and diving in from the main view (where we want
the default select mode). Distinguish them with SelectLineInDefaultMode
on OnFocusOpts: the main-view entry points set it; the click-to-drag
path does not.

In hunk mode the selection covers the block of changes around the
clicked line. A context line has no surrounding changes, so we snap to
the next change line (as toggling hunk mode does); the clicked context
line itself is then not part of the selection.

This is a separate commit only because the branch is a throwaway
prototype; in a real history it would be folded into the commit that
introduces the focused-main-view enter behavior rather than landing on
top of it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 ea4a1716d1 Session notes: escape flicker fix implemented; timing races remain
Records session 3: the cmd/pty scroll-restore mechanism, the refreshMainViews
reset reorder, the corrected onNewKey understanding, and the remaining
timing-race investigation to do before productionizing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 13115140db Restore scroll and selection seamlessly when escaping to a focused main view
EscapeFromPatchExplorer re-renders the side panel's content back into the main
view and wants to land at the scroll position and selection the user had before
diving into staging/patch building. The previous version set the origin on the
next UI tick, after the placeholder had already been painted at the wrong
position, so the restore was visible as a jump.

Instead, ask the re-render itself to restore the scroll (via
ScrollToOriginYForNextTask), so the saved position is applied in the first
paint that shows the real content. The selection still needs the diff loaded
down to the selected line, so restore it via ReadToEnd once the content is
fully read; the scroll is no longer touched there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 3f542e7c35 Let a cmd/pty task restore a saved scroll position at its first paint
When re-rendering content the user was already scrolled into, we want the
saved scroll position applied exactly when the real content first paints — not
before. Setting the origin up front instead paints it onto whatever placeholder
is currently in the view (e.g. the shorter buffer CopyContent left there),
which flickers: either a blank frame past the placeholder's end, or a jump to
the top when the task resets the origin at startup.

Add ViewBufferManager.ScrollToOriginYForNextTask: the next cmd/pty task then
(a) does not reset the view to the top at startup even though the command key
changed, so the placeholder stays put, (b) sizes its initial read to the saved
position so enough content is loaded to fill the view there, and (c) scrolls to
it as part of the first refresh, in the same paint that shows the real content.
This is the cmd/pty analogue of RenderStringWithScrollTask.

No caller sets it yet, so this is behaviour-preserving on its own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 53a25a34f1 Reset other main views' scroll after copying content, not before
refreshMainViews reset the scroll position of every other main view at the
very top, before moveMainContextPairToTop runs its CopyContent. CopyContent
copies the previously-shown view's content into the now-visible one to avoid a
blank frame during the async re-render — but because the reset ran first, it
had already zeroed the origin of that soon-to-be-copied source view. The
placeholder therefore always appeared scrolled to the top, jumping away from
wherever the screen actually was, on every cross-pair transition.

Move the reset to after the copy. The end state is unchanged (each other main
view still ends at origin 0, and the destination always re-renders), but the
brief placeholder now stays at the source view's real scroll position until
the real content paints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 d26b1ebfa6 Route all view origin writes through SetOriginX and SetOriginY
Several methods assigned v.ox and v.oy directly: SetOrigin, CopyContent,
the wrap/autoscroll branches in draw, FocusPoint, and
Scroll{Up,Down,Left,Right}. Funnelling them all through SetOriginX and
SetOriginY gives a single place to observe (or set a breakpoint on)
every change to a view's scroll position, which makes debugging scroll
behaviour much easier.

This means those call sites now also get the setters' `< 0` clamps, but
that is behaviour-preserving in every case: each assigned value is
already >= 0. calculateNewOrigin never returns a negative number;
CopyContent copies origins that are themselves always >= 0; and the draw
and scroll writes are all guarded (or fed only non-negative amounts) so
the result can't go below zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 23f8d634cc Session notes: corrected flicker diagnosis and the 3 bug fixes
Records session 2's findings: the escape flicker was caused by the layout's
scroll-up-to-fill clamp running against partially-loaded async content (not the
"only a screenful loaded" mechanism session 1 guessed); the full origin-reset
chain (onNewKey / CopyContent / layout clamp) and how each is handled; the three
standalone bug fixes that landed; and the precise remaining task (apply the
saved origin at the pty task's first repaint, i.e. a cmd-task RenderWithScroll).
Also captures the reusable debug tooling that was stripped from the tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 c8175c053f Fire queued ReadToEnd callbacks when the initial read reaches EOF
A task's read loop processes one LinesToRead request at a time. The initial
request has a large line count and no Then callback; if the content is shorter
than that, the loop hits EOF on the initial request and breaks out, abandoning
any further requests still sitting in the readLines channel. So a ReadToEnd
call that races a still-loading-but-shorter-than-its-initial-read view has its
Then silently dropped: it isn't fired immediately (the channel was non-nil at
call time) and it's never dequeued.

On EOF, drain the queued requests and fire their Then callbacks before
breaking out, since reaching EOF trivially satisfies any "read more" request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 0483b12864 Don't scroll a view up to fill blank space while its content is loading
The layout scrolls a view up if its origin is past the bottom of its
content, to avoid showing blank space (e.g. after a resize). But it measures
content height by the lines loaded so far, and command/pty tasks load
asynchronously. So when a view is re-rendered while scrolled down, the layout
would yank it to the top because only a fraction of the content has been read
yet, then leave it there once loading finished.

Track whether a command task is actively reading (set synchronously when the
task is created, so a layout pass in between sees it; cleared at EOF, but not
when stopped, since that means a newer task is taking over) and skip the
scroll-up clamp for such views. onEndOfInput already re-clamps once loading
completes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 70b80d1375 Lock the view and guard the line index when reading a hyperlink
HyperLinkInLine read v.lines/v.viewLines without holding writeMutex, so it
could race a concurrent re-render rebuilding the buffer. It also indexed
v.lines by viewLines[y].linesY after only checking y against len(viewLines);
since refreshViewLinesIfNeeded overwrites viewLines in place without
truncating, the tail can hold stale entries pointing past a shrunk v.lines,
giving an out-of-range panic while a shorter diff is still loading.

Take writeMutex (as the sibling view methods do) and bounds-check linesY
against len(v.lines), returning "no link" rather than panicking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan Haller 1b70ac6fa6 Session notes 2026-08-08 12:58:59 +02:00
Stefan Haller e8388a998d WIP FocusedMainViewSnapshot approach 2026-08-08 12:58:59 +02:00
Stefan Haller e520517388 WIP New click behavior 2026-08-08 12:58:59 +02:00
Stefan Haller bdf067e172 Open a browser at the selected line in the diff of the current branch's PR 2026-08-08 12:58:59 +02:00
Stefan Haller 37ca2a2395 Press e in focused main view (when selection is showing) to edit that line 2026-08-08 12:58:59 +02:00
Stefan Haller 4ced2c0c24 Replace gui.showSelectionInFocusedMainView config with on-demand selection
Instead of a user config that always shows a selection on focus, the
focused main view starts without a selection (matching master). Pressing
<space> shows a selection in the middle of the view (no scrolling);
pressing <esc> hides it again before falling through to exiting the view.
2026-08-08 12:58:59 +02:00
Stefan Haller 89b67cf938 WIP After going straight to patch building from main view, esc goes all the way back out
I *think* I like it better this way, but it needs more testing.
2026-08-08 12:58:59 +02:00
Stefan Haller 63ffb829c0 Press enter in main view of commits panel to enter patch building for clicked line
This involves first switching to the commit files view, and then entering the
clicked file from there.
2026-08-08 12:58:59 +02:00
Stefan Haller 2210a00af9 Extract some functions from CommitFilesController to a new CommitFilesHelper 2026-08-08 12:58:59 +02:00
Stefan Haller a32ed507d3 Press enter in main view of files/commitFiles to enter staging/patch-building
This was already possible, but only when a file was selected, and it woudln't
always land on the right line when a pager was used. Now it's also possible to
do this for directories, and it jumps to the right line.

At the moment this is a hack that relies on delta's hyperlinks, so it only works
on lines that have hyperlinks (added and context).

The implementation is very hacky for other reasons too (e.g. the addition of the
weirdly named ClickedViewRealLineIdx to OnFocusOpts).
2026-08-08 12:58:58 +02:00
Stefan Haller 37855b59e9 Select line that is in the middle of the screen 2026-08-08 12:58:58 +02:00
Stefan Haller 79f3053f41 Press enter in focused main view when user config is on 2026-08-08 12:58:58 +02:00
Stefan Haller 64cc4ccd32 Add user config gui.showSelectionInFocusedMainView 2026-08-08 12:58:58 +02:00
Stefan HallerandClaude Opus 4.8 ba9373575f Add LAZYGIT_SLOW_RENDER debug knob for watching async render frames
Re-rendering a diff into a main view is asynchronous and lazy: the read
loop fills the view a screenful at a time and refreshes as it goes. When
debugging scroll-restore and flicker behaviour, the individual frames go by
too fast to see. Setting LAZYGIT_SLOW_RENDER=<milliseconds> sleeps that long
after each line is written, stretching the load out so the frames become
visible. It has no effect when unset, so it's safe to leave in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:58 +02:00
125 changed files with 17687 additions and 611 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"additionalDirectories": [
"/Users/stk/Stk/Dev/Builds/delta",
"/Users/stk/Stk/Dev/Builds/difftastic"
]
}
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -371,6 +371,12 @@ git:
# # renderer command.
# name: ""
#
# # If true, selected diff lines are highlighted only with a narrow bar
# # at the left edge of the view, rather than across the whole width.
# # Useful for pagers whose own line background colors should remain
# # visible.
# narrowSelectionHighlight: false
#
# # Value of the --color arg in the git diff command. Only used for type
# # 'stdinFilter'. Some renderers want this to be set to 'always' and some
# # want it set to 'never'.
+18
View File
@@ -224,6 +224,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Scroll up | |
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Search the current view by text | |
## Main panel (patch building)
@@ -329,6 +338,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Search the current view by text | |
## Stash
+18
View File
@@ -193,6 +193,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <esc> `` | サイドパネルに戻る | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 現在のビューをテキストで検索 | |
## タグ
@@ -306,6 +315,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | 上にスクロール | |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <esc> `` | サイドパネルに戻る | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 現在のビューをテキストで検索 | |
## メニュー
+18
View File
@@ -85,6 +85,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 검색 시작 | |
## Stash
@@ -163,6 +172,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | 위로 스크롤 | |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 검색 시작 | |
## 메인 패널 (Patch Building)
+18
View File
@@ -232,6 +232,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Scroll omhoog | |
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle drag selecteer | |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Start met zoeken | |
## Patch bouwen
@@ -307,6 +316,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle drag selecteer | |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Start met zoeken | |
## Staging
+18
View File
@@ -100,6 +100,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Drzewa pracy
@@ -202,6 +211,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Przewiń w górę | |
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Panel główny (scalanie)
+18
View File
@@ -236,6 +236,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Rolar para cima | |
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Pesquisar na visualização atual por texto | |
## Painel Principal (preparação)
@@ -338,6 +347,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Pesquisar na visualização atual por texto | |
## Stash
+18
View File
@@ -75,6 +75,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Найти | |
## Главная панель (Индексирование)
@@ -107,6 +116,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Прокрутить вверх | |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Найти | |
## Главная панель (Слияние)
@@ -287,6 +287,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <esc> `` | 退出回到侧边面板 | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 开始搜索 | |
## 正在合并
@@ -335,6 +344,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <esc> `` | 退出回到侧边面板 | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 开始搜索 | |
## 状态
@@ -83,6 +83,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 切換拖曳選擇 | |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 搜尋 | |
## 主面板(合併)
@@ -364,6 +373,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 切換拖曳選擇 | |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 搜尋 | |
## 狀態
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -137,6 +137,9 @@ func NewGitCommandAux(
patchBuilder := patch.NewPatchBuilder(cmn.Log,
func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, plain)
},
func() (string, error) {
return os.MkdirTemp(osCommand.GetTempDir(), "custom-patch-")
})
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
bisectCommands := git_commands.NewBisectCommands(gitCommon)
+2 -2
View File
@@ -240,10 +240,10 @@ func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj {
return self.cmd.New(cmdArgs)
}
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string) *oscommands.CmdObj {
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string, ignoreExternalDiff bool) *oscommands.CmdObj {
cmdArgs := NewGitCmd("show").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
Arg("--submodule").
Arg("--color=" + self.diffRendererConfigManager.GetColorArg()).
Arg("--stat").
+1 -1
View File
@@ -341,7 +341,7 @@ func TestCommitShowCmdObj(t *testing.T) {
}
instance := buildCommitCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, runner: runner, repoPaths: &repoPaths})
assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths).Run())
assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths, false).Run())
runner.CheckForMissingCalls()
})
}
+132 -3
View File
@@ -2,8 +2,11 @@ package git_commands
import (
"fmt"
"os"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/mgutz/str"
)
type DiffCommands struct {
@@ -16,13 +19,118 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands {
}
}
// metadataHandshake is the OSC sequence a metadata-aware pager emits, as its first
// output, to announce that it speaks the diff-line-metadata protocol: a version-only
// OSC 1717 record (no fields). See ProbePagerEmitsDiffMetadata and, for how it's
// swallowed on a real render, escapeInterpreter.dropMetadataIfHandshake.
const metadataHandshake = "\x1b]1717"
// ProbePagerEmitsDiffMetadata reports whether the configured diff renderer speaks the
// diff-line-metadata protocol, by running it on empty input and checking for its
// handshake. It's the focused main view's signal for whether it can act on the rendered
// diff or must fall back to the raw diff (see
// StagingHelper.DiffMainViewShouldRenderRaw). The verdict is content-independent — the
// handshake is the renderer's first output regardless of the diff — so the caller caches
// it per renderer.
//
// No PTY is needed: git needs a terminal to decide to invoke a pager, but a renderer
// emits the handshake whenever OSC1717 is set, so we can run it directly with empty
// input.
//
// A git-config external diff driver (useExternalDiffGitConfig) is chosen per file via
// .gitattributes and a single diff can mix drivers, so there's no one renderer to probe;
// we conservatively report false (the focused main view then always renders raw).
func (self *DiffCommands) ProbePagerEmitsDiffMetadata() bool {
if extDiffCmd := self.diffRendererConfigManager.GetExternalDiffCommand(3); extDiffCmd != "" {
return self.externalDiffEmitsMetadata(extDiffCmd)
}
if pagerCmd := self.diffRendererConfigManager.GetStdinFilterCommand(0); pagerCmd != "" {
return self.probeEmitsMetadata(self.cmd.NewShell(pagerCmd, ""))
}
if rawGitArgs := self.diffRendererConfigManager.GetRawGitArgs(); len(rawGitArgs) > 0 {
return self.rawGitEmitsMetadata(rawGitArgs)
}
return false
}
// rawGitEmitsMetadata probes git itself, run with the diff renderer's own arguments.
//
// The arguments are the point: git describes only the formats whose output can't be read
// back from its own text, and announces itself for exactly those, so the same git answers
// differently depending on what it is asked for — a handshake for --color-words, silence
// for a unified diff.
func (self *DiffCommands) rawGitEmitsMetadata(rawGitArgs []string) bool {
oldPath, newPath, cleanup, ok := probeFiles()
if !ok {
return false
}
defer cleanup()
return self.probeEmitsMetadata(self.cmd.New(
NewGitCmd("diff").
Arg("--no-index").
Arg(rawGitArgs...).
Arg(oldPath, newPath).
ToArgv(),
))
}
// externalDiffEmitsMetadata probes an external diff command, invoking it the way git
// invokes a diff.external driver — with 7 positional args
// (path old-file old-hex old-mode new-file new-hex new-mode) — but on two empty temp
// files, so it emits its handshake without there being a real diff to render.
func (self *DiffCommands) externalDiffEmitsMetadata(extDiffCmd string) bool {
oldPath, newPath, cleanup, ok := probeFiles()
if !ok {
return false
}
defer cleanup()
args := append(str.ToArgv(extDiffCmd),
"probe", oldPath, "0000000", "100644", newPath, "0000000", "100644")
return self.probeEmitsMetadata(self.cmd.New(args))
}
// probeFiles creates the two empty temp files a probe stands a diff up from, and a
// cleanup that removes them. Empty because a probe needs the renderer to announce
// itself, not to have anything to render.
func probeFiles() (string, string, func(), bool) {
oldFile, err := os.CreateTemp("", "lazygit-probe-old-*")
if err != nil {
return "", "", nil, false
}
oldFile.Close()
newFile, err := os.CreateTemp("", "lazygit-probe-new-*")
if err != nil {
os.Remove(oldFile.Name())
return "", "", nil, false
}
newFile.Close()
return oldFile.Name(), newFile.Name(), func() {
os.Remove(oldFile.Name())
os.Remove(newFile.Name())
}, true
}
func (self *DiffCommands) probeEmitsMetadata(cmdObj *oscommands.CmdObj) bool {
cmdObj.AddEnvVars("OSC1717=V1")
// The pager may exit non-zero on the synthetic input; we only care about whether
// it emitted the handshake first, and the output is captured either way.
output, _ := cmdObj.RunWithOutput()
return strings.Contains(output, metadataHandshake)
}
// This is for generating diffs to be shown in the UI (e.g. rendering a range
// diff to the main view). It uses a custom diff renderer if one is configured.
func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj {
// diff to the main view). It uses a custom diff renderer if one is configured, unless
// ignoreExternalDiff is set (the focused main view's raw-diff fallback; keeps the
// colour, unlike a plain diff).
func (self *DiffCommands) DiffCmdObj(diffArgs []string, ignoreExternalDiff bool) *oscommands.CmdObj {
return self.cmd.New(
NewGitCmd("diff").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg(diffArgs...).
@@ -31,6 +139,27 @@ func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj {
)
}
// CustomPatchDiffCmdObj builds the command that renders the custom patch shown in the
// secondary pane: a `git diff --no-index` of the two file trees PatchCommands materialized
// under dir (a/ = before, b/ = after; see WriteCustomPatchDiffTrees). It uses the same pager
// wiring as DiffCmdObj so the patch renders exactly like any other diff — through a stdin
// pager, an external diff tool, or (when ignoreExternalDiff is set, the focused main view's
// raw-diff fallback) git's own colour. --no-prefix is used because the a/ and b/ tree names
// already stand in for git's conventional a//b/ path prefixes, so the diff's paths come out
// as the real repo-relative paths.
func (self *DiffCommands) CustomPatchDiffCmdObj(dir string, ignoreExternalDiff bool) *oscommands.CmdObj {
return self.cmd.New(
NewGitCmd("diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
Arg("--no-index").
Arg("--no-prefix").
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg("a", "b").
Dir(dir).
ToArgv(),
)
}
// This is a basic generic diff command that can be used for any diff operation
// (e.g. copying a diff to the clipboard). It will not use a custom diff renderer,
// and does not use user configs such as ignore whitespace.
+92
View File
@@ -2,7 +2,9 @@ package git_commands
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-errors/errors"
@@ -20,6 +22,11 @@ type PatchCommands struct {
stash *StashCommands
PatchBuilder *patch.PatchBuilder
// lastBuiltTreeGeneration is the patch builder generation the diff trees were last
// materialized for, so EnsureCustomPatchDiffTrees rebuilds them only when the patch
// actually changed (not on every render / navigation).
lastBuiltTreeGeneration int
}
func NewPatchCommands(
@@ -40,6 +47,91 @@ func NewPatchCommands(
}
}
// EnsureCustomPatchDiffTrees materializes the custom patch's diff trees if they're stale —
// i.e. the patch changed since they were last built. Called before rendering the secondary
// pane, so a mere re-render (e.g. navigating commits, which doesn't change the patch) reuses
// the existing trees, while a toggle/removal/whole-file change rebuilds them. This is what
// keeps the trees current across every path that mutates the patch.
func (self *PatchCommands) EnsureCustomPatchDiffTrees() error {
if self.PatchBuilder.Generation() == self.lastBuiltTreeGeneration {
return nil
}
if err := self.WriteCustomPatchDiffTrees(); err != nil {
return err
}
self.lastBuiltTreeGeneration = self.PatchBuilder.Generation()
return nil
}
// WriteCustomPatchDiffTrees materializes the custom patch under the patch builder's temp
// dir as two file trees — a/ holds each patched file's "from"-side content, b/ holds that
// content with the patch applied — so the patch can be re-diffed with `git diff --no-index`
// and rendered through any pager (see DiffCommands.CustomPatchDiffCmdObj). This is what lets
// a partial, in-memory custom patch be shown the same way as any other diff; the in-memory
// aggregated patch on its own could only be fed to a stdin pager, never an external diff
// tool. The dirs are named a/b so that, with `--no-prefix`, the diff's paths come out as the
// real repo-relative paths (git's conventional a//b/ prefixes).
//
// Called whenever the patch's contents change. The temp dir's lifetime is owned by the
// patch builder (created on Start, removed on Reset), so there's nothing to clean up here
// beyond wiping the trees before rebuilding them.
func (self *PatchCommands) WriteCustomPatchDiffTrees() error {
dir := self.PatchBuilder.TempDir()
if dir == "" {
return nil
}
aDir := filepath.Join(dir, "a")
bDir := filepath.Join(dir, "b")
for _, d := range []string{aDir, bDir} {
if err := os.RemoveAll(d); err != nil {
return err
}
if err := os.MkdirAll(d, 0o700); err != nil {
return err
}
}
for _, filename := range self.PatchBuilder.ActiveFilenames() {
// The "from"-side content; the patch applied below turns b/ into the "after" side
// while a/ stays the "before".
content, err := self.commit.ShowFileContentCmdObj(self.PatchBuilder.From, filename).RunWithOutput()
added := err != nil // absent on the "from" side — a file the patch adds
// a/ always holds the "before" content — empty for an added file, so the rendered
// diff still pairs it with b/ and shows the real a//b/ paths (rather than git's
// directory-comparison "added in b" form, which mangles the header).
beforeContent := content
if added {
beforeContent = ""
}
if err := self.os.CreateFileWithContent(filepath.Join(aDir, filename), beforeContent); err != nil {
return err
}
// b/ is seeded only for existing files (which the patch modifies in place); an added
// file is left absent so the patch — rendered as a /dev/null creation — creates it.
if !added {
if err := self.os.CreateFileWithContent(filepath.Join(bDir, filename), content); err != nil {
return err
}
}
}
// Render added files as /dev/null creations (the natural form), not as diffs against an
// empty file: the latter (--- a/file) would make the atomic `git apply` expect them to
// already exist in b/, where they don't.
patchText := self.PatchBuilder.PatchToApply(false, false)
if strings.TrimSpace(patchText) == "" {
// An empty patch leaves a/ and b/ identical (or empty), so the diff is empty.
return nil
}
patchFilePath, err := self.SaveTemporaryPatch(patchText)
if err != nil {
return err
}
return self.cmd.New(NewGitCmd("apply").Arg(patchFilePath).Dir(bDir).ToArgv()).Run()
}
type ApplyPatchOpts struct {
ThreeWay bool
Cached bool
+2 -2
View File
@@ -80,10 +80,10 @@ func (self *StashCommands) Hash(index int) (string, error) {
return strings.Trim(hash, "\r\n"), err
}
func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj {
func (self *StashCommands) ShowStashEntryCmdObj(index int, ignoreExternalDiff bool) *oscommands.CmdObj {
// "-u" is the same as "--include-untracked", but the latter fails in older git versions for some reason
cmdArgs := NewGitCmd("stash").Arg("show").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
Arg("-p").
Arg("--stat").
Arg("-u").
+1 -1
View File
@@ -174,7 +174,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
}
instance := buildStashCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
cmdStr := instance.ShowStashEntryCmdObj(s.index).Args()
cmdStr := instance.ShowStashEntryCmdObj(s.index, false).Args()
assert.Equal(t, s.expected, cmdStr)
})
}
+9 -6
View File
@@ -385,14 +385,17 @@ func (self *WorkingTreeCommands) Exclude(filename string) error {
// WorktreeFileDiff returns the diff of a file
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string {
// for now we assume an error means the file was deleted
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, nil).RunWithOutput()
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, false, nil).RunWithOutput()
return s
}
// WorktreeFileDiffCmdObj returns a command object for diffing a file or directory
// in the working tree. When pathOverrides is non-empty, those paths are used instead of
// the node's path (used to diff only filtered/visible files within a directory).
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj {
// ignoreExternalDiff forces git's own (coloured) diff regardless of a configured
// external diff command, for the focused main view's raw-diff fallback (see
// StagingHelper.DiffMainViewShouldRenderRaw); unlike plain it keeps the colour.
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, ignoreExternalDiff bool, pathOverrides []string) *oscommands.CmdObj {
colorArg := self.diffRendererConfigManager.GetColorArg()
if plain {
colorArg = "never"
@@ -407,7 +410,7 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
}
cmdArgs := NewGitCmd("diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain && !ignoreExternalDiff).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", colorArg)).
ArgIf(cached, "--cached").
@@ -431,10 +434,10 @@ func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bo
if previousPath != "" {
fileNames = append(fileNames, previousPath)
}
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain).RunWithOutput()
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain, false).RunWithOutput()
}
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj {
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool, ignoreExternalDiff bool) *oscommands.CmdObj {
colorArg := self.diffRendererConfigManager.GetColorArg()
if plain {
colorArg = "never"
@@ -442,7 +445,7 @@ func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reve
cmdArgs := NewGitCmd("diff").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain && !ignoreExternalDiff).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", colorArg)).
Arg(from).
+5
View File
@@ -16,6 +16,11 @@ type Hunk struct {
newStart int
// the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example)
headerContext string
// the lengths declared in the hunk header ('2' and '3' in the above example),
// kept so we can verify the parsed body actually matches the header (see
// Patch.IsWellFormed). Only populated by Parse.
declaredOldLength int
declaredNewLength int
// the body of the hunk, excluding the header line
bodyLines []*PatchLine
}
+26 -11
View File
@@ -7,7 +7,9 @@ import (
"github.com/jesseduffield/lazygit/pkg/utils"
)
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
// Captures, in order: old start, old length (omitted when 1), new start, new
// length (omitted when 1), and the trailing context.
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$`)
func Parse(patchStr string) *Patch {
// ignore trailing newline.
@@ -19,13 +21,15 @@ func Parse(patchStr string) *Patch {
var currentHunk *Hunk
for _, line := range lines {
if strings.HasPrefix(line, "@@") {
oldStart, newStart, headerContext := headerInfo(line)
oldStart, oldLength, newStart, newLength, headerContext := headerInfo(line)
currentHunk = &Hunk{
oldStart: oldStart,
newStart: newStart,
headerContext: headerContext,
bodyLines: []*PatchLine{},
oldStart: oldStart,
newStart: newStart,
declaredOldLength: oldLength,
declaredNewLength: newLength,
headerContext: headerContext,
bodyLines: []*PatchLine{},
}
hunks = append(hunks, currentHunk)
} else if currentHunk != nil {
@@ -41,14 +45,25 @@ func Parse(patchStr string) *Patch {
}
}
func headerInfo(header string) (int, int, string) {
func headerInfo(header string) (oldStart int, oldLength int, newStart int, newLength int, headerContext string) {
match := hunkHeaderRegexp.FindStringSubmatch(header)
oldStart := utils.MustConvertToInt(match[1])
newStart := utils.MustConvertToInt(match[2])
headerContext := match[3]
oldStart = utils.MustConvertToInt(match[1])
oldLength = declaredLength(match[2])
newStart = utils.MustConvertToInt(match[3])
newLength = declaredLength(match[4])
headerContext = match[5]
return oldStart, newStart, headerContext
return oldStart, oldLength, newStart, newLength, headerContext
}
// declaredLength parses a hunk header length capture, which git omits when it
// is 1 (e.g. "@@ -0,0 +1 @@").
func declaredLength(match string) int {
if match == "" {
return 1
}
return utils.MustConvertToInt(match)
}
func newHunkLine(line string) *PatchLine {
+116
View File
@@ -79,6 +79,23 @@ func (self *Patch) HunkEndIdx(hunkIndex int) int {
return self.HunkStartIdx(hunkIndex) + self.hunks[hunkIndex].lineCount() - 1
}
// IsWellFormed reports whether every hunk's body matches the lengths declared
// in its header. A faithful unified diff always satisfies this. A rendering that
// restructured the diff body does not — e.g. delta with line-number gutters
// shifts the +/- marker off the start of each line, so every body line reads as
// context and the computed lengths no longer match the header. This lets a
// parser tell a real unified diff from a mangled one and fall back rather than
// trust a mis-parse. Only meaningful for patches produced by Parse (the lengths
// are read from the header there).
func (self *Patch) IsWellFormed() bool {
for _, hunk := range self.hunks {
if hunk.oldLength() != hunk.declaredOldLength || hunk.newLength() != hunk.declaredNewLength {
return false
}
}
return true
}
func (self *Patch) ContainsChanges() bool {
return lo.SomeBy(self.hunks, func(hunk *Hunk) bool {
return hunk.containsChanges()
@@ -114,6 +131,105 @@ func (self *Patch) LineNumberOfLine(idx int) int {
return hunk.newStart + offset
}
// Takes a line index in the patch and returns the line number in the old file.
// This is the old-file counterpart of LineNumberOfLine; for a deletion it gives
// the line's position in the old file (additions get the position they sit at).
// If the line is a header line, returns 1.
// If the line is a hunk header line, returns the first old-file line number in
// that hunk.
// If the line is out of range below, returns the last old-file line number in
// the last hunk.
func (self *Patch) OldLineNumberOfLine(idx int) int {
if idx < len(self.header) || len(self.hunks) == 0 {
return 1
}
hunkIdx := self.HunkContainingLine(idx)
// cursor out of range, return last file line number
if hunkIdx == -1 {
lastHunk := self.hunks[len(self.hunks)-1]
return lastHunk.oldStart + lastHunk.oldLength() - 1
}
hunk := self.hunks[hunkIdx]
hunkStartIdx := self.HunkStartIdx(hunkIdx)
idxInHunk := idx - hunkStartIdx
if idxInHunk == 0 {
return hunk.oldStart
}
lines := hunk.bodyLines[:idxInHunk-1]
offset := nLinesWithKind(lines, []PatchLineKind{DELETION, CONTEXT})
return hunk.oldStart + offset
}
// Takes a line number in the new file and returns the line index in the patch.
// This is the opposite of LineNumberOfLine.
// If the line number is not contained in any of the hunks, it returns the
// closest position.
func (self *Patch) PatchLineForLineNumber(lineNumber int) int {
if len(self.hunks) == 0 {
return len(self.header)
}
for hunkIdx, hunk := range self.hunks {
if lineNumber <= hunk.newStart {
return self.HunkStartIdx(hunkIdx)
}
if lineNumber < hunk.newStart+hunk.newLength() {
lines := hunk.bodyLines
offset := lineNumber - hunk.newStart
for i, line := range lines {
if offset == 0 {
return self.HunkStartIdx(hunkIdx) + i + 1
}
if line.Kind == ADDITION || line.Kind == CONTEXT {
offset--
}
}
}
}
return self.LineCount() - 1
}
// Takes a line number in the old file and returns the line index in the patch.
// This is the old-file counterpart of PatchLineForLineNumber. It is what lets us
// land on the right patch line for a deletion: two consecutive deletions share a
// new-file line number, so only the old-file number tells them apart.
// If the line number is not contained in any of the hunks, it returns the
// closest position.
func (self *Patch) PatchLineForOldLineNumber(lineNumber int) int {
if len(self.hunks) == 0 {
return len(self.header)
}
for hunkIdx, hunk := range self.hunks {
if lineNumber <= hunk.oldStart {
return self.HunkStartIdx(hunkIdx)
}
if lineNumber < hunk.oldStart+hunk.oldLength() {
lines := hunk.bodyLines
offset := lineNumber - hunk.oldStart
for i, line := range lines {
if offset == 0 {
return self.HunkStartIdx(hunkIdx) + i + 1
}
if line.Kind == DELETION || line.Kind == CONTEXT {
offset--
}
}
}
}
return self.LineCount() - 1
}
// Returns hunk index containing the line at the given patch line index
func (self *Patch) HunkContainingLine(idx int) int {
for hunkIdx, hunk := range self.hunks {
+160 -1
View File
@@ -1,6 +1,7 @@
package patch
import (
"os"
"sort"
"strings"
@@ -60,12 +61,26 @@ type PatchBuilder struct {
// loadFileDiff loads the diff of a file, for a given to (typically a commit hash)
loadFileDiff loadFileDiffFunc
// newTempDir creates a fresh temp dir for the current patch, into which the patch is
// materialized as two file trees so it can be rendered through any pager (see
// PatchCommands.WriteCustomPatchDiffTrees). The patch builder owns its lifetime: a dir
// is created on Start and removed on Reset. Nil in tests that don't render the patch.
newTempDir func() (string, error)
tempDir string
// generation is bumped on every change to the patch's contents, so a consumer that
// materializes the patch (the secondary pane's two file trees) can tell when it's stale
// and rebuild only then — covering every path that mutates the patch (the focused main
// view and the old explorer alike) without rebuilding on mere navigation.
generation int
}
func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBuilder {
func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc, newTempDir func() (string, error)) *PatchBuilder {
return &PatchBuilder{
Log: log,
loadFileDiff: loadFileDiff,
newTempDir: newTempDir,
}
}
@@ -73,6 +88,16 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) {
p.mutex.Lock()
defer p.mutex.Unlock()
p.generation++
p.removeTempDir()
if p.newTempDir != nil {
if dir, err := p.newTempDir(); err != nil {
p.Log.Error(err)
} else {
p.tempDir = dir
}
}
p.To = to
p.From = from
p.reverse = reverse
@@ -91,6 +116,37 @@ func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo {
return p.fileInfoMap
}
// TempDir is the directory the current patch is materialized into for rendering, or "" if
// none was created. See PatchCommands.WriteCustomPatchDiffTrees.
func (p *PatchBuilder) TempDir() string {
return p.tempDir
}
// Generation is bumped each time the patch's contents change; see the field comment.
func (p *PatchBuilder) Generation() int {
return p.generation
}
func (p *PatchBuilder) removeTempDir() {
if p.tempDir != "" {
_ = os.RemoveAll(p.tempDir)
p.tempDir = ""
}
}
// ActiveFilenames returns the files currently part of the patch (mode != UNSELECTED), in
// sorted order — the files to materialize when rendering the patch.
func (p *PatchBuilder) ActiveFilenames() []string {
filenames := make([]string, 0, len(p.fileInfoMap))
for filename, info := range p.fileInfoMap {
if info.mode != UNSELECTED {
filenames = append(filenames, filename)
}
}
sort.Strings(filenames)
return filenames
}
func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string {
var patch strings.Builder
@@ -135,6 +191,7 @@ func (p *PatchBuilder) AddFileWhole(filename string, previousPath string) error
return err
}
p.generation++
p.addFileWhole(info)
return nil
@@ -146,6 +203,8 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error {
return err
}
p.generation++
p.removeFile(info)
return nil
@@ -182,6 +241,7 @@ func (p *PatchBuilder) AddFileLineRange(filename string, previousPath string, li
if err != nil {
return err
}
p.generation++
info.mode = PART
info.includedLineIndices = lo.Union(info.includedLineIndices, lineIndices)
@@ -193,6 +253,7 @@ func (p *PatchBuilder) RemoveFileLineRange(filename string, previousPath string,
if err != nil {
return err
}
p.generation++
info.mode = PART
info.includedLineIndices, _ = lo.Difference(info.includedLineIndices, lineIndices)
if len(info.includedLineIndices) == 0 {
@@ -299,11 +360,109 @@ 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
}
// IncludedChangeLineIndices returns the patch-line indices of the change lines (additions
// and deletions) currently included in the patch for filename, in ascending order. These
// are exactly the change lines the aggregated patch renders for the file, in the same
// order, so the k-th change line shown in the custom-patch (secondary) view corresponds to
// the k-th index here. That correspondence lets the focused main view remove a selection
// from the patch by its ordinal among the shown change lines, sidestepping the line-number
// renumbering the aggregated patch applies (which makes matching by identity unreliable for
// additions). Empty when the file isn't part of the patch.
func (p *PatchBuilder) IncludedChangeLineIndices(filename string) []int {
info, ok := p.fileInfoMap[filename]
if !ok || info.mode == UNSELECTED {
return nil
}
lines := Parse(info.diff).Lines()
included := append([]int{}, info.includedLineIndices...)
sort.Ints(included)
result := make([]int, 0, len(included))
for _, idx := range included {
if idx >= 0 && idx < len(lines) && (lines[idx].IsAddition() || lines[idx].IsDeletion()) {
result = append(result, idx)
}
}
return result
}
// 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()
defer p.mutex.Unlock()
p.generation++
p.removeTempDir()
p.To = ""
p.fileInfoMap = map[string]*fileInfo{}
}
+53
View File
@@ -0,0 +1,53 @@
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
},
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"))
}
+103
View File
@@ -120,6 +120,20 @@ index 9320895..6d79956 100644
lemon
`
// Two deletions with no line between them: they share a new-file line number
// (both sit at the same new-file position), so only their old-file line numbers
// distinguish them.
const consecutiveDeletions = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
-grape
-pear
lemon
`
const newFile = `diff --git a/newfile b/newfile
new file mode 100644
index 0000000..4e680cc
@@ -682,6 +696,95 @@ func TestLineNumberOfLine(t *testing.T) {
}
}
func TestOldLineNumberOfLine(t *testing.T) {
type scenario struct {
testName string
patchStr string
indexes []int
expecteds []int
}
scenarios := []scenario{
{
testName: "twoChangesInOneHunk",
patchStr: twoChangesInOneHunk,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5, 5},
},
{
testName: "consecutiveDeletions",
patchStr: consecutiveDeletions,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 4, 4},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
for i, idx := range s.indexes {
patch := Parse(s.patchStr)
result := patch.OldLineNumberOfLine(idx)
assert.Equal(t, s.expecteds[i], result)
}
})
}
}
func TestPatchLineForOldLineNumber(t *testing.T) {
type scenario struct {
testName string
patchStr string
lineNumbers []int
expecteds []int
}
scenarios := []scenario{
{
// The two consecutive deletions (grape, pear) sit at patch line
// indices 6 and 7; their old-file line numbers (2 and 3) keep them
// distinct even though their new-file line numbers are both 2.
testName: "consecutiveDeletions",
patchStr: consecutiveDeletions,
lineNumbers: []int{0, 1, 2, 3, 4, 5},
expecteds: []int{4, 4, 6, 7, 8, 8},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
for i, lineNumber := range s.lineNumbers {
patch := Parse(s.patchStr)
result := patch.PatchLineForOldLineNumber(lineNumber)
assert.Equal(t, s.expecteds[i], result)
}
})
}
}
func TestIsWellFormed(t *testing.T) {
assert.True(t, Parse(simpleDiff).IsWellFormed())
assert.True(t, Parse(twoHunks).IsWellFormed())
assert.True(t, Parse(consecutiveDeletions).IsWellFormed())
assert.True(t, Parse(newFile).IsWellFormed())
assert.True(t, Parse(deletedFile).IsWellFormed())
assert.True(t, Parse(addNewlineToEndOfFile).IsWellFormed())
// A diff whose body has been mangled so the +/- markers no longer start each
// line (as delta's line-number gutters do) reads as all-context, so the body
// lengths no longer match the header.
gutterMangled := `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
grape
pear
lemon
`
assert.False(t, Parse(gutterMangled).IsWellFormed())
}
func TestGetNextStageableLineIndex(t *testing.T) {
type scenario struct {
testName string
@@ -87,6 +87,14 @@ func (self *DiffRendererConfigManager) GetColorArg() string {
return colorArg
}
func (self *DiffRendererConfigManager) GetNarrowSelectionHighlight() bool {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil {
return false
}
return currentDiffRendererConfig.NarrowSelectionHighlight
}
func (self *DiffRendererConfigManager) GetExternalDiffCommand(diffContext uint64) string {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_ExtDiff {
@@ -70,6 +70,20 @@ func TestCurrentDiffRendererNameWithoutDiffRenderers(t *testing.T) {
assert.Equal(t, tr.DefaultDiffRendererName, config.CurrentDiffRendererName(tr))
}
func TestGetNarrowSelectionHighlight(t *testing.T) {
userConfig := &UserConfig{}
userConfig.Git.DiffRenderers = []DiffRendererConfig{{NarrowSelectionHighlight: true}}
config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig })
assert.Equal(t, true, config.GetNarrowSelectionHighlight())
}
func TestGetNarrowSelectionHighlightWithoutPagers(t *testing.T) {
config := NewDiffRendererConfigManager(func() *UserConfig { return &UserConfig{} })
assert.Equal(t, false, config.GetNarrowSelectionHighlight())
}
func TestCycleDiffRenderers(t *testing.T) {
userConfig := &UserConfig{}
userConfig.Git.DiffRenderers = []DiffRendererConfig{{Name: "a"}, {Name: "b"}, {Name: "c"}}
+8
View File
@@ -281,6 +281,12 @@ type GitConfig struct {
// # renderer command.
// name: ""
//
// # If true, selected diff lines are highlighted only with a narrow bar
// # at the left edge of the view, rather than across the whole width.
// # Useful for pagers whose own line background colors should remain
// # visible.
// narrowSelectionHighlight: false
//
// # Value of the --color arg in the git diff command. Only used for type
// # 'stdinFilter'. Some renderers want this to be set to 'always' and some
// # want it set to 'never'.
@@ -377,6 +383,8 @@ type DiffRendererConfig struct {
Type string `yaml:"type" jsonschema:"enum=stdinFilter,enum=extDiff,enum=rawGit"`
// A name for the diff renderer, shown in the notification when cycling renderers. If not set, the name is derived from the first word of the renderer command.
Name string `yaml:"name"`
// If true, selected diff lines are highlighted only with a narrow bar at the left edge of the view, rather than across the whole width. Useful for pagers whose own line background colors should remain visible.
NarrowSelectionHighlight bool `yaml:"narrowSelectionHighlight"`
// Value of the --color arg in the git diff command. Only used for type 'stdinFilter'. Some renderers want this to be set to 'always' and some want it set to 'never'.
ColorArg string `yaml:"colorArg" jsonschema:"enum=always,enum=never"`
// The command to use for rendering diffs. This is either a stdinFilter or an external diff command, depending on the type field; not applicable if the type is 'rawGit'.
+95 -20
View File
@@ -20,6 +20,24 @@ 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 1717 per-line diff-metadata sequence (see
// diff-line-metadata-notes.md), accumulated like hyperlink
metadata strings.Builder
// OSC 1717 payloads that were superseded by the next record before any
// cell consumed them. A row can carry several records back-to-back with
// nothing rendered in between — difftastic's combined file+hunk banner, or
// the deletion+addition pair of a modification row collapsed to a single
// column — so a record's region can be zero-width. The write loop drains
// these into content-less carrier cells, keeping every record of the row
// discoverable instead of only the last one.
orphanedMetadata []string
// whether the payload currently in metadata has been stamped onto at
// least one cell; an unconsumed payload is orphaned rather than dropped
// when the next record arrives or the line ends
metadataConsumed bool
// 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 +100,9 @@ const (
stateParams
stateCSIDiscard
stateOSC
stateOSCWaitForParams
stateOSCParams
stateOSCHyperlink
stateOSCMetadata
stateOSCEndEscape
stateOSCSkipUnknown
@@ -427,27 +445,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 1717 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 "1717":
ei.orphanUnconsumedMetadata()
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 +494,18 @@ 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.dropMetadataIfHandshake()
ei.state = stateNone
case characterEquals(ch, 0x1b):
ei.dropMetadataIfHandshake()
ei.state = stateOSCEndEscape
default:
ei.metadata.Write(ch)
}
return true, nil
case stateOSCEndEscape:
ei.state = stateNone
return true, nil
@@ -478,6 +521,38 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
return false, nil
}
// orphanUnconsumedMetadata prepares the metadata accumulator for a new OSC 1717
// record. If the previous record's payload never made it onto a cell (its region
// is zero-width because the next record follows immediately — see
// orphanedMetadata), it is kept as an orphan rather than dropped.
func (ei *escapeInterpreter) orphanUnconsumedMetadata() {
if ei.metadata.Len() > 0 && !ei.metadataConsumed {
ei.orphanedMetadata = append(ei.orphanedMetadata, ei.metadata.String())
}
ei.metadata.Reset()
ei.metadataConsumed = false
}
// takeOrphanedMetadata hands the accumulated zero-width-record payloads (see
// orphanedMetadata) to the caller and clears the list.
func (ei *escapeInterpreter) takeOrphanedMetadata() []string {
result := ei.orphanedMetadata
ei.orphanedMetadata = nil
return result
}
// dropMetadataIfHandshake discards a just-completed OSC 1717 payload that carries no
// fields (no ';'). A metadata-aware pager emits such a version-only record once, as
// its first output, to announce it speaks the protocol (so we can probe it; see the
// raw-diff fallback in the lazygit staging helper). It isn't per-line metadata, so it
// must not linger in the accumulator and attach to the following line. Per-line
// payloads always have fields, so they're kept.
func (ei *escapeInterpreter) dropMetadataIfHandshake() {
if !strings.Contains(ei.metadata.String(), ";") {
ei.metadata.Reset()
}
}
func (ei *escapeInterpreter) outputCSI() error {
n := len(ei.csiParam)
for i := 0; i < n; {
+33 -10
View File
@@ -86,6 +86,14 @@ type ViewMouseBinding struct {
// must be a mouse key
Key KeyName
// If true, this binding is dispatched before ShouldHandleMouseEvent is
// consulted, so it fires even when a popup panel is focused and the click
// lands on a view other than that panel (which is normally swallowed). This
// is the same early phase that hyperlink clicks are handled in; use it for
// clicks that must stay live behind a popup, e.g. opening a diff line in the
// editor from the main view behind the commit-message panel.
HandleWhenPopupPanelFocused bool
}
type ViewMouseBindingOpts struct {
@@ -1246,7 +1254,7 @@ func calcScrollbarRune(
func calcRealScrollbarStartEnd(v *View) (bool, int, int) {
height := v.InnerHeight()
fullHeight := v.ViewLinesHeight() - v.scrollMargin()
fullHeight := v.scrollbarContentHeight() - v.scrollMargin()
if v.CanScrollPastBottom {
fullHeight += height
@@ -1467,7 +1475,7 @@ func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error {
// drawListFooter draws the footer of a list view, showing something like '1 of 10'
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
if len(v.lines) == 0 {
if len(v.buf.lines) == 0 {
return nil
}
@@ -1717,13 +1725,13 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
if newY < 0 {
newY = 0
newCy = -v.oy
} else if newY >= len(v.lines) {
newY = len(v.lines) - 1
} else if newY >= len(v.buf.lines) {
newY = len(v.buf.lines) - 1
newCy = newY - v.oy
}
visibleLineWidth := 0
for _, c := range v.lines[newY].cells {
for _, c := range v.buf.lines[newY].cells {
visibleLineWidth += c.width
}
if visibleLineWidth < newX {
@@ -1740,6 +1748,22 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
}
}
var mouseOpts ViewMouseBindingOpts
if IsMouseKey(ev.Key) {
isDoubleClick := g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
mouseOpts = ViewMouseBindingOpts{X: newX, Y: newY, Key: ev.Key.KeyName(), IsDoubleClick: isDoubleClick}
// Dispatch bindings that opt into firing while a popup panel is focused
// before the gate below gets a chance to reject the click.
matched, err := g.execMouseKeybindings(v, ev, mouseOpts, true)
if err != nil {
return err
}
if matched {
return nil
}
}
if g.ShouldHandleMouseEvent != nil {
if !g.ShouldHandleMouseEvent(v, ev.Key.KeyName()) {
// Give clients a chance to reject clicks, for example clicks in inactive views
@@ -1789,9 +1813,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
}
if IsMouseKey(ev.Key) {
isDoubleClick := g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
opts := ViewMouseBindingOpts{X: newX, Y: newY, Key: ev.Key.KeyName(), IsDoubleClick: isDoubleClick}
matched, err := g.execMouseKeybindings(v, ev, opts)
matched, err := g.execMouseKeybindings(v, ev, mouseOpts, false)
if err != nil {
return err
}
@@ -1855,11 +1877,12 @@ func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) bool {
return isDoubleClick
}
func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts) (bool, error) {
func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts, handleWhenPopupPanelFocused bool) (bool, error) {
isMatch := func(binding *ViewMouseBinding) bool {
return binding.ViewName == view.Name() &&
ev.Key.KeyName() == binding.Key &&
ev.Key.Mod() == binding.Modifier
ev.Key.Mod() == binding.Modifier &&
binding.HandleWhenPopupPanelFocused == handleWhenPopupPanelFocused
}
// first pass looks for ones that match the focused view
+14 -3
View File
@@ -202,6 +202,7 @@ const (
var (
lastMouseKey tcell.ButtonMask = tcell.ButtonNone
lastMouseMod tcell.ModMask = tcell.ModNone
dragState = NOT_DRAGGING
lastX = 0
lastY = 0
@@ -370,6 +371,15 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
if button != tcell.ButtonNone && lastMouseKey == tcell.ButtonNone {
newButtonPress = true
lastMouseKey = button
// The keyboard modifiers held at press time apply to the whole
// gesture: the press (a click is reported on the press, so this is
// what makes modified clicks bindable), every drag event, and the
// release. Snapshotting them here keeps the gesture atomic: a
// modified press can't produce drag/release events that match an
// unmodified binding, and pressing or releasing a modifier while
// the button is held doesn't change the gesture midway.
lastMouseMod = tev.Modifiers()
mouseMod = Modifier(lastMouseMod)
switch button {
case tcell.ButtonPrimary:
mouseKey = MouseLeft
@@ -395,7 +405,8 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
case tcell.ButtonMiddle:
default:
}
mouseMod = ModNone
mouseMod = Modifier(lastMouseMod)
lastMouseMod = tcell.ModNone
lastMouseKey = tcell.ButtonNone
}
default:
@@ -426,10 +437,10 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
// reaches drag bindings instead of being delivered with the
// default MouseRelease key.
dragState = DRAGGING
mouseMod = ModMotion
mouseMod = Modifier(lastMouseMod) | ModMotion
mouseKey = MouseLeft
case DRAGGING:
mouseMod = ModMotion
mouseMod = Modifier(lastMouseMod) | ModMotion
mouseKey = MouseLeft
}
}
+37 -3
View File
@@ -36,21 +36,55 @@ func TestMouseReleaseAfterDragIsMouseEvent(t *testing.T) {
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
}
func TestMouseReleaseDoesNotKeepPressModifiers(t *testing.T) {
func TestWholeGestureCarriesPressModifiers(t *testing.T) {
t.Cleanup(resetMouseState)
resetMouseState()
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt))
assert.Equal(t, eventMouse, pressEvent.Type)
assert.Equal(t, MouseLeft, pressEvent.Key.KeyName())
assert.Equal(t, ModAlt, pressEvent.Key.Mod())
assert.Equal(t, eventMouse, dragEvent.Type)
assert.Equal(t, MouseLeft, dragEvent.Key.KeyName())
assert.Equal(t, ModAlt|ModMotion, dragEvent.Key.Mod())
assert.Equal(t, eventMouse, releaseEvent.Type)
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
assert.Equal(t, ModAlt, releaseEvent.Key.Mod())
}
func TestModifierChangesWhileButtonHeldAreIgnored(t *testing.T) {
t.Cleanup(resetMouseState)
resetMouseState()
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModNone))
dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt))
assert.Equal(t, ModMotion, dragEvent.Key.Mod())
assert.Equal(t, ModNone, releaseEvent.Key.Mod())
}
func TestModifiedClickWithoutDragCarriesModifierOnPressAndRelease(t *testing.T) {
t.Cleanup(resetMouseState)
resetMouseState()
pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModShift))
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonNone, tcell.ModShift))
assert.Equal(t, eventMouse, pressEvent.Type)
assert.Equal(t, MouseLeft, pressEvent.Key.KeyName())
assert.Equal(t, ModShift, pressEvent.Key.Mod())
assert.Equal(t, eventMouse, releaseEvent.Type)
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
assert.Equal(t, ModShift, releaseEvent.Key.Mod())
}
func resetMouseState() {
lastMouseKey = tcell.ButtonNone
lastMouseMod = tcell.ModNone
dragState = NOT_DRAGGING
lastX = 0
lastY = 0
+611 -150
View File
File diff suppressed because it is too large Load Diff
+428 -15
View File
@@ -101,13 +101,13 @@ func TestWriteString(t *testing.T) {
for _, test := range tests {
v := NewView("name", 0, 0, 10, 10, OutputNormal)
for _, l := range test.existingLines {
v.lines = append(v.lines, lineType{cells: stringToCells(l)})
v.buf.lines = append(v.buf.lines, lineType{cells: stringToCells(l)})
}
for _, s := range test.stringsToWrite {
v.writeString(s)
}
var resultingLines [][]string
for _, l := range v.lines {
for _, l := range v.buf.lines {
resultingLines = append(resultingLines, cellsToStrings(l.cells))
}
assert.Equal(t, test.expectedLines, resultingLines)
@@ -144,19 +144,359 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
v.writeString("htt")
// No hyperlinks are generated for incomplete URLs
assert.Equal(t, "", v.lines[0].cells[0].hyperlink)
assert.Equal(t, "", v.buf.lines[0].cells[0].hyperlink)
// Writing more characters to the same line makes the link complete (even
// though we didn't see a newline yet)
v.writeString("ps://example.com")
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
v.Clear()
// Valid but incomplete URL
v.writeString("https://exa")
assert.Equal(t, "https://exa", v.lines[0].cells[0].hyperlink)
assert.Equal(t, "https://exa", v.buf.lines[0].cells[0].hyperlink)
// Writing more characters to the same fixes the link
v.writeString("mple.com")
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
}
func TestSelectedLineBgColorWidth(t *testing.T) {
tests := []struct {
name string
bgColorWidth int
selected func(screenX int) bool
}{
{
name: "zero uses full-width selection background",
bgColorWidth: 0,
selected: func(_ int) bool { return true },
},
{
name: "non-zero uses left-edge selection background",
bgColorWidth: 2,
selected: func(screenX int) bool { return screenX <= 2 },
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
WithSimulationScreen(t, 14, 4)
v := NewView("name", 0, 0, 11, 3, OutputNormal) // InnerWidth=10
v.Highlight = true
v.SelBgColor = ColorBlue
v.SelectedLineBgColorWidth = test.bgColorWidth
v.writeString("0123456789\n")
v.draw()
selectedBg := getTcellColor(ColorBlue, OutputNormal)
for screenX := 1; screenX <= 10; screenX++ {
_, style, _ := Screen.Get(screenX, 1)
if test.selected(screenX) {
assert.Equal(t, selectedBg, style.GetBackground(), "cell %d should show the selection background", screenX)
} else {
assert.Equal(t, tcell.ColorDefault, style.GetBackground(), "cell %d should keep its original background", screenX)
}
}
})
}
}
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 1717 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]1717;" + 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.buf.lines))
for y := range v.buf.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 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())
}
func TestDiffLineMetadataZeroWidthRecords(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A row can carry several records back-to-back with nothing rendered in
// between, making all but the last record's region zero-width. Those
// payloads must survive as payloads of the row (via content-less carrier
// cells) rather than being clobbered by the record that follows.
osc := func(payload string) string { return "\x1b]1717;" + payload + "\x1b\\" }
v.writeString(strings.Join([]string{
// difftastic's combined file+hunk banner: the f is immediately
// followed by the h.
osc("1;f;;;foo.txt") + osc("1;h;5;;foo.txt") + "foo.txt --- Go",
// A modification row collapsed to a single column carries its
// deletion and its addition back-to-back before the row's content.
osc("1;d;5;5;foo.txt") + osc("1;a;5;;foo.txt") + "595 new content",
// A record emitted right before the line end covers no cell either.
"trailing" + osc("1;d;6;6;foo.txt"),
}, "\n") + "\n")
assert.Equal(t, [][]string{
{"1;f;;;foo.txt", "1;h;5;;foo.txt"},
{"1;d;5;5;foo.txt", "1;a;5;;foo.txt"},
{"1;d;6;6;foo.txt"},
}, v.DiffLineMetadataPayloads())
// The carrier cells are invisible: the rendered text is unchanged.
assert.Equal(t, []string{
"foo.txt --- Go",
"595 new content",
"trailing",
}, v.BufferLines())
// A row's single-payload identity is its first record: the f of a banner,
// the d of a collapsed modification row.
payload, ok := v.DiffLineMetadataInLine(0)
assert.True(t, ok)
assert.Equal(t, "1;f;;;foo.txt", payload)
payload, ok = v.DiffLineMetadataInLine(1)
assert.True(t, ok)
assert.Equal(t, "1;d;5;5;foo.txt", payload)
}
func TestDiffLineMetadataHandshakeSwallowed(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A metadata-aware pager emits a version-only handshake (an OSC 1717 with no
// fields) as its first output, immediately before the diff, to announce it speaks
// the protocol. It must be swallowed whole: no visible bytes, no phantom line, and
// crucially it must not attach as metadata to the diff header that follows it.
osc := func(payload string) string { return "\x1b]1717;" + payload + "\x1b\\" }
v.writeString(osc("1") + strings.Join([]string{
"diff --git a/foo.txt b/foo.txt",
osc("1;a;1;;foo.txt") + "added",
}, "\n"))
// The handshake produced no phantom line and no visible bytes.
assert.Equal(t, []string{
"diff --git a/foo.txt b/foo.txt",
"added",
}, v.BufferLines())
// The handshake didn't bleed onto the header line, and the real per-line metadata
// after it still applies.
type result struct {
payload string
ok bool
}
got := make([]result, len(v.buf.lines))
for y := range v.buf.lines {
payload, ok := v.DiffLineMetadataInLine(y)
got[y] = result{payload, ok}
}
assert.Equal(t, []result{
{"", false},
{"1;a;1;;foo.txt", true},
}, got)
}
// 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 line that no longer exists onto the wrong buffer line — and with wrapping
// the stale entry's buffer index can still be in range of the new, shorter,
// less-wrapped buffer, so an in-range guard alone wouldn't catch it. See
// diff-line-metadata-notes.md §8.
func TestBufferLineForViewLineStaleTail(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// First render: two lines that each wrap into three view lines, so the
// buffer has 2 lines but there are 6 view lines.
v.writeString(strings.Repeat("a", 27) + "\n" + strings.Repeat("b", 27))
assert.Equal(t, 6, v.ViewLinesHeight())
// Re-render with shorter content (rewind, then overwrite from the top with
// three short, unwrapped lines). There are now only 3 view lines.
v.Reset()
v.writeString("aaa\nbbb\nccc")
assert.Equal(t, 3, v.ViewLinesHeight())
// A real view line maps to its buffer line as usual.
bufferLine, ok := v.BufferLineForViewLine(1)
assert.True(t, ok)
assert.Equal(t, 1, bufferLine)
// View line 4 no longer exists in the current buffer, so the mapping must
// fail rather than land on a stale entry from the previous render.
_, ok = v.BufferLineForViewLine(4)
assert.False(t, ok)
}
// An async re-render builds into an off-screen buffer and swaps it in once it
// has enough to paint, so readers keep seeing the previous render — coherent and
// consistent — until the new content appears in one step. See View.offscreen.
func TestOffscreenRender(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
v.writeString("a\nb\nc")
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
// Render new, longer content off-screen.
v.BeginOffscreenRender()
v.writeString("w\nx\ny\nz")
// The displayed buffer is untouched: readers still see the previous render,
// and the view-line→buffer-line mapping stays consistent with it.
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
bufferLine, ok := v.BufferLineForViewLine(1)
assert.True(t, ok)
assert.Equal(t, 1, bufferLine)
// Swapping in reveals the new content in one step.
v.SwapInOffscreenRender()
assert.Equal(t, []string{"w", "x", "y", "z"}, v.ViewBufferLines())
// A further write now appends to the displayed buffer directly.
v.writeString("\nmore")
assert.Equal(t, []string{"w", "x", "y", "z", "more"}, v.ViewBufferLines())
}
// The escape restore scans the *incoming* content of a re-render as it loads,
// before it is swapped in, so it can find the row matching a target identity and
// decide when to swap. That means reading the off-screen buffer's loaded rows
// (text, metadata, hyperlink) while the displayed buffer still shows the old
// render. See View.offscreen / OffscreenDiffLineContents.
func TestOffscreenDiffLineContents(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// No off-screen render in progress: nothing to scan.
assert.Nil(t, v.OffscreenDiffLineContents())
osc := func(payload string) string { return "\x1b]1717;" + payload + "\x1b\\" }
v.BeginOffscreenRender()
v.writeString(strings.Join([]string{
osc("1;c;1;;foo.txt") + "context",
osc("1;a;2;;foo.txt") + "added",
}, "\n"))
contents := v.OffscreenDiffLineContents()
assert.Equal(t, []DiffLineContent{
{Text: "context", Metadata: "1;c;1;;foo.txt"},
{Text: "added", Metadata: "1;a;2;;foo.txt"},
}, contents)
// The displayed buffer is still empty; the scan reads the off-screen render.
assert.Empty(t, v.BufferLines())
}
// The escape restore matches a target identity against a buffer line, then needs
// the view line that renders it to scroll there and select it. ViewLineForBufferLine
// is that inverse mapping, and it must point at the *first* of the (wrapped) view
// lines a buffer line spans.
func TestViewLineForBufferLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// Buffer line 0 is short (one view line); buffer line 1 wraps into three view
// lines (view lines 1, 2, 3); buffer line 2 is short again (view line 4).
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for bufferLine, wantViewLine := range map[int]int{0: 0, 1: 1, 2: 4} {
viewLine, ok := v.ViewLineForBufferLine(bufferLine)
assert.True(t, ok)
assert.Equal(t, wantViewLine, viewLine)
}
_, ok := v.ViewLineForBufferLine(3)
assert.False(t, ok)
}
// While an async re-render loads, it swaps in only a partially-filled buffer at
// its first paint and keeps appending lines afterwards. The scrollbar must keep
// using the pre-load height until the load ends, so the thumb doesn't shrink and
// snap back as the rest streams in. See View.scrollbarHeightFloor.
func TestScrollbarHeightHeldWhileLoading(t *testing.T) {
v := NewView("name", 0, 0, 80, 12, OutputNormal)
// Initial render: 100 lines, scrolled well down.
v.writeString(strings.Repeat("x\n", 100))
v.SetOrigin(0, 80)
assert.Equal(t, 100, v.scrollbarContentHeight())
// A re-render begins while the previous render is still shown: hold the
// scrollbar height at the current value.
v.FreezeScrollbarHeight()
// The off-screen render swaps in only a screenful at its first paint.
v.BeginOffscreenRender()
v.writeString(strings.Repeat("y\n", 30))
v.SwapInOffscreenRender()
// The displayed buffer is now short, but the scrollbar height stays held, so
// the thumb keeps its position instead of jumping.
assert.Equal(t, 30, v.ViewLinesHeight())
assert.Equal(t, 100, v.scrollbarContentHeight())
// The rest of the content streams in.
v.writeString(strings.Repeat("y\n", 70))
assert.Equal(t, 100, v.scrollbarContentHeight())
// Once the load ends, the scrollbar tracks the real content directly again.
v.UnfreezeScrollbarHeight()
assert.Equal(t, 100, v.scrollbarContentHeight())
}
// If a synchronous render (e.g. a string render) supersedes a still-loading diff
// before it reaches its end, the held scrollbar height must be released, so the
// scrollbar reflects the new content rather than the abandoned load's height.
func TestScrollbarHeightReleasedWhenContentReplaced(t *testing.T) {
v := NewView("name", 0, 0, 80, 12, OutputNormal)
v.writeString(strings.Repeat("x\n", 100))
v.FreezeScrollbarHeight()
assert.Equal(t, 100, v.scrollbarContentHeight())
// A synchronous render replaces the content before the (notional) load ends.
v.SetContent("just a few\nshort lines\nhere")
assert.Equal(t, 3, v.scrollbarContentHeight())
}
func TestContainsColoredText(t *testing.T) {
@@ -233,7 +573,7 @@ func TestContainsColoredText(t *testing.T) {
for j, cells := range test.lines {
lines[j] = lineType{cells: cells}
}
v := &View{lines: lines}
v := &View{buf: &viewBuffer{lines: lines}}
assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i)
}
}
@@ -248,8 +588,8 @@ func TestWriteCursorPositionEscape(t *testing.T) {
// "a", then "skip to row 3" (i.e. one blank row), then "b".
v.writeString("a\r\n\x1b[3;1Hb\r\n")
got := make([][]string, 0, len(v.lines))
for _, l := range v.lines {
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
@@ -269,8 +609,8 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
// ConPTY is on row 3 here; CUP to row 5 should skip exactly one row.
v.writeString("c\x1b[5;1Hd\n")
got := make([][]string, 0, len(v.lines))
for _, l := range v.lines {
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
assert.Equal(t, [][]string{
@@ -292,8 +632,8 @@ func TestWriteCursorForwardEscape(t *testing.T) {
// "a" + ECH 5 + CUF 5 + "b" — visually "a b".
v.writeString("a\x1b[5X\x1b[5Cb\n")
got := make([][]string, 0, len(v.lines))
for _, l := range v.lines {
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
@@ -312,8 +652,8 @@ func TestWriteCursorPositionEscapeWithSoftWraps(t *testing.T) {
v.writeString("abcdefghij\n")
v.writeString("\x1b[4;1Hxyz\n")
got := make([][]string, 0, len(v.lines))
for _, l := range v.lines {
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
assert.Equal(t, [][]string{
@@ -664,3 +1004,76 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
"trailing cell at (%d, 2) should have green bg", x)
}
}
// TestInclusionGutter verifies the on-demand inclusion gutter reserves a
// left-hand column, draws the marker glyph on marked lines only, and shifts the
// content right past it.
func TestInclusionGutter(t *testing.T) {
WithSimulationScreen(t, 14, 6)
// InnerWidth=10; the frame inset of 1 places view x=0 at screen x=1.
v := NewView("name", 0, 0, 11, 5, OutputNormal)
v.Wrap = true
v.InclusionGutterMarker = "✓"
v.writeString("aaa\nbbb\nccc\n")
// The gutter is 2 columns wide (marker + separator); mark the middle line.
v.SetInclusionGutter(true, []bool{false, true, false})
v.draw()
// The marker appears at the gutter's first column (view x=0 → screen x=1) on
// the marked line only.
chr, _, _ := Screen.Get(1, 1)
assert.Equal(t, " ", chr, "unmarked line has no gutter marker")
chr, _, _ = Screen.Get(1, 2)
assert.Equal(t, "✓", chr, "marked line shows the gutter marker")
chr, _, _ = Screen.Get(1, 3)
assert.Equal(t, " ", chr, "unmarked line has no gutter marker")
// The content is shifted right past the 2-column gutter (view x=2 → screen x=3).
chr, _, _ = Screen.Get(3, 1)
assert.Equal(t, "a", chr, "content is shifted past the gutter")
chr, _, _ = Screen.Get(3, 2)
assert.Equal(t, "b", chr)
chr, _, _ = Screen.Get(3, 3)
assert.Equal(t, "c", chr)
// Hiding the gutter again returns the content flush left (view x=0 → screen x=1).
v.SetInclusionGutter(false, nil)
v.draw()
chr, _, _ = Screen.Get(1, 1)
assert.Equal(t, "a", chr, "content is flush left with no gutter")
}
// TestInclusionGutterMarkerOnEverySegment verifies that a marked buffer line that
// wraps shows the marker on every wrapped segment, and that the gutter narrows the
// content wrap width.
func TestInclusionGutterMarkerOnEverySegment(t *testing.T) {
WithSimulationScreen(t, 14, 6)
v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth=10
v.Wrap = true
v.InclusionGutterMarker = "✓"
// 10 cells; with a 2-column gutter the content wrap width is 8, so this wraps
// to "01234567" / "89".
v.writeString("0123456789\n")
v.SetInclusionGutter(true, []bool{true})
v.draw()
// First segment: marker present, content starts at screen x=3 and the eighth
// content cell ("7") sits at the right edge (screen x=10).
chr, _, _ := Screen.Get(1, 1)
assert.Equal(t, "✓", chr)
chr, _, _ = Screen.Get(3, 1)
assert.Equal(t, "0", chr)
chr, _, _ = Screen.Get(10, 1)
assert.Equal(t, "7", chr)
// Continuation segment: marker too, content resumes at screen x=3.
chr, _, _ = Screen.Get(1, 2)
assert.Equal(t, "✓", chr, "continuation segment also shows the marker")
chr, _, _ = Screen.Get(3, 2)
assert.Equal(t, "8", chr)
}
+12
View File
@@ -373,3 +373,15 @@ func (self *ContextMgr) NextInStack(c types.Context) types.Context {
panic("context not in stack")
}
// IsInStack reports whether the given context is somewhere in the context stack.
// Unlike NextInStack it never panics, so it's safe to call before dereferencing
// the stack from a caller that might run with the context off-stack.
func (self *ContextMgr) IsInStack(c types.Context) bool {
self.RLock()
defer self.RUnlock()
return lo.ContainsBy(self.ContextStack, func(other types.Context) bool {
return other.GetKey() == c.GetKey()
})
}
+19 -20
View File
@@ -13,15 +13,15 @@ type BaseContext struct {
windowName string
onGetOptionsMap func() map[string]string
keybindingsFns []types.KeybindingsFn
mouseKeybindingsFns []types.MouseKeybindingsFn
onDoubleClickFn func() error
onClickFn func(opts gocui.ViewMouseBindingOpts) error
onClickFocusedMainViewFn onClickFocusedMainViewFn
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
onQuitFns []func()
keybindingsFns []types.KeybindingsFn
mouseKeybindingsFns []types.MouseKeybindingsFn
onDoubleClickFn func() error
onClickFn func(opts gocui.ViewMouseBindingOpts) error
focusedMainViewActions types.FocusedMainViewActions
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
onQuitFns []func()
focusable bool
transient bool
@@ -34,9 +34,8 @@ type BaseContext struct {
}
type (
onFocusFn = func(types.OnFocusOpts)
onFocusLostFn = func(types.OnFocusLostOpts)
onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error
onFocusFn = func(types.OnFocusOpts)
onFocusLostFn = func(types.OnFocusLostOpts)
)
var _ types.IBaseContext = &BaseContext{}
@@ -145,7 +144,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
self.onQuitFns = nil
self.onDoubleClickFn = nil
self.onClickFn = nil
self.onClickFocusedMainViewFn = nil
self.focusedMainViewActions = nil
self.onRenderToMainFn = nil
}
@@ -167,12 +166,12 @@ func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) e
}
}
func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn) {
if fn != nil {
if self.onClickFocusedMainViewFn != nil {
panic("only one controller is allowed to set an onClickFocusedMainViewFn")
func (self *BaseContext) AddFocusedMainViewActions(actions types.FocusedMainViewActions) {
if actions != nil {
if self.focusedMainViewActions != nil {
panic("only one controller is allowed to set the focused main view actions")
}
self.onClickFocusedMainViewFn = fn
self.focusedMainViewActions = actions
}
}
@@ -184,8 +183,8 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro
return self.onClickFn
}
func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn {
return self.onClickFocusedMainViewFn
func (self *BaseContext) GetFocusedMainViewActions() types.FocusedMainViewActions {
return self.focusedMainViewActions
}
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {
+18 -6
View File
@@ -19,11 +19,16 @@ type CommitFilesContext struct {
}
var (
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
)
func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
viewModel := filetree.NewCommitFileTreeViewModel(
func() []*models.CommitFile { return c.Model().CommitFiles },
@@ -80,10 +85,17 @@ func (self *CommitFilesContext) RefForAdjustingLineNumberInDiff() string {
}
func (self *CommitFilesContext) GetFromAndToForDiff() (string, string) {
if refs := self.GetRefRange(); refs != nil {
return refs.From.ParentRefName(), refs.To.RefName()
return FromAndToForDiff(self.GetRef(), self.GetRefRange())
}
// FromAndToForDiff derives the diff endpoints for a ref (or a range of refs): a range
// diffs its parent-of-from against to, a single ref its parent against itself. It's
// shared by the commit files context and by patch building straight from the commits /
// sub-commits / stash main views, which build a patch for the panel's selected ref.
func FromAndToForDiff(ref models.Ref, refRange *types.RefRange) (string, string) {
if refRange != nil {
return refRange.From.ParentRefName(), refRange.To.RefName()
}
ref := self.GetRef()
return ref.ParentRefName(), ref.RefName()
}
+8 -3
View File
@@ -31,11 +31,16 @@ type commitDropIndicator struct {
}
var (
_ types.IListContext = (*LocalCommitsContext)(nil)
_ types.DiffableContext = (*LocalCommitsContext)(nil)
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
_ types.IListContext = (*LocalCommitsContext)(nil)
_ types.DiffableContext = (*LocalCommitsContext)(nil)
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
)
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
dropIndicator := &commitDropIndicator{insertionIndex: -1}
viewModel := NewLocalCommitsViewModel(
+51
View File
@@ -8,10 +8,61 @@ import (
type MainContext struct {
*SimpleContext
*SearchTrait
diffSelect DiffSelectState
// dragAnchorViewLine is the view line a mouse-down landed on, remembered so a drag
// that follows can anchor its range there. The click can show a whole hunk (a range
// with a different anchor), so the clicked line can't be read back from the view.
dragAnchorViewLine int
}
var _ types.ISearchableContext = (*MainContext)(nil)
// DiffSelectMode is how the focused main view's diff selection extends from the
// cursor: a single line, a range from a fixed anchor, or the change block (hunk)
// around the cursor.
type DiffSelectMode int
const (
DiffSelectModeLine DiffSelectMode = iota
DiffSelectModeRange
DiffSelectModeHunk
)
// DiffSelectState holds the *mode* of the focused main view's diff selection. The
// selected line and the range anchor themselves live in the gocui view (its
// cursor and rangeSelectStartY, i.e. native range select); only the mode lives
// here. It's on the context, not the controller, so that the main view controller
// (which drives the selection), the focus controller (which resets it on focus),
// and togglePanel (which sets it on the other pane) can all reach it via the
// context they already hold.
type DiffSelectState struct {
Mode DiffSelectMode
// When a range is sticky, moving the cursor without holding shift extends the
// range; otherwise it collapses the range back to a single line.
RangeIsSticky bool
// Whether the user turned on hunk mode explicitly, as opposed to it being the
// configured default; this decides whether escape leaves hunk mode.
UserEnabledHunkMode bool
}
// DiffSelectState returns the focused main view's selection mode state, for the
// controllers to read and mutate directly.
func (self *MainContext) DiffSelectState() *DiffSelectState {
return &self.diffSelect
}
// SetDragAnchorViewLine records the view line a mouse-down landed on, so a drag that
// follows can anchor its range there (see dragAnchorViewLine).
func (self *MainContext) SetDragAnchorViewLine(viewLine int) {
self.dragAnchorViewLine = viewLine
}
// DragAnchorViewLine returns the view line the last mouse-down landed on.
func (self *MainContext) DragAnchorViewLine() int {
return self.dragAnchorViewLine
}
func NewMainContext(
view *gocui.View,
windowName string,
+13
View File
@@ -20,6 +20,11 @@ type PatchExplorerContext struct {
// true if we're inside the OnSelectItem callback; in that case we don't want to update the
// search result index.
inOnSelectItemCallback bool
// Set when this patch explorer was entered from a focused main view, so that
// escaping returns there; nil for the normal flow. See
// types.FocusedMainViewSnapshot.
focusedMainViewSnapshot *types.FocusedMainViewSnapshot
}
var (
@@ -60,6 +65,14 @@ func NewPatchExplorerContext(
func (self *PatchExplorerContext) IsPatchExplorerContext() {}
func (self *PatchExplorerContext) GetFocusedMainViewSnapshot() *types.FocusedMainViewSnapshot {
return self.focusedMainViewSnapshot
}
func (self *PatchExplorerContext) SetFocusedMainViewSnapshot(snapshot *types.FocusedMainViewSnapshot) {
self.focusedMainViewSnapshot = snapshot
}
func (self *PatchExplorerContext) GetState() *patch_exploring.State {
return self.state
}
+9 -2
View File
@@ -14,10 +14,17 @@ type ReflogCommitsContext struct {
}
var (
_ types.IListContext = (*ReflogCommitsContext)(nil)
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
_ types.IListContext = (*ReflogCommitsContext)(nil)
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
_ types.DiffMainViewContext = (*ReflogCommitsContext)(nil)
)
// Reflog shows a commit's diff but has no primary action (building a patch from it is
// a deferred gap), so the focused main view has a selection but space does nothing.
func (self *ReflogCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypeNone
}
func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
viewModel := NewFilteredListViewModel(
func() []*models.Commit { return c.Model().FilteredReflogCommits },
+7 -2
View File
@@ -12,10 +12,15 @@ type StashContext struct {
}
var (
_ types.IListContext = (*StashContext)(nil)
_ types.DiffableContext = (*StashContext)(nil)
_ types.IListContext = (*StashContext)(nil)
_ types.DiffableContext = (*StashContext)(nil)
_ types.DiffMainViewContext = (*StashContext)(nil)
)
func (self *StashContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewStashContext(
c *ContextCommon,
) *StashContext {
+8 -3
View File
@@ -21,11 +21,16 @@ type SubCommitsContext struct {
}
var (
_ types.IListContext = (*SubCommitsContext)(nil)
_ types.DiffableContext = (*SubCommitsContext)(nil)
_ types.ISearchableContext = (*SubCommitsContext)(nil)
_ types.IListContext = (*SubCommitsContext)(nil)
_ types.DiffableContext = (*SubCommitsContext)(nil)
_ types.ISearchableContext = (*SubCommitsContext)(nil)
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
)
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewSubCommitsContext(
c *ContextCommon,
) *SubCommitsContext {
+7 -2
View File
@@ -15,10 +15,15 @@ type WorkingTreeContext struct {
}
var (
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
_ types.DiffMainViewContext = (*WorkingTreeContext)(nil)
)
func (self *WorkingTreeContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypeStaging
}
func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
viewModel := filetree.NewFileTreeViewModel(
func() []*models.File { return c.Model().Files },
+4 -6
View File
@@ -52,8 +52,9 @@ func (gui *Gui) resetHelpersAndControllers() {
gpgHelper := helpers.NewGpgHelper(helperCommon)
viewHelper := helpers.NewViewHelper(helperCommon, gui.State.Contexts)
patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon)
stagingHelper := helpers.NewStagingHelper(helperCommon)
windowHelper := helpers.NewWindowHelper(helperCommon, viewHelper)
stagingHelper := helpers.NewStagingHelper(helperCommon, windowHelper)
patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon, stagingHelper)
mergeConflictsHelper := helpers.NewMergeConflictsHelper(helperCommon)
searchHelper := helpers.NewSearchHelper(helperCommon)
@@ -73,7 +74,6 @@ func (gui *Gui) resetHelpersAndControllers() {
rebaseHelper,
)
bisectHelper := helpers.NewBisectHelper(helperCommon)
windowHelper := helpers.NewWindowHelper(helperCommon, viewHelper)
modeHelper := helpers.NewModeHelper(
helperCommon,
diffHelper,
@@ -108,6 +108,7 @@ func (gui *Gui) resetHelpersAndControllers() {
FixupHelper: helpers.NewFixupHelper(helperCommon),
Commits: commitsHelper,
SuspendResume: helpers.NewSuspendResumeHelper(helperCommon),
CommitFiles: helpers.NewCommitFilesHelper(helperCommon, patchBuildingHelper),
Snake: helpers.NewSnakeHelper(helperCommon),
Diff: diffHelper,
Repos: reposHelper,
@@ -173,7 +174,6 @@ func (gui *Gui) resetHelpersAndControllers() {
contextLinesController := controllers.NewContextLinesController(common)
renameSimilarityThresholdController := controllers.NewRenameSimilarityThresholdController(common)
verticalScrollControllerFactory := controllers.NewVerticalScrollControllerFactory(common)
viewSelectionControllerFactory := controllers.NewViewSelectionControllerFactory(common)
branchesController := controllers.NewBranchesController(common)
gitFlowController := controllers.NewGitFlowController(common)
@@ -310,13 +310,11 @@ func (gui *Gui) resetHelpersAndControllers() {
controllers.AttachControllers(gui.State.Contexts.Normal,
mainViewController,
verticalScrollControllerFactory.Create(gui.State.Contexts.Normal),
viewSelectionControllerFactory.Create(gui.State.Contexts.Normal),
)
controllers.AttachControllers(gui.State.Contexts.NormalSecondary,
secondaryViewController,
verticalScrollControllerFactory.Create(gui.State.Contexts.NormalSecondary),
viewSelectionControllerFactory.Create(gui.State.Contexts.NormalSecondary),
)
controllers.AttachControllers(gui.State.Contexts.Files,
+1 -1
View File
@@ -8,7 +8,7 @@ func AttachControllers(context types.Context, controllers ...types.IController)
context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
context.AddOnClickFn(controller.GetOnClick())
context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView())
context.AddFocusedMainViewActions(controller.GetFocusedMainViewActions())
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
context.AddOnFocusFn(controller.GetOnFocus())
context.AddOnFocusLostFn(controller.GetOnFocusLost())
+1 -1
View File
@@ -19,7 +19,7 @@ func (self *baseController) GetOnDoubleClick() func() error {
return nil
}
func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
func (self *baseController) GetFocusedMainViewActions() types.FocusedMainViewActions {
return nil
}
+83 -72
View File
@@ -176,8 +176,19 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
paths := self.pathsForDiff(node)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false)
task := types.NewRunPtyTask(cmdObj.GetCmd())
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false, renderRaw)
task := types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd())
// Keep the inclusion gutter in step with the content as this diff (re-)renders.
// It's a no-op unless the main view is focused and a patch is being built (see
// RefreshInclusionGutter); a patch toggle re-renders this same diff, so the marks
// recomputed over the current content stay valid through the swap.
self.c.Helpers().Staging.RefreshInclusionGutter()
// Preserve the focused-main-view selection across a commit rewrite. See
// LocalCommitsController.GetOnRenderToMain.
preserveFocusedMainViewSelectionAcrossContentChange(self.c, task)
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
@@ -195,7 +206,7 @@ func (self *CommitFilesController) copyDiffToClipboard(paths []string, toastMess
from, to := self.context().GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, true)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, true, false)
diff, err := cmdObj.RunWithOutput()
if err != nil {
return err
@@ -492,7 +503,7 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
})
}
from, to, reverse := self.currentFromToReverseForPatchBuilding()
from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding()
mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse)
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
Title: self.c.Tr.DiscardPatch,
@@ -503,7 +514,7 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
}
if !self.c.Git().Patch.PatchBuilder.Active() {
if err := self.startPatchBuilder(); err != nil {
if err := self.c.Helpers().CommitFiles.StartPatchBuilder(); err != nil {
return err
}
}
@@ -518,68 +529,8 @@ func (self *CommitFilesController) toggleAllForPatch(_ *filetree.CommitFileNode)
return self.toggleForPatch([]*filetree.CommitFileNode{root})
}
func (self *CommitFilesController) startPatchBuilder() error {
commitFilesContext := self.context()
canRebase := commitFilesContext.GetCanRebase()
from, to, reverse := self.currentFromToReverseForPatchBuilding()
self.c.Git().Patch.PatchBuilder.Start(from, to, reverse, canRebase)
return nil
}
func (self *CommitFilesController) currentFromToReverseForPatchBuilding() (string, string, bool) {
commitFilesContext := self.context()
from, to := commitFilesContext.GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
return from, to, reverse
}
func (self *CommitFilesController) enter(node *filetree.CommitFileNode) error {
return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1})
}
func (self *CommitFilesController) enterCommitFile(node *filetree.CommitFileNode, opts types.OnFocusOpts) error {
if node.File == nil {
return self.handleToggleCommitFileDirCollapsed(node)
}
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
from, to, reverse := self.currentFromToReverseForPatchBuilding()
mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse)
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
Title: self.c.Tr.DiscardPatch,
Prompt: self.c.Tr.DiscardPatchConfirm,
HandleConfirm: func() error {
if mustDiscardPatch {
self.c.Git().Patch.PatchBuilder.Reset()
}
if !self.c.Git().Patch.PatchBuilder.Active() {
if err := self.startPatchBuilder(); err != nil {
return err
}
}
self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts)
self.c.Helpers().PatchBuilding.ShowHunkStagingHint()
return nil
},
})
}
func (self *CommitFilesController) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) error {
self.context().CommitFileTreeViewModel.ToggleCollapsed(node.GetInternalPath())
self.c.PostRefreshUpdate(self.context())
return nil
return self.c.Helpers().CommitFiles.EnterCommitFile(node, nil, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1})
}
// NOTE: this is very similar to handleToggleFileTreeView, could be DRY'd with generics
@@ -606,14 +557,74 @@ func (self *CommitFilesController) expandAll() error {
return nil
}
func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return func(mainViewName string, clickedLineIdx int) error {
node := self.getSelectedItem()
if node != nil && node.File != nil {
return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
}
func (self *CommitFilesController) GetFocusedMainViewActions() types.FocusedMainViewActions {
return self
}
func (self *CommitFilesController) OnClick(mainViewName string, clickedLineIdx int) error {
// Capture before any mutation below that might re-render the main view.
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context())
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
line := -1
isDeletion := false
if ok {
line, isDeletion = info.PatchSelectLine()
}
node := self.getSelectedItem()
if node == nil {
return nil
}
if !node.IsFile() && ok {
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
if err != nil {
return err
}
relativePath = "./" + relativePath
self.context().CommitFileTreeViewModel.ExpandToPath(relativePath)
self.c.PostRefreshUpdate(self.context())
idx, ok := self.context().CommitFileTreeViewModel.GetIndexForPath(relativePath)
if ok {
self.context().SetSelectedLineIdx(idx)
self.context().GetViewTrait().FocusPoint(
self.context().ModelIndexToViewIndex(idx), false)
node = self.context().GetSelected()
}
}
// Entered from the focused main view, so escaping returns there.
return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
}
// PrimaryAction toggles the selected diff line(s) into or out of the custom patch when
// space is pressed in the focused main view of a commit's files. The per-file diff's
// patch target comes from the commit files context. It refreshes normally afterwards so
// the file's patch-status indicator in the browser updates along with the secondary patch
// view (the commits / sub-commits / stash panels, which build from the whole-commit diff,
// have no such indicator and refresh more cheaply).
func (self *CommitFilesController) PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error {
from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding()
canRebase := self.context().GetCanRebase()
return primaryPatchActionFromFocusedMainView(self.c, mainViewName, firstLineIdx, lastLineIdx,
from, to, reverse, canRebase,
func() {
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMIT_FILES}})
})
}
// DiscardSelection removes the selected diff line(s) from the commit the focused main
// view shows the files of, via a rebase. Same target derivation as the patch toggle.
func (self *CommitFilesController) DiscardSelection(mainViewName string, firstLineIdx int, lastLineIdx int) error {
from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding()
canRebase := self.context().GetCanRebase()
return discardSelectionFromCommit(self.c, mainViewName, firstLineIdx, lastLineIdx, from, to, reverse, canRebase)
}
func (self *CommitFilesController) DiscardSelectionDisabledReason(mainViewName string) *types.DisabledReason {
return discardFromCommitDisabledReason(self.c, mainViewName, self.context().GetCanRebase())
}
// pathsForDiff returns the file paths to use for a diff command. When a text
@@ -1,7 +1,6 @@
package controllers
import (
"errors"
"fmt"
"math"
@@ -51,10 +50,6 @@ func (self *ContextLinesController) Context() types.Context {
}
func (self *ContextLinesController) Increase() error {
if err := self.checkCanChangeContext(); err != nil {
return err
}
if self.c.UserConfig().Git.DiffContextSize < math.MaxUint64 {
self.c.UserConfig().Git.DiffContextSize++
}
@@ -62,10 +57,6 @@ func (self *ContextLinesController) Increase() error {
}
func (self *ContextLinesController) Decrease() error {
if err := self.checkCanChangeContext(); err != nil {
return err
}
if self.c.UserConfig().Git.DiffContextSize > 0 {
self.c.UserConfig().Git.DiffContextSize--
}
@@ -83,15 +74,13 @@ func (self *ContextLinesController) applyChange() error {
case context.STAGING_MAIN_CONTEXT_KEY, context.STAGING_SECONDARY_CONTEXT_KEY:
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STAGING}})
default:
// The new context size re-renders the diff with a different git command, which
// would otherwise reset the main view to the top. Preserve where it was
// scrolled (and its selection, if the focused main view is showing one)
// instead. This covers both the focused main view and the side panels, since
// they all render their diff into the same "main" view.
self.c.Helpers().Staging.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
currentContext.HandleRenderToMain()
}
return nil
}
func (self *ContextLinesController) checkCanChangeContext() error {
if self.c.Git().Patch.PatchBuilder.Active() {
return errors.New(self.c.Tr.CantChangeContextSizeError)
}
return nil
}
+285 -16
View File
@@ -9,6 +9,7 @@ import (
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
@@ -249,6 +250,11 @@ func (self *FilesController) GetOnRenderToMain() func() {
self.c.Helpers().Diff.WithDiffModeCheck(func() {
node := self.context().GetSelected()
// Default the focused-main-view selection off; the real-diff branch below
// turns it back on. Every other outcome (no file, merge conflict) renders
// non-diff content with nothing to select.
updateFocusedMainViewSelectionVisibility(self.c, false, false)
if node == nil {
self.renderToMainWithTask(types.NewRenderStringTask(self.c.Tr.NoChangedFiles))
return
@@ -347,7 +353,7 @@ func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) {
message := self.conflictResolutionHint(node.File.GetMergeStateDescription(self.c.Tr))
if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" {
cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()})
cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()}, false)
prefix := message + "\n\n"
if node.File.ShortStatus == "DU" {
prefix += self.c.Tr.MergeConflictIncomingDiff
@@ -365,11 +371,15 @@ func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) {
func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
self.c.Helpers().MergeConflicts.ResetMergeState()
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
mainShowsStaged := !split && node.GetHasStagedChanges()
split, mainShowsStaged := self.diffSplitState(node)
// When the focused main view needs to act on a diff the configured pager
// can't resolve, render it raw (no pager) so it's stageable; browsing keeps
// the pretty pager output. See StagingHelper.DiffMainViewShouldRenderRaw.
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
pathOverrides := self.pathOverridesForDiff(node)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, renderRaw, pathOverrides)
title := self.c.Tr.UnstagedChanges
if mainShowsStaged {
title = self.c.Tr.StagedChanges
@@ -377,14 +387,14 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
refreshOpts := types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
Task: types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd()),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: title,
},
}
if split {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, renderRaw, pathOverrides)
title := self.c.Tr.StagedChanges
if mainShowsStaged {
@@ -394,10 +404,15 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
refreshOpts.Secondary = &types.ViewUpdateOpts{
Title: title,
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
Task: types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd()),
}
}
// A real diff is being shown, so the focused main view has something to select:
// restore the selection on whichever pane is focused (the secondary only when
// the diff is split across both).
updateFocusedMainViewSelectionVisibility(self.c, true, split)
self.c.RenderToMainViews(refreshOpts)
}
@@ -407,14 +422,256 @@ func (self *FilesController) GetOnDoubleClick() func() error {
})
}
func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return func(mainViewName string, clickedLineIdx int) error {
node := self.getSelectedItem()
if node != nil && node.File != nil {
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
}
func (self *FilesController) GetFocusedMainViewActions() types.FocusedMainViewActions {
return self
}
func (self *FilesController) OnClick(mainViewName string, clickedLineIdx int) error {
// Capture before any mutation below that might re-render the main view.
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context())
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
line := -1
isDeletion := false
if ok {
line, isDeletion = info.PatchSelectLine()
}
node := self.context().GetSelected()
if node == nil {
return nil
}
if !node.IsFile() && ok {
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
if err != nil {
return err
}
relativePath = "./" + relativePath
self.context().FileTreeViewModel.ExpandToPath(relativePath)
self.c.PostRefreshUpdate(self.context())
idx, ok := self.context().FileTreeViewModel.GetIndexForPath(relativePath)
if ok {
self.context().SetSelectedLineIdx(idx)
self.context().GetViewTrait().FocusPoint(
self.context().ModelIndexToViewIndex(idx), false)
}
}
return self.EnterFile(snapshot, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
}
// diffSplitState reports, for the given file node, how the focused main view lays
// out its diff: whether it's split into unstaged (Normal) and staged
// (NormalSecondary) halves, and — when not split — whether the single Normal view
// shows the staged diff (which happens when the file has only staged changes).
// GetOnRenderToMain and PrimaryAction share this so the staging direction
// can't drift from what's on screen.
func (self *FilesController) diffSplitState(node *filetree.FileNode) (split bool, mainShowsStaged bool) {
split = self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
mainShowsStaged = !split && node.GetHasStagedChanges()
return split, mainShowsStaged
}
// PrimaryAction stages (or unstages) the selected diff line(s) when space is pressed in
// the focused main view of the working-tree files panel.
func (self *FilesController) PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error {
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
infos, onStagedSide, ok := self.diffLineSelection(mainViewName, firstLineIdx, lastLineIdx)
if !ok {
return nil
}
// The whole diff shown in the main view is on one side; space unstages when that's
// the staged side (reverse), otherwise it stages. Either way it applies to the index.
return self.applyDiffLineSelection(mainViewName, firstLineIdx, infos, onStagedSide,
git_commands.ApplyPatchOpts{Reverse: onStagedSide, Cached: true})
}
// DiscardSelectionDisabledReason: discarding from the working tree is always available
// (a zero-context diff is reported as an error from DiscardSelection itself, matching the
// staging view).
// DiscardSelectionDisabledReason ignores which pane the action came from: discarding from
// the working tree is meaningful in both the unstaged and staged (secondary) panes (the
// staged side just unstages), unlike the commit panels' custom-patch preview.
func (self *FilesController) DiscardSelectionDisabledReason(mainViewName string) *types.DisabledReason {
return nil
}
func (self *FilesController) DiscardSelection(mainViewName string, firstLineIdx int, lastLineIdx int) error {
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToDiscard,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
infos, onStagedSide, ok := self.diffLineSelection(mainViewName, firstLineIdx, lastLineIdx)
if !ok {
return nil
}
// Discarding always reverses the change. On the staged side that's just unstaging
// (apply reverse to the index) — same as space there — so it follows the identical
// path and isn't destructive, hence no confirm. On the unstaged side it removes the
// change from the working tree (reverse, not cached), which is destructive.
return self.c.ConfirmIf(!onStagedSide && !self.c.UserConfig().Gui.SkipDiscardChangeWarning,
types.ConfirmOpts{
Title: self.c.Tr.DiscardChangeTitle,
Prompt: self.c.Tr.DiscardChangePrompt,
HandleConfirm: func() error {
return self.applyDiffLineSelection(mainViewName, firstLineIdx, infos, onStagedSide,
git_commands.ApplyPatchOpts{Reverse: true, Cached: onStagedSide})
},
})
}
// diffLineSelection resolves the change lines the user has selected in the focused main
// view and which side of the diff they're on. onStagedSide is true when the diff shown
// is the staged side — the secondary half of a split, or the main half of an only-staged
// file; it's the same for every file in a multi-file (directory) diff. ok is false when
// there's no file or the selection covers no change lines (the caller does nothing).
func (self *FilesController) diffLineSelection(mainViewName string, firstLineIdx int, lastLineIdx int) (infos []types.DiffLineInfo, onStagedSide bool, ok bool) {
node := self.context().GetSelected()
if node == nil {
return nil, false, false
}
infos = self.c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
if len(infos) == 0 {
return nil, false, false
}
_, mainShowsStaged := self.diffSplitState(node)
onStagedSide = mainShowsStaged || mainViewName == self.c.Contexts().NormalSecondary.GetViewName()
return infos, onStagedSide, true
}
// applyDiffLineSelection applies the selected change lines (grouped per file for a
// multi-file diff) with opts, then re-establishes the selection and focus the way the
// staging view does — shared by the primary action (stage/unstage) and discard, since
// discarding on the staged side is just unstaging and must follow the identical path.
// onStagedSide names which diff each file's patch is read from and drives the focus-
// follow; opts says how to apply it.
func (self *FilesController) applyDiffLineSelection(mainViewName string, firstLineIdx int, infos []types.DiffLineInfo, onStagedSide bool, opts git_commands.ApplyPatchOpts) error {
// A directory diff spans several files; group the selected change lines by
// file and apply one patch per file.
infosByFile := lo.GroupBy(infos, func(info types.DiffLineInfo) string { return info.Path })
self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
for path, fileInfos := range infosByFile {
file := self.fileForDiffLinePath(path)
if file == nil {
continue
}
if err := self.applyDiffLines(file, fileInfos, onStagedSide, opts); err != nil {
return err
}
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
// Focus follows the side that was acted on. The unstaged side stays in the main half
// (which always holds the unstaged side, or the staged side once the file has only
// staged changes). The staged side lives in the secondary half once the file is split
// into staged + unstaged, and moves back to the main half when the staged side empties
// and the split collapses. The model is up to date now (Refresh above is synchronous),
// so the post-op split is read from the freshly selected node.
focusViewName := self.c.Contexts().Normal.GetViewName()
if onStagedSide {
if node := self.context().GetSelected(); node != nil {
if split, _ := self.diffSplitState(node); split {
focusViewName = self.c.Contexts().NormalSecondary.GetViewName()
}
}
}
// The Refresh above queued the main-view re-render; re-establish the selection at its
// change-line ordinal once that render lands, and focus the pane now holding the
// acted-on side.
revealSelectionAfterPrimaryAction(self.c, mainViewName, focusViewName, firstLineIdx, 0)
if focusViewName != mainViewName {
self.c.Context().Push(mainContextForViewName(self.c, focusViewName), types.OnFocusOpts{})
}
return nil
}
// fileForDiffLinePath maps a diff line's absolute file path (as carried by the
// diff-line metadata) to the working-tree file it belongs to, or nil if it isn't a
// tracked working-tree file.
func (self *FilesController) fileForDiffLinePath(path string) *models.File {
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
if err != nil {
return nil
}
return self.context().FileTreeViewModel.GetFile(filepath.ToSlash(relativePath))
}
// applyDiffLines applies, to the diff lines identified by infos (a single line, a
// range, or a hunk) — all belonging to file — a patch built from the file's staged or
// unstaged diff (sourceCached: the side the selection was made on) and applied per opts:
// - stage: read unstaged, apply forward to the index (sourceCached=false, Reverse=false, Cached=true)
// - unstage: read staged, apply reverse to the index (sourceCached=true, Reverse=true, Cached=true)
// - discard: read the shown side, apply reverse — not cached on the unstaged side
// removes the change from the working tree, cached on the staged side just unstages it.
//
// The read side and the apply direction are independent (they only coincide for
// staging/unstaging), so they're passed separately: the patch lines are matched in the
// diff actually shown, then Transform reverses them to match opts. A selection covering
// no change lines yields an empty patch and is a no-op. The caller logs the action and
// refreshes, once, around the (possibly several) files it touches.
//
// Each selected change line is keyed by its (file line number, deletion?) identity,
// and the freshly parsed patch is scanned for the body lines matching those
// identities. Scanning the patch and matching identities — rather than looking each
// line number up with PatchLineForLineNumber — is what makes a modified line work: a
// deletion and the addition replacing it share a position but have distinct
// identities, and the line-number lookup can't tell an addition at the start of a
// hunk from the deletion above it.
func (self *FilesController) applyDiffLines(file *models.File, infos []types.DiffLineInfo, sourceCached bool, opts git_commands.ApplyPatchOpts) error {
parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, true, sourceCached))
type changeLineKey struct {
lineNumber int
isDeletion bool
}
selected := make(map[changeLineKey]bool, len(infos))
for _, info := range infos {
lineNumber, isDeletion := info.PatchSelectLine()
selected[changeLineKey{lineNumber, isDeletion}] = true
}
var patchLineIndices []int
for idx, line := range parsedPatch.Lines() {
var key changeLineKey
switch {
case line.IsAddition():
key = changeLineKey{parsedPatch.LineNumberOfLine(idx), false}
case line.IsDeletion():
key = changeLineKey{parsedPatch.OldLineNumberOfLine(idx), true}
default:
continue
}
if selected[key] {
patchLineIndices = append(patchLineIndices, idx)
}
}
patchToApply := parsedPatch.
Transform(patch.TransformOpts{
Reverse: opts.Reverse,
IncludedLineIndices: patchLineIndices,
FileNameOverride: file.GetPath(),
}).
FormatPlain()
if patchToApply == "" {
return nil
}
return self.c.Git().Patch.ApplyPatch(patchToApply, opts)
}
// if we are dealing with a status for which there is no key in this map,
@@ -701,7 +958,7 @@ func (self *FilesController) getSelectedFile() *models.File {
}
func (self *FilesController) enter() error {
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1})
return self.EnterFile(nil, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1})
}
func (self *FilesController) collapseAll() error {
@@ -720,7 +977,11 @@ func (self *FilesController) expandAll() error {
return nil
}
func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
// focusedMainViewSnapshot records the focused main view to return to when
// escaping the staging view, for the case where we're entering it straight from
// there; it's nil for the normal flow that goes through the files panel. See
// types.FocusedMainViewSnapshot.
func (self *FilesController) EnterFile(focusedMainViewSnapshot *types.FocusedMainViewSnapshot, opts types.OnFocusOpts) error {
node := self.context().GetSelected()
if node == nil {
return nil
@@ -747,6 +1008,14 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
}
context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging)
// Record the focused-main-view return on *both* staging halves, not just the
// one we're entering: staging the last unstaged hunk (or tabbing) moves the
// selection to the other half, and escaping from there must still know to
// return to the focused main view rather than falling back to the files panel.
// Set on every entry (nil for a normal entry through the files panel) so a
// snapshot can't leak from a previous main-view entry into a subsequent normal one.
self.c.Contexts().Staging.SetFocusedMainViewSnapshot(focusedMainViewSnapshot)
self.c.Contexts().StagingSecondary.SetFocusedMainViewSnapshot(focusedMainViewSnapshot)
self.c.Context().Push(context, opts)
self.c.Helpers().PatchBuilding.ShowHunkStagingHint()
@@ -1530,7 +1799,7 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro
}
func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y})
return self.EnterFile(nil, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y})
}
func (self *FilesController) fetch() error {
+19
View File
@@ -185,11 +185,21 @@ func (self *GlobalController) cycleDiffRenderersBackward() error {
// onDiffRenderersChanged re-renders the main view so the newly selected diff renderer
// takes effect, and shows a toast naming it.
func (self *GlobalController) onDiffRenderersChanged() {
self.applyCurrentPagerSelectionStyle()
currentSide := self.c.Context().CurrentSide()
currentKey := self.c.Context().Current().GetKey()
if currentSide.GetKey() == currentKey ||
currentKey == context.NORMAL_MAIN_CONTEXT_KEY ||
currentKey == context.NORMAL_SECONDARY_CONTEXT_KEY {
// Switching pagers re-renders the diff. Keep it anchored on the same patch
// line across the switch, restoring by patch identity rather than raw line
// number: two pagers can structure the same diff very differently (side-by-
// side vs inline), so the same screen line means something different after.
// Without this a plain pager swap keeps the old origin (the git command is
// unchanged) but now pointing at the wrong content, while an entry with its
// own externalDiffCommand changes the git command and so resets to the top.
self.c.Helpers().Staging.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
currentSide.HandleRenderToMain()
}
@@ -203,6 +213,15 @@ func (self *GlobalController) onDiffRenderersChanged() {
}))
}
func (self *GlobalController) applyCurrentPagerSelectionStyle() {
bgColorWidth := 0
if self.c.State().GetDiffRendererConfigManager().GetNarrowSelectionHighlight() {
bgColorWidth = 2
}
self.c.Contexts().Normal.GetView().SelectedLineBgColorWidth = bgColorWidth
self.c.Contexts().NormalSecondary.GetView().SelectedLineBgColorWidth = bgColorWidth
}
func (self *GlobalController) canCycleDiffRenderers() *types.DisabledReason {
_, total := self.c.State().GetDiffRendererConfigManager().CurrentDiffRendererIndex()
if total <= 1 {
@@ -0,0 +1,94 @@
package helpers
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type CommitFilesHelper struct {
c *HelperCommon
patchBuildingHelper *PatchBuildingHelper
}
func NewCommitFilesHelper(c *HelperCommon, patchBuildingHelper *PatchBuildingHelper) *CommitFilesHelper {
return &CommitFilesHelper{
c: c,
patchBuildingHelper: patchBuildingHelper,
}
}
// focusedMainViewSnapshot records the focused main view to return to when
// escaping the patch builder, for the case where we're entering it straight from
// there; it's nil for the normal flow that goes through the commit files panel.
// See types.FocusedMainViewSnapshot.
func (self *CommitFilesHelper) EnterCommitFile(node *filetree.CommitFileNode, focusedMainViewSnapshot *types.FocusedMainViewSnapshot, opts types.OnFocusOpts) error {
if node.File == nil {
self.handleToggleCommitFileDirCollapsed(node)
return nil
}
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
from, to, reverse := self.CurrentFromToReverseForPatchBuilding()
mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse)
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
Title: self.c.Tr.DiscardPatch,
Prompt: self.c.Tr.DiscardPatchConfirm,
HandleConfirm: func() error {
if mustDiscardPatch {
self.c.Git().Patch.PatchBuilder.Reset()
}
if !self.c.Git().Patch.PatchBuilder.Active() {
if err := self.StartPatchBuilder(); err != nil {
return err
}
}
// Set on every entry (so it can't leak from a previous main-view
// entry into a subsequent normal one), right as we push the patch
// builder.
self.c.Contexts().CustomPatchBuilder.SetFocusedMainViewSnapshot(focusedMainViewSnapshot)
self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts)
self.patchBuildingHelper.ShowHunkStagingHint()
return nil
},
})
}
func (self *CommitFilesHelper) context() *context.CommitFilesContext {
return self.c.Contexts().CommitFiles
}
func (self *CommitFilesHelper) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) {
self.context().CommitFileTreeViewModel.ToggleCollapsed(node.GetInternalPath())
self.c.PostRefreshUpdate(self.context())
}
func (self *CommitFilesHelper) StartPatchBuilder() error {
commitFilesContext := self.context()
canRebase := commitFilesContext.GetCanRebase()
from, to, reverse := self.CurrentFromToReverseForPatchBuilding()
self.c.Git().Patch.PatchBuilder.Start(from, to, reverse, canRebase)
return nil
}
func (self *CommitFilesHelper) CurrentFromToReverseForPatchBuilding() (string, string, bool) {
commitFilesContext := self.context()
from, to := commitFilesContext.GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
return from, to, reverse
}
+10 -6
View File
@@ -52,7 +52,11 @@ func (self *DiffHelper) DiffArgs() []string {
// and the refRange for a range selection. If the refRange is nil (meaning that
// either there's no range, or it can't be diffed for some reason), then we want
// to fall back to rendering the diff for the single commit.
func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Commit, refRange *types.RefRange) types.UpdateTask {
// GetUpdateTaskForRenderingCommitsDiff builds the task for showing a commit's (or a
// commit range's) diff in the main view. renderRaw bypasses the pager for the focused
// main view's raw-diff fallback (see StagingHelper.DiffMainViewShouldRenderRaw); the
// calling panel computes it, since this helper can't reach the staging helper.
func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Commit, refRange *types.RefRange, renderRaw bool) types.UpdateTask {
if refRange != nil {
from, to := refRange.From, refRange.To
args := []string{from.ParentRefName(), to.RefName(), "--stat", "-p"}
@@ -72,13 +76,13 @@ func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Comm
args = append(args, filterPath)
}
}
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
cmdObj := self.c.Git().Diff.DiffCmdObj(args, renderRaw)
prefix := style.FgYellow.Sprintf("%s %s-%s\n\n", self.c.Tr.ShowingDiffForRange, from.ShortRefName(), to.ShortRefName())
return types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)
return types.NewMainViewDiffTaskWithPrefix(renderRaw, cmdObj.GetCmd(), prefix)
}
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit))
return types.NewRunPtyTask(cmdObj.GetCmd())
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit), renderRaw)
return types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd())
}
func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string {
@@ -100,7 +104,7 @@ func (self *DiffHelper) ExitDiffMode() error {
func (self *DiffHelper) RenderDiff() {
args := self.DiffArgs()
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
cmdObj := self.c.Git().Diff.DiffCmdObj(args, false)
prefix := style.FgMagenta.Sprintf(
"%s %s\n\n",
self.c.Tr.ShowingGitDiff,
@@ -0,0 +1,296 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
)
// AdjacentChangeBlock returns the view line to move to for next/previous change-block
// navigation in view's displayed diff, starting from anchorViewLine. A "change block"
// is lazygit's notion of a hunk — a run of consecutive added/deleted lines separated
// by context, of which there may be several within one git @@ hunk — matching what
// the staging view's hunk navigation jumps between. forward=true targets the start of
// the next block; forward=false the start of the previous one (from mid-block this
// skips to the previous block, mirroring State.SelectPreviousHunk). ok is false when
// there's no further block, so the caller leaves the view where it is.
func (self *StagingHelper) AdjacentChangeBlock(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
contents := view.DiffLineContents()
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return 0, false
}
resolved := self.resolveDiffLines(contents)
isChange := make([]bool, len(resolved))
for i, r := range resolved {
isChange[i] = r.ok && r.info.IsChange()
}
target, ok := changeBlockStart(isChange, anchor, forward)
if !ok {
return 0, false
}
return view.ViewLineForBufferLine(target)
}
// AdjacentFile returns the view line to move to for next/previous file navigation in
// view's (possibly multi-file) displayed diff, starting from anchorViewLine: the
// first row belonging to the next/previous file, found where the per-row metadata's
// file changes. ok is false at the first/last file.
func (self *StagingHelper) AdjacentFile(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
contents := view.DiffLineContents()
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return 0, false
}
resolved := self.resolveDiffLines(contents)
paths := make([]string, len(resolved))
for i, r := range resolved {
if r.ok {
paths[i] = r.info.Path
}
}
target, ok := fileStart(paths, anchor, forward)
if !ok {
return 0, false
}
return view.ViewLineForBufferLine(target)
}
// DiffFile is a file shown in a (possibly multi-file) diff: its absolute path and the
// view line its section starts at — the row that next/previous-file navigation lands on.
type DiffFile struct {
Path string
FirstViewLine int
}
// FilesInDiff lists the files shown in view's diff, in display order, each paired with
// the view line its section starts at. It is the jump-to-file menu's source: jumping to
// a file goes to its FirstViewLine — the file's first located row, the same row that
// AdjacentFile lands on, so the menu and n/N agree on where each file begins. A file
// whose start row isn't currently mapped to a view line (not loaded yet) is skipped.
func (self *StagingHelper) FilesInDiff(view *gocui.View) []DiffFile {
resolved := self.resolveDiffLines(view.DiffLineContents())
paths := make([]string, len(resolved))
for i, r := range resolved {
if r.ok {
paths[i] = r.info.Path
}
}
var files []DiffFile
seen := map[string]bool{}
for i, path := range paths {
if path == "" || seen[path] {
continue
}
seen[path] = true
if viewLine, ok := view.ViewLineForBufferLine(i); ok {
files = append(files, DiffFile{Path: path, FirstViewLine: viewLine})
}
}
return files
}
// FirstChangeLineInView returns the view line of the first change line at or below
// the top of the viewport, for placing the initial selection when focusing the main
// view: we select the first change the user can already see rather than jumping to
// the top of the diff (which would be jarring when the view is scrolled down). If
// the top visible line is itself mid-change-block, that line is returned, so the
// selection stays put. ok is false when no change line is loaded at or below the top
// (e.g. scrolled into trailing context, or the diff isn't loaded that far yet),
// leaving the caller to fall back.
func (self *StagingHelper) FirstChangeLineInView(view *gocui.View) (int, bool) {
top, ok := view.BufferLineForViewLine(view.OriginY())
if !ok {
return 0, false
}
resolved := self.resolveDiffLines(view.DiffLineContents())
for i := top; i < len(resolved); i++ {
if resolved[i].ok && resolved[i].info.IsChange() {
return view.ViewLineForBufferLine(i)
}
}
return 0, false
}
// ViewHasChangeLines reports whether view's displayed diff contains any change line
// (an addition or deletion), i.e. whether there's anything to select. It's false when
// the main view shows a non-diff placeholder ("No changed files", a merge message) or a
// diff that is somehow all context — the cases where the focused main view should show
// no selection.
func (self *StagingHelper) ViewHasChangeLines(view *gocui.View) bool {
for _, resolved := range self.resolveDiffLines(view.DiffLineContents()) {
if resolved.ok && resolved.info.IsChange() {
return true
}
}
return false
}
// IsChangeLine reports whether the given view line of view's displayed diff is a
// change line (an addition or deletion) rather than context, a header, or an
// unparseable row — i.e. whether a click there points at something stageable.
func (self *StagingHelper) IsChangeLine(view *gocui.View, viewLineIdx int) bool {
info, ok := self.GetDiffLineInfoForView(view, viewLineIdx)
return ok && info.IsChange()
}
// ChangeBlockBounds returns the view-line range [start, end] of the change block
// (lazygit's notion of a hunk; see AdjacentChangeBlock) to select when entering or
// moving in hunk mode in view's displayed diff. The block is the one containing
// anchorViewLine, or — when that line is context — the first block at or below it
// (matching how toggling hunk mode in the staging view snaps to the next change).
// ok is false when no change line lies at or below the anchor (e.g. scrolled into
// trailing context, or the diff isn't loaded that far yet).
func (self *StagingHelper) ChangeBlockBounds(view *gocui.View, anchorViewLine int) (int, int, bool) {
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return 0, 0, false
}
resolved := self.resolveDiffLines(view.DiffLineContents())
isChange := make([]bool, len(resolved))
for i, r := range resolved {
isChange[i] = r.ok && r.info.IsChange()
}
// Snap to the first change line at or after the anchor, then expand over the
// whole contiguous run of change lines around it.
start := anchor
for start < len(isChange) && !isChange[start] {
start++
}
if start >= len(isChange) {
return 0, 0, false
}
end := start
for start > 0 && isChange[start-1] {
start--
}
for end < len(isChange)-1 && isChange[end+1] {
end++
}
startView, ok1 := view.ViewLineForBufferLine(start)
endView, ok2 := view.ViewLineForBufferLine(end)
if !ok1 || !ok2 {
return 0, 0, false
}
// ViewLineForBufferLine gives the first view line of the block's last buffer
// line; extend over any further view lines it wrapped to, so the highlight
// covers the whole block.
for endView < view.ViewLinesHeight()-1 {
next, ok := view.BufferLineForViewLine(endView + 1)
if !ok || next != end {
break
}
endView++
}
return startView, endView, true
}
// changeBlockStart finds, in a diff whose lines are flagged by isChange, the first
// line of the change block adjacent to `from` in the given direction. It is the pure
// index arithmetic behind AdjacentChangeBlock, mirroring the staging view's
// State.SelectNextHunk / SelectPreviousHunk line by line.
func changeBlockStart(isChange []bool, from int, forward bool) (int, bool) {
if forward {
i := from
for i < len(isChange) && isChange[i] { // leave the current block
i++
}
for i < len(isChange) && !isChange[i] { // skip the separating context
i++
}
if i < len(isChange) {
return i, true
}
return 0, false
}
i := from
for i >= 0 && isChange[i] { // leave the current block
i--
}
for i >= 0 && !isChange[i] { // skip context, landing on the previous block's last line
i--
}
if i < 0 {
return 0, false
}
for i > 0 && isChange[i-1] { // walk back to that block's first line
i--
}
return i, true
}
// fileStart finds, in a diff whose lines carry the file path they belong to (empty
// for a row no backend could place), the first located row of the file adjacent to
// `from` in the given direction — the row file navigation lands on. It is the pure
// index arithmetic behind AdjacentFile. A file is identified by its path, so we
// find where the path changes, skipping unlocated rows: those are blank separator
// rows between files, or the header rows of a pager that doesn't emit `f`/`h`
// records. So the landing row is the file's header for any conforming source (a
// parseable buffer, or a pager tagging its headers), and the first content line
// under a pager that leaves its headers untagged — an accepted degradation. (An
// earlier version instead backed up over the untagged rows above the first located
// one, to reach the file's top under such pagers; but that overshoots onto the
// blank line above the header whenever the headers themselves are tagged.)
func fileStart(paths []string, from int, forward bool) (int, bool) {
anchorPath, ok := anchorFilePath(paths, from)
if !ok {
return 0, false
}
if forward {
for i := from; i < len(paths); i++ {
if paths[i] != "" && paths[i] != anchorPath {
return i, true
}
}
return 0, false
}
// Walk back past the current file (its rows and any unlocated rows) to the
// previous file's last located row, then back over that whole file, landing
// on its first located row.
i := from
for i >= 0 && (paths[i] == "" || paths[i] == anchorPath) {
i--
}
if i < 0 {
return 0, false
}
prevPath := paths[i]
for i > 0 && (paths[i-1] == "" || paths[i-1] == prevPath) {
i--
}
for paths[i] != prevPath {
i++
}
return i, true
}
// anchorFilePath returns the path of the file the anchor sits in: the first row at or
// below it that carries a path — the file whose content is at or below the top of the
// view — falling back to the nearest above when there's nothing below. Scanning down
// first matters because the anchor is often an untagged file-header row whose nearest
// tagged row is the *previous* file's content just above it; taking that would make
// next-file navigation jump back into the file just left (so a second `n` wouldn't
// advance). ok is false when no row carries a path.
func anchorFilePath(paths []string, from int) (string, bool) {
for i := from; i < len(paths); i++ {
if paths[i] != "" {
return paths[i], true
}
}
for i := from - 1; i >= 0; i-- {
if paths[i] != "" {
return paths[i], true
}
}
return "", false
}
@@ -0,0 +1,116 @@
package helpers
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestChangeBlockStart(t *testing.T) {
// A diff with three change blocks separated by context:
// 0 file header 1 hunk header 2 context
// 3 + 4 + (block A)
// 5 context
// 6 - (block B)
// 7 context
// 8 + (block C)
isChange := []bool{false, false, false, true, true, false, true, false, true}
scenarios := []struct {
name string
from int
forward bool
expected int
found bool
}{
{"forward from a header lands on the first block", 0, true, 3, true},
{"forward from separating context lands on the next block", 5, true, 6, true},
{"forward from the start of a block skips to the next", 3, true, 6, true},
{"forward from inside a block skips the rest of it", 4, true, 6, true},
{"forward from the last block finds nothing", 8, true, 0, false},
{"backward from a later block lands on the previous one's start", 8, false, 6, true},
{"backward from a block start lands on the previous block's start", 6, false, 3, true},
{"backward from inside the first block finds nothing", 4, false, 0, false},
{"backward from the first block's start finds nothing", 3, false, 0, false},
{"backward from context lands on the preceding block's start", 7, false, 6, true},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
got, found := changeBlockStart(isChange, s.from, s.forward)
assert.Equal(t, s.found, found)
if s.found {
assert.Equal(t, s.expected, got)
}
})
}
}
func TestFileStart(t *testing.T) {
// A parseable two-file diff: every row carries its file's path (the headers
// included), as the buffer parser reports.
parseable := []string{"a", "a", "a", "a", "b", "b", "b", "b"}
// The same diff as a pager without `f`/`h` header records emits it: only content
// lines carry the path; the file/hunk header rows above each file are untagged
// (empty), so navigation can only land on each file's first content line.
tagged := []string{"", "", "a", "a", "", "", "b", "b"}
// Three such files, to exercise navigating from one file's untagged header to the
// next: the row just above b's header is a's content, so the anchor file must be
// found by scanning down (b), not up (a) — otherwise next-file would jump back
// into b and a second `n` couldn't advance.
taggedThree := []string{"", "", "a", "a", "", "", "b", "b", "", "", "c", "c"}
// A pager that tags its header rows with `f`/`h` records (delta): the two-row
// file header and the hunk-header box carry the file's path, but the blank
// separator rows — above each file header, and between it and the first hunk
// header — carry nothing. Navigation must land on the header's first row, not
// the blank line above it.
// 0 blank 1 file hdr 2 file hdr 3 blank 4-6 hunk hdr box 7 content
// 8 blank 9 file hdr 10 file hdr 11 blank 12-13 hunk hdr box 14 content
headerTagged := []string{"", "a", "a", "", "a", "a", "a", "a", "", "b", "b", "", "b", "b", "b"}
scenarios := []struct {
name string
paths []string
from int
forward bool
expected int
found bool
}{
{"parseable: next file lands on its header", parseable, 1, true, 4, true},
{"parseable: next from the last file finds nothing", parseable, 6, true, 0, false},
{"parseable: previous file lands on its header", parseable, 5, false, 0, true},
{"parseable: previous from the first file finds nothing", parseable, 1, false, 0, false},
// With only content tagged, both directions land on the file's first content
// line — the untagged header rows above it can't be told apart from the
// blank separator rows, so they are never a landing spot.
{"tagged: next file lands on its first content line", tagged, 2, true, 6, true},
{"tagged: next from an untagged header still advances", tagged, 0, true, 6, true},
{"tagged: previous file lands on its first content line", tagged, 7, false, 2, true},
{"tagged: previous from the first file finds nothing", tagged, 2, false, 0, false},
// From b's untagged header (row 4), the anchor file is b (below), so next goes
// to c and previous goes to a — neither sticks on b.
{"tagged: next from a middle file's header advances past it", taggedThree, 4, true, 10, true},
{"tagged: previous from a middle file's header lands on the prior file", taggedThree, 4, false, 2, true},
// With headers tagged, both directions land on the header's first row —
// crucially not on the blank separator row above it.
{"header-tagged: next file lands on the header, not the blank above it", headerTagged, 7, true, 9, true},
{"header-tagged: next from inside a file's header advances", headerTagged, 2, true, 9, true},
{"header-tagged: previous file lands on the header, not the blank above it", headerTagged, 14, false, 1, true},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
got, found := fileStart(s.paths, s.from, s.forward)
assert.Equal(t, s.found, found)
if s.found {
assert.Equal(t, s.expected, got)
}
})
}
}
@@ -0,0 +1,259 @@
package helpers
import (
"strconv"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
// diffFilePrefix marks the start of a file's section in a (possibly multi-file)
// unified diff.
const diffFilePrefix = "diff --git "
// parsedDiffLine is what a backend recovers about a rendered diff row. RelPath
// is the path as the source spells it — repo-relative from the diff header for
// the buffer parser (#1), or whatever the pager emitted (possibly absolute) for
// the OSC metadata (#2); the caller turns it into the absolute path of
// types.DiffLineInfo.
type parsedDiffLine struct {
RelPath string
Type types.DiffLineType
NewLine int
OldLine int
}
// bufferLineParse is the buffer-parse backend's result for one buffer line: the
// recovered identity and whether the line could be resolved (false for a line in
// an unparseable or restructured section, or outside any file section).
type bufferLineParse struct {
parsed parsedDiffLine
ok bool
}
// parseDiffLineFromBuffer recovers a rendered diff row's patch-space identity by
// parsing the decolorized diff buffer (mechanism #1 in diff-line-metadata-notes.md).
//
// bufferLines is the full unwrapped view buffer; targetIdx is the buffer line to
// resolve. A commit diff spans multiple files, so we isolate the file section
// containing targetIdx and parse just that one (see parseFileSection). Use this for
// a single line (e.g. a click); to resolve every line of a buffer, prefer
// parseAllDiffLinesFromBuffer, which parses each section only once.
//
// ok is false when the buffer isn't a parseable unified diff at targetIdx (e.g.
// a pager that restructures the diff, like delta's default mode), so the caller
// can fall back to another backend.
func parseDiffLineFromBuffer(bufferLines []string, targetIdx int) (parsedDiffLine, bool) {
if targetIdx < 0 || targetIdx >= len(bufferLines) {
return parsedDiffLine{}, false
}
start, end := fileSectionBounds(bufferLines, targetIdx)
if start == -1 {
return parsedDiffLine{}, false
}
r := parseFileSection(bufferLines[start:end])[targetIdx-start]
return r.parsed, r.ok
}
// parseAllDiffLinesFromBuffer resolves every line of a (possibly multi-file) diff
// buffer in one pass, parsing each file section exactly once. It is the batch form
// of parseDiffLineFromBuffer for callers that scan the whole buffer (the position-
// restore and navigation scans): resolving line-by-line would re-parse each section
// once per line — O(n²) on a large single-file diff — whereas this is O(n). The
// returned slice is indexed 1:1 with bufferLines; a line in an unparseable section,
// or before the first "diff --git", is left ok=false.
func parseAllDiffLinesFromBuffer(bufferLines []string) []bufferLineParse {
result := make([]bufferLineParse, len(bufferLines))
for i := 0; i < len(bufferLines); {
if !strings.HasPrefix(bufferLines[i], diffFilePrefix) {
i++ // not in a file section yet; leave it unresolved
continue
}
_, end := fileSectionBounds(bufferLines, i)
copy(result[i:end], parseFileSection(bufferLines[i:end]))
i = end
}
return result
}
// fileSectionBounds returns the half-open range [start, end) of the file section
// containing targetIdx: the nearest "diff --git" at or above it, up to the next one
// (or the end of the buffer). start is -1 when targetIdx is before the first file
// section.
func fileSectionBounds(bufferLines []string, targetIdx int) (start, end int) {
start = -1
for i := targetIdx; i >= 0; i-- {
if strings.HasPrefix(bufferLines[i], diffFilePrefix) {
start = i
break
}
}
if start == -1 {
return -1, -1
}
end = len(bufferLines)
for i := start + 1; i < len(bufferLines); i++ {
if strings.HasPrefix(bufferLines[i], diffFilePrefix) {
end = i
break
}
}
return start, end
}
// parseFileSection parses one file's diff section (fileLines, starting at its
// "diff --git" line) a single time and returns the resolved identity for each of
// its lines, indexed 1:1 with fileLines. patch.Parse's line indices line up with
// the section's buffer lines, so the type and old/new line numbers fall straight
// out of the patch arithmetic. Every line is left ok=false when the section has no
// recoverable path or isn't a well-formed unified diff — the rendering restructured
// it (e.g. delta's line-number gutters push the +/- marker off the start of the
// line, so every body line reads as context), and trusting the mis-parse would land
// us on the wrong line, so the caller should fall back to another backend.
func parseFileSection(fileLines []string) []bufferLineParse {
result := make([]bufferLineParse, len(fileLines))
relPath := pathFromDiffHeader(fileLines)
if relPath == "" {
return result
}
p := patch.Parse(strings.Join(fileLines, "\n"))
if !p.IsWellFormed() {
return result
}
patchLines := p.Lines()
for i := range fileLines {
if i >= len(patchLines) {
break
}
parsed := parsedDiffLine{
RelPath: relPath,
Type: diffLineTypeForKind(patchLines[i].Kind),
NewLine: p.LineNumberOfLine(i),
}
if parsed.Type == types.DiffLineDeleted {
parsed.OldLine = p.OldLineNumberOfLine(i)
}
result[i] = bufferLineParse{parsed, true}
}
return result
}
func diffLineTypeForKind(kind patch.PatchLineKind) types.DiffLineType {
switch kind {
case patch.PATCH_HEADER:
return types.DiffLineFileHeader
case patch.HUNK_HEADER:
return types.DiffLineHunkHeader
case patch.ADDITION:
return types.DiffLineAdded
case patch.DELETION:
return types.DiffLineDeleted
case patch.CONTEXT:
return types.DiffLineContext
default:
return types.DiffLineOther
}
}
// pathFromDiffHeader extracts the new-file path of a single file's diff section.
// It prefers the "+++ b/<path>" line (falling back to "--- a/<path>" when the
// new path is /dev/null, i.e. a deleted file), and as a last resort the
// "diff --git" line. Paths with characters git C-quotes are not handled (this is
// a prototype); the common unquoted case is.
func pathFromDiffHeader(fileLines []string) string {
var oldPath, newPath string
for _, line := range fileLines {
if strings.HasPrefix(line, "@@") {
break // past the header
}
switch {
case strings.HasPrefix(line, "+++ "):
newPath = stripDiffPathPrefix(strings.TrimPrefix(line, "+++ "))
case strings.HasPrefix(line, "--- "):
oldPath = stripDiffPathPrefix(strings.TrimPrefix(line, "--- "))
}
}
if newPath != "" && newPath != "/dev/null" {
return newPath
}
if oldPath != "" && oldPath != "/dev/null" {
return oldPath
}
return pathFromDiffGitLine(fileLines[0])
}
// stripDiffPathPrefix removes git's default a/ or b/ diff path prefix if present.
func stripDiffPathPrefix(path string) string {
if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") {
return path[2:]
}
return path
}
// pathFromDiffGitLine extracts the new-file path from a "diff --git a/X b/X"
// line, used only when the +++/--- lines are absent.
func pathFromDiffGitLine(line string) string {
rest := strings.TrimPrefix(line, diffFilePrefix)
if idx := strings.LastIndex(rest, " b/"); idx != -1 {
return rest[idx+len(" b/"):]
}
return ""
}
// parseDiffLineMetadata parses mechanism #2's OSC 1717 payload (v1):
// version;type;new-line;old-line;file — positional and ';'-delimited, with the
// file last (so it may itself contain ';'), old-line empty unless the line is a
// deletion, and new-line empty on a file header (the one type that carries no
// line number). See diff-line-metadata-notes.md §9.2. ok is false for a payload
// of an unknown version or shape, so the caller can fall back to another backend.
func parseDiffLineMetadata(payload string) (parsedDiffLine, bool) {
fields := strings.SplitN(payload, ";", 5)
if len(fields) < 5 || fields[0] != "1" {
return parsedDiffLine{}, false
}
lineType, ok := diffLineTypeFromMetadata(fields[1])
if !ok {
return parsedDiffLine{}, false
}
newLine := 0
if fields[2] != "" {
var err error
if newLine, err = strconv.Atoi(fields[2]); err != nil {
return parsedDiffLine{}, false
}
} else if lineType != types.DiffLineFileHeader {
return parsedDiffLine{}, false
}
oldLine := 0
if fields[3] != "" {
var err error
if oldLine, err = strconv.Atoi(fields[3]); err != nil {
return parsedDiffLine{}, false
}
}
return parsedDiffLine{RelPath: fields[4], Type: lineType, NewLine: newLine, OldLine: oldLine}, true
}
func diffLineTypeFromMetadata(typeField string) (types.DiffLineType, bool) {
switch typeField {
case "c":
return types.DiffLineContext, true
case "a":
return types.DiffLineAdded, true
case "d":
return types.DiffLineDeleted, true
case "f":
return types.DiffLineFileHeader, true
case "h":
return types.DiffLineHunkHeader, true
default:
return types.DiffLineOther, false
}
}
@@ -0,0 +1,147 @@
package helpers
import (
"strings"
"testing"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/stretchr/testify/assert"
)
// A two-file commit diff as it appears (decolorized) in the main view. file1 has
// two consecutive deletions (grape, pear) that share a new-file line number;
// file2 has two consecutive additions.
const twoFileDiff = `diff --git a/file1.go b/file1.go
index 1111111..2222222 100644
--- a/file1.go
+++ b/file1.go
@@ -1,4 +1,2 @@
apple
-grape
-pear
lemon
diff --git a/dir/file2.go b/dir/file2.go
index 3333333..4444444 100644
--- a/dir/file2.go
+++ b/dir/file2.go
@@ -10,2 +9,4 @@ func foo() {
ctx
+added1
+added2
ctx2`
func TestParseDiffLineFromBuffer(t *testing.T) {
bufferLines := strings.Split(twoFileDiff, "\n")
scenarios := []struct {
name string
targetIdx int
expected parsedDiffLine
expectOk bool
}{
{"file header", 0, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineFileHeader, NewLine: 1}, true},
{"hunk header", 4, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineHunkHeader, NewLine: 1}, true},
{"context line", 5, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineContext, NewLine: 1}, true},
// The two deletions share new-file line 2 but have distinct old-file lines.
{"first deletion", 6, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
{"second deletion", 7, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true},
// The second file: its path comes from the second "diff --git" section,
// and its additions get distinct new-file line numbers.
{"first addition", 15, parsedDiffLine{RelPath: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, true},
{"second addition", 16, parsedDiffLine{RelPath: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 11}, true},
{"out of range", 999, parsedDiffLine{}, false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result, ok := parseDiffLineFromBuffer(bufferLines, s.targetIdx)
assert.Equal(t, s.expectOk, ok)
if s.expectOk {
assert.Equal(t, s.expected, result)
}
})
}
}
func TestParseDiffLineFromBufferNotADiff(t *testing.T) {
// A rendering with no "diff --git" line (e.g. delta's default mode) can't be
// parsed, so the caller falls back to another backend.
bufferLines := []string{"some", "lines", "that", "are not a diff"}
_, ok := parseDiffLineFromBuffer(bufferLines, 2)
assert.False(t, ok)
}
func TestParseDiffLineFromBufferGutterMangled(t *testing.T) {
// delta with line-number gutters keeps the diff/hunk headers but pushes the
// +/- markers off the start of each body line, so every line reads as
// context. The body no longer matches the hunk header, so we refuse to parse
// (and the caller falls back) rather than return a confident mis-parse.
mangled := strings.Split(`diff --git a/file1.txt b/file1.txt
index 1111111..2222222 100644
--- a/file1.txt
+++ b/file1.txt
@@ -1,5 +1,3 @@
1 ⋮ 1 │ apple
2 ⋮ │-grape
3 ⋮ │-pear
4 ⋮ 2 │ lemon
5 ⋮ 3 │ mango`, "\n")
_, ok := parseDiffLineFromBuffer(mangled, 6)
assert.False(t, ok)
}
func TestParseDiffLineMetadata(t *testing.T) {
scenarios := []struct {
name string
payload string
expected parsedDiffLine
expectOk bool
}{
// These payloads are exactly what the patched delta emits (verified
// against the real binary; see diff-line-metadata-notes.md §9).
{"context", "1;c;1;;foo.txt", parsedDiffLine{RelPath: "foo.txt", Type: types.DiffLineContext, NewLine: 1}, true},
{"added", "1;a;3;;foo.txt", parsedDiffLine{RelPath: "foo.txt", Type: types.DiffLineAdded, NewLine: 3}, true},
// A deletion carries both numbers; two consecutive deletions share the
// new-file line and differ only in the old-file line.
{"first deletion", "1;d;2;2;foo.txt", parsedDiffLine{RelPath: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
{"second deletion", "1;d;2;3;foo.txt", parsedDiffLine{RelPath: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true},
// A whole-file deletion has new-file position 0 and the old path.
{"deleted file", "1;d;0;1;gone.txt", parsedDiffLine{RelPath: "gone.txt", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 1}, true},
// The path is the last field, so a ';' within it is preserved.
{"path with semicolon", "1;c;5;;weird;name.txt", parsedDiffLine{RelPath: "weird;name.txt", Type: types.DiffLineContext, NewLine: 5}, true},
// A pager may emit an absolute path; the parser keeps it verbatim (the
// caller decides whether to join the worktree path).
{"absolute path", "1;a;7;;/abs/foo.txt", parsedDiffLine{RelPath: "/abs/foo.txt", Type: types.DiffLineAdded, NewLine: 7}, true},
// A file header carries no line numbers; a hunk header carries the
// new-file line of the hunk's first line (0 for a whole-file deletion,
// mirroring `@@ -1,N +0,0 @@`).
{"file header", "1;f;;;foo.txt", parsedDiffLine{RelPath: "foo.txt", Type: types.DiffLineFileHeader}, true},
{"hunk header", "1;h;10;;foo.txt", parsedDiffLine{RelPath: "foo.txt", Type: types.DiffLineHunkHeader, NewLine: 10}, true},
{"hunk header of a deleted file", "1;h;0;;gone.txt", parsedDiffLine{RelPath: "gone.txt", Type: types.DiffLineHunkHeader, NewLine: 0}, true},
// The spec leaves a file header's new-line always empty, but the parser
// doesn't police a number being there — it just carries it along.
{"file header with a line number (tolerated)", "1;f;10;;foo.txt", parsedDiffLine{RelPath: "foo.txt", Type: types.DiffLineFileHeader, NewLine: 10}, true},
{"unknown version", "2;c;1;;foo.txt", parsedDiffLine{}, false},
{"unknown type", "1;x;1;;foo.txt", parsedDiffLine{}, false},
{"too few fields", "1;c;1", parsedDiffLine{}, false},
{"non-numeric new-line", "1;c;x;;foo.txt", parsedDiffLine{}, false},
{"non-numeric old-line", "1;d;2;y;foo.txt", parsedDiffLine{}, false},
// Only a file header may omit the new-line; on any other type an empty
// new-line is a malformed record, and rejecting it makes the row fall
// back to the other backends rather than acting on wrong data.
{"empty new-line on a content line", "1;c;;;foo.txt", parsedDiffLine{}, false},
{"empty new-line on a hunk header", "1;h;;;foo.txt", parsedDiffLine{}, false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result, ok := parseDiffLineMetadata(s.payload)
assert.Equal(t, s.expectOk, ok)
if s.expectOk {
assert.Equal(t, s.expected, result)
}
})
}
}
+2
View File
@@ -36,6 +36,7 @@ type Helpers struct {
FixupHelper *FixupHelper
Commits *CommitsHelper
SuspendResume *SuspendResumeHelper
CommitFiles *CommitFilesHelper
Snake *SnakeHelper
// lives in context package because our contexts need it to render to main
Diff *DiffHelper
@@ -74,6 +75,7 @@ func NewStubHelpers() *Helpers {
AmendHelper: &AmendHelper{},
FixupHelper: &FixupHelper{},
Commits: &CommitsHelper{},
CommitFiles: &CommitFilesHelper{},
Snake: &SnakeHelper{},
Diff: &DiffHelper{},
Repos: &ReposHelper{},
@@ -9,14 +9,17 @@ import (
)
type PatchBuildingHelper struct {
c *HelperCommon
c *HelperCommon
stagingHelper *StagingHelper
}
func NewPatchBuildingHelper(
c *HelperCommon,
stagingHelper *StagingHelper,
) *PatchBuildingHelper {
return &PatchBuildingHelper{
c: c,
c: c,
stagingHelper: stagingHelper,
}
}
@@ -32,9 +35,59 @@ func (self *PatchBuildingHelper) ShowHunkStagingHint() {
}
}
// takes us from the patch building panel back to the commit files panel
// takes us from the patch building panel back to the commit files panel, or to
// the focused main view if that's where we entered it from
func (self *PatchBuildingHelper) Escape() {
self.c.Context().Pop()
EscapeFromPatchExplorer(self.c, self.stagingHelper, self.c.Contexts().CustomPatchBuilder)
}
// EscapeFromPatchExplorer returns from a patch explorer context (staging or
// patch building). If we entered it from a focused main view, we go back to
// where we came from (re-rendering the side panel's content into the main view,
// like the plain escape does), then focus the main view and land on the line the
// explorer currently has selected. Otherwise we just pop to the side panel.
func EscapeFromPatchExplorer(c *HelperCommon, stagingHelper *StagingHelper, context types.IPatchExplorerContext) {
snapshot := context.GetFocusedMainViewSnapshot()
if snapshot == nil {
c.Context().Pop()
return
}
// Clear the snapshot wherever it was set. The staging view records it on both
// its halves (see FilesController.EnterFile) so escape works after the selection
// crosses between them; clear both so a stale one can't linger. For patch
// building it's only on the context we're escaping from.
context.SetFocusedMainViewSnapshot(nil)
c.Contexts().Staging.SetFocusedMainViewSnapshot(nil)
c.Contexts().StagingSecondary.SetFocusedMainViewSnapshot(nil)
// Restore the side panel's selection before we render it, so it shows the
// same content the main view had (diving into staging can change it, e.g.
// from a directory to a file in the files panel).
if listContext, ok := snapshot.SidePanel.(types.IListContext); ok && snapshot.SidePanelSelectedLineIdx >= 0 {
listContext.GetList().SetSelectedLineIdx(snapshot.SidePanelSelectedLineIdx)
}
// Ask the upcoming re-render of the main view to land on the line the explorer
// currently has selected. Read that identity now, before the pushes: pushing
// the side panel re-renders its content into the main view, and the restore
// rides that re-render — finding the matching row as it loads and scrolling to
// and selecting it as the content first appears. See RestoreFocusedMainViewOnEscape.
//
// Anchor on the *first* line of the explorer's selection: in hunk or range mode
// the selection spans several lines and its cursor sits at the last one, but
// returning to the start of the hunk is what reads as "the same place".
selectedViewLine := context.GetView().SelectedLineIdx()
if state := context.GetState(); state != nil {
selectedViewLine, _ = state.SelectedViewRange()
}
stagingHelper.RestoreFocusedMainViewOnEscape(
context.GetView(), snapshot.MainView.GetView(), selectedViewLine)
// Land on the side panel first (this re-renders the original content into the
// main view), then focus the main view on top of it.
c.Context().Push(snapshot.SidePanel, types.OnFocusOpts{})
c.Context().Push(snapshot.MainView, types.OnFocusOpts{})
}
// kills the custom patch and returns us back to the commit files panel if needed
@@ -56,8 +109,10 @@ func (self *PatchBuildingHelper) Reset() error {
func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpts) {
selectedLineIdx := -1
selectedRealLineIdx := -1
if opts.ClickedWindowName == "main" {
selectedLineIdx = opts.ClickedViewLineIdx
selectedRealLineIdx = opts.ClickedViewRealLineIdx
}
if !self.c.Git().Patch.PatchBuilder.Active() {
@@ -90,7 +145,7 @@ func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpt
oldState := context.GetState()
state := patch_exploring.NewState(diff, selectedLineIdx, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView)
state := patch_exploring.NewState(diff, selectedLineIdx, selectedRealLineIdx, opts.ClickedViewRealLineIsDeletion, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView, opts.SelectLineInDefaultMode)
context.SetState(state)
if state == nil {
self.Escape()
+20 -8
View File
@@ -371,11 +371,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway
if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) {
var capturedCommitFiles capturedCommitFilesState
var capturedCommitFiles *capturedCommitFilesState
self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedCommitFiles = self.captureCommitFilesState()
})
refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) })
if capturedCommitFiles != nil {
refresh("commit files", func() { _ = self.refreshCommitFilesContext(*capturedCommitFiles, env) })
}
}
fileWg := sync.WaitGroup{}
@@ -752,10 +754,12 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS
// Capture the diff endpoints here, on the UI thread and after
// ReInit has set them, before dispatching the git work.
capturedCommitFiles := self.captureCommitFilesState()
self.onWorker(env.background, func(gocui.Task) error {
_ = self.refreshCommitFilesContext(capturedCommitFiles, env)
return nil
})
if capturedCommitFiles != nil {
self.onWorker(env.background, func(gocui.Task) error {
_ = self.refreshCommitFilesContext(*capturedCommitFiles, env)
return nil
})
}
}
})
}
@@ -1013,10 +1017,18 @@ type capturedCommitFilesState struct {
// captureCommitFilesState reads the commit-files refresh's diff endpoints into
// an immutable snapshot. It must run on the UI thread.
func (self *RefreshHelper) captureCommitFilesState() capturedCommitFilesState {
func (self *RefreshHelper) captureCommitFilesState() *capturedCommitFilesState {
// The commit files context may have no ref associated with it yet — e.g. a custom
// patch was started straight from the commits panel's main view without ever entering
// the commit files panel. There are then no commit files to load (and deriving a diff
// range from a nil ref would panic), so there's nothing to refresh.
if self.c.Contexts().CommitFiles.GetRef() == nil && self.c.Contexts().CommitFiles.GetRefRange() == nil {
return nil
}
from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
return capturedCommitFilesState{from: from, to: to, reverse: reverse}
return &capturedCommitFilesState{from: from, to: to, reverse: reverse}
}
func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, env refreshEnv) error {
+896 -4
View File
@@ -1,20 +1,43 @@
package helpers
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/jesseduffield/lazygit/pkg/utils"
)
var lazygitEditURLRegexp = regexp.MustCompile(`^lazygit-edit://(.+?):(\d+)$`)
type StagingHelper struct {
c *HelperCommon
c *HelperCommon
windowHelper *WindowHelper
// pagerMetadataSupport caches whether the configured pager speaks the
// diff-line-metadata protocol (nil until first probed), so the focused main view
// knows whether it can act on the pager's diff or must render it raw (see
// DiffMainViewShouldRenderRaw). Keyed on pagerSupportSig; re-probed when the pager
// changes (a cycle or a config reload).
pagerMetadataSupport *bool
pagerSupportSig string
}
func NewStagingHelper(
c *HelperCommon,
windowHelper *WindowHelper,
) *StagingHelper {
return &StagingHelper{
c: c,
c: c,
windowHelper: windowHelper,
}
}
@@ -30,12 +53,16 @@ func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) {
}
mainSelectedLineIdx := -1
mainSelectedRealLineIdx := -1
secondarySelectedLineIdx := -1
secondarySelectedRealLineIdx := -1
if focusOpts.ClickedViewLineIdx > 0 {
if secondaryFocused {
secondarySelectedLineIdx = focusOpts.ClickedViewLineIdx
secondarySelectedRealLineIdx = focusOpts.ClickedViewRealLineIdx
} else {
mainSelectedLineIdx = focusOpts.ClickedViewLineIdx
mainSelectedRealLineIdx = focusOpts.ClickedViewRealLineIdx
}
}
@@ -64,11 +91,11 @@ func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) {
hunkMode := self.c.UserConfig().Gui.UseHunkModeInStagingView
mainContext.SetState(
patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainContext.GetView(), mainContext.GetState(), hunkMode),
patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainSelectedRealLineIdx, focusOpts.ClickedViewRealLineIsDeletion, mainContext.GetView(), mainContext.GetState(), hunkMode, focusOpts.SelectLineInDefaultMode),
)
secondaryContext.SetState(
patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode),
patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondarySelectedRealLineIdx, focusOpts.ClickedViewRealLineIsDeletion, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode, focusOpts.SelectLineInDefaultMode),
)
mainState := mainContext.GetState()
@@ -125,3 +152,868 @@ func (self *StagingHelper) secondaryStagingFocused() bool {
func (self *StagingHelper) mainStagingFocused() bool {
return self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().Staging.GetKey()
}
// GetDiffLineInfo recovers the patch-space identity — (file, type, new-line,
// old-line) — of a rendered diff row, given the window showing the diff and the
// (wrapped) view line index. It is the single seam the focused main view and
// patch explorer consumers go through to act on the line the user is pointing
// at, and the strategy behind it is swappable (see diff-line-metadata-notes.md).
//
// It tries three backends in order of fidelity. First, mechanism #2: per-line
// OSC metadata emitted by a patched pager (delta), which carries the side
// directly and so serves the renderings #1 can't parse — delta's default mode,
// --line-numbers, diff-so-fancy. Failing that, mechanism #1: parsing the
// decolorized view buffer, which serves the structure-preserving renderings (no
// pager, git diff --color, delta --color-only, diff-so-fancy --patch). Failing
// that, delta's lazygit-edit:// hyperlinks; the hyperlink can't convey the side,
// so its result is reported as a non-deletion content line.
func (self *StagingHelper) GetDiffLineInfo(windowName string, viewLineIdx int) (types.DiffLineInfo, bool) {
v, _ := self.c.GocuiGui().View(self.windowHelper.GetViewNameForWindow(windowName))
if v == nil {
return types.DiffLineInfo{}, false
}
return self.GetDiffLineInfoForView(v, viewLineIdx)
}
// ChangeLinesInViewRange resolves every change line (addition/deletion) in the
// inclusive view-line range [first, last] of the diff shown in the given window, as
// the patch-space identities to stage. It is the range form of GetDiffLineInfo,
// behind staging a selection from the focused main view. Context and header lines
// 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 {
return nil
}
resolved := self.resolveDiffLines(v.DiffLineContents())
payloadsByLine := v.DiffLineMetadataPayloads()
var infos []types.DiffLineInfo
lastBufferLine := -1
for viewLine := first; viewLine <= last; viewLine++ {
bufferLine, ok := v.BufferLineForViewLine(viewLine)
if !ok || bufferLine == lastBufferLine {
continue
}
lastBufferLine = bufferLine
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)
}
}
return infos
}
// ChangeLineOrdinalsInViewRange resolves the selected view-line range [first, last] of a
// custom-patch (secondary) diff to, per file, the ordinals of the selected change lines
// among that file's change lines (0-based, in displayed order), keyed by the file's
// absolute path. The custom-patch view shows exactly the change lines included in the
// patch, in order, so the k-th change line of a file maps to that file's k-th included
// change line — which PatchBuilder.IncludedChangeLineIndices turns into a patch-line index
// to remove. This is how the focused main view removes lines from the custom patch without
// matching by line number, which the aggregated patch renumbers (unreliable for additions).
//
// The whole buffer is scanned to count each file's change lines from its start; only the
// lines inside the selected range are reported. View lines that wrap to the same buffer
// line are de-duplicated by the buffer-line walk.
func (self *StagingHelper) ChangeLineOrdinalsInViewRange(windowName string, first int, last int) map[string][]int {
v, _ := self.c.GocuiGui().View(self.windowHelper.GetViewNameForWindow(windowName))
if v == nil {
return nil
}
firstBuffer, ok := v.BufferLineForViewLine(first)
if !ok {
return nil
}
lastBuffer, ok := v.BufferLineForViewLine(last)
if !ok {
return nil
}
resolved := self.resolveDiffLines(v.DiffLineContents())
countByFile := map[string]int{}
result := map[string][]int{}
for i, r := range resolved {
if !r.ok || !r.info.IsChange() {
continue
}
ordinal := countByFile[r.info.Path]
countByFile[r.info.Path]++
if i >= firstBuffer && i <= lastBuffer {
result[r.info.Path] = append(result[r.info.Path], ordinal)
}
}
return result
}
// GetDiffLineInfoForView is GetDiffLineInfo against a specific view rather than
// one looked up by window. It is used to read the identity of the line the patch
// explorer currently has selected when escaping back to the focused main view,
// where we hold the explorer's view directly.
func (self *StagingHelper) GetDiffLineInfoForView(v *gocui.View, viewLineIdx int) (types.DiffLineInfo, bool) {
// A click/cursor lands on a (wrapped) view line; resolve it to the unwrapped
// buffer line all three backends key off, then read that buffer line's content.
bufferLineIdx, ok := v.BufferLineForViewLine(viewLineIdx)
if !ok {
return types.DiffLineInfo{}, false
}
return self.diffLineInfoFromContents(v.DiffLineContents(), bufferLineIdx)
}
// findResolvedDiffLine returns the index of the first line, at or after from, that
// match accepts as the target, or -1 if none does. It is the inverse direction of the
// diff-line primitive: instead of resolving the line under a cursor, it scans a
// rendered diff (resolved once via resolveDiffLines) for a known identity. The
// position restore uses it to locate, in the re-rendered view, the line it wants to
// land on; match is how that restore decides a row is the target (see diffLineMatch).
func findResolvedDiffLine(resolved []resolvedDiffLine, target types.DiffLineInfo, match diffLineMatch, from int) int {
for i := from; i < len(resolved); i++ {
if resolved[i].ok && match(target, resolved[i].info) {
return i
}
}
return -1
}
// diffLineMatch decides whether a re-rendered row is the target a restore is looking
// for. matchByPatchLine is currently the only one — the post-stage reveal, the other
// case where a line's number can change across the re-render, matches by ordinal
// instead (see RevealSelectionAfterStaging).
type diffLineMatch func(target, row types.DiffLineInfo) bool
// matchByPatchLine matches by source-line number (DiffLineInfo.SamePatchLine). It is
// for restores whose target keeps its source-line number across the re-render — the
// escape restore and the context-size preserve, which re-render the same
// staged/unstaged state, so only context lines (not change lines) can come or go.
func matchByPatchLine(target, row types.DiffLineInfo) bool {
return row.SamePatchLine(target)
}
// RestoreFocusedMainViewOnEscape arranges, when escaping a patch explorer back to
// the focused main view it was entered from, for that view to re-render and then
// land on the line the explorer currently has selected. After staging or dropping
// hunks the explorer's selection auto-advances, so the line the user ended up on
// is more useful to return to than the one they entered on — and, since the diff
// has changed, more reliable than replaying a numeric scroll/index.
//
// It reads that line's patch identity from the explorer now (explorerView,
// explorerSelectedLineIdx), then installs a restore that, as the main view
// re-renders, scans the incoming content for the row matching that identity and —
// once it and a screenful below have loaded — swaps in, scrolls there and selects
// it. If the identity can't be read, or the line never turns up in the re-render
// (the content changed out from under it), no restore is applied and the view
// just re-renders normally.
func (self *StagingHelper) RestoreFocusedMainViewOnEscape(explorerView, mainView *gocui.View, explorerSelectedLineIdx int) {
target, ok := self.GetDiffLineInfoForView(explorerView, explorerSelectedLineIdx)
if !ok {
return
}
self.restoreDiffLinePositionOnRerender(mainView, []diffLineAnchor{{identity: target}}, matchByPatchLine, func(_ diffLineAnchor, viewLine int) {
// scrollIntoView centres the line if it's off-screen, and leaves the scroll
// untouched if it's already visible — so for the common unchanged-content
// escape (the placeholder is the same content at the same scroll) nothing
// moves.
mainView.FocusPoint(0, viewLine, true)
mainView.Highlight = true
mainView.HighlightInactive = false
})
}
// RevealSelectionAfterStaging arranges, after staging or unstaging re-renders the
// focused main view's diff, for the selection to land on the change nearest the one
// just acted on rather than at a stale (and possibly off-content) position — the same
// "advance to the next change" the staging view does. Install it before triggering the
// re-render: it rides the next render of targetView, like RestoreFocusedMainViewOnEscape.
// place re-selects the landed line in the current select mode.
//
// It works by preserving the selection's ordinal among change lines, exactly as the
// staging view preserves its patch-line index: the acted-on line's ordinal is read
// from sourceView (the pane the user acted in) before the op, and after the re-render
// the change line at that ordinal in targetView is selected. Because the op removes the
// acted-on change line(s), that ordinal then holds the next surviving change — the next
// line of the same block when one line was staged, the next block when a whole block
// was. This sidesteps matching by line number, which can't identify the target across a
// stage: a deletion shares its new-file number with the rest of its block, and in the
// staged pane the new-file number is the index, which the op shifts.
//
// sourceView and targetView are usually the same pane, but staging or unstaging can move
// the acted-on side to the other pane (e.g. unstaging the first hunk of an only-staged
// file splits it, pushing the staged remainder to the secondary half); the ordinal is
// preserved across that move since both panes show the same side.
//
// advanceBy shifts the landed ordinal forward by that many change lines. Staging and
// unstaging consume the acted-on lines, so the preserved ordinal (advanceBy 0) already
// lands on the next change. A custom-patch toggle leaves the diff unchanged, so preserving
// the ordinal would land back on the just-toggled lines; passing the toggled change-line
// count advances past them to the next stageable hunk/line, matching the staging feel.
func (self *StagingHelper) RevealSelectionAfterStaging(sourceView *gocui.View, targetView *gocui.View, firstLine int, advanceBy int, place func(viewLine int)) {
ordinal, ok := self.changeLineOrdinal(sourceView, firstLine)
if !ok {
return
}
self.revealChangeLineAtOrdinal(targetView, ordinal+advanceBy, place)
}
// changeLineOrdinal returns how many change lines precede the (change) line at
// viewLine in view's displayed diff — i.e. that line's index among the diff's change
// lines. ok is false when viewLine maps to no buffer line.
func (self *StagingHelper) changeLineOrdinal(view *gocui.View, viewLine int) (int, bool) {
bufferLine, ok := view.BufferLineForViewLine(viewLine)
if !ok {
return 0, false
}
resolved := self.resolveDiffLines(view.DiffLineContents())
ordinal := 0
for i := 0; i < bufferLine && i < len(resolved); i++ {
if resolved[i].ok && resolved[i].info.IsChange() {
ordinal++
}
}
return ordinal, true
}
// diffLineAnchor is a candidate line for a restore to land on after a re-render: a
// patch identity to find the line by, plus the screen row (offset from the view's
// top) it was on when captured, for a restore that wants to put it back there.
type diffLineAnchor struct {
identity types.DiffLineInfo
row int
}
// installDiffLineRestore is the shared core of the position restores. It sets a
// restore on view's render manager that, as view next re-renders, locates a target
// buffer line and places it: findEarly is given the rows that have loaded since the
// previous call (resolvable on their own via OSC metadata or a lazygit-edit hyperlink)
// so the target can be found and painted early when possible; findComplete is given
// the whole resolved diff at the EOF swap for the definitive target (and the
// buffer-parse fallback, which only resolves once whole hunks have loaded). Each
// returns the anchor to hand back to place — the matched candidate for an identity
// restore, a zero anchor for a positional one — and the target buffer line. place
// receives that anchor and the target's view line; if neither finder produces a
// target, place isn't called and the view re-renders normally.
//
// The find runs against the off-screen buffer before the swap, so the (possibly
// whole-diff) scan happens while the previous content is still displayed; otherwise
// the new content would be drawn at the old scroll for the scan's duration.
func (self *StagingHelper) installDiffLineRestore(
view *gocui.View,
findEarly func(rows []gocui.DiffLineContent, offset int) (diffLineAnchor, int, bool),
findComplete func(resolved []resolvedDiffLine) (diffLineAnchor, int, bool),
place func(anchor diffLineAnchor, viewLine int),
) {
// Get-or-create: the target pane may not have rendered yet (the secondary half
// when a stage/unstage first splits the diff), so the restore has to be set on a
// manager that the upcoming render will then reuse.
manager := self.c.GetOrCreateViewBufferManagerForView(view)
if manager == nil {
return
}
// scanned tracks how far findEarly has looked, so each line is checked once
// (keeping the incremental scan O(n) over the whole load).
primaryAnchor := diffLineAnchor{}
primaryBufferLine := -1
scanned := 0
manager.SetRestoreForNextTask(&tasks.RenderRestore{
FirstPaintReady: func() bool {
if primaryBufferLine == -1 {
newRows := view.OffscreenDiffLineContentsFrom(scanned)
if anchor, line, ok := findEarly(newRows, scanned); ok {
primaryAnchor, primaryBufferLine = anchor, line
}
scanned += len(newRows)
if primaryBufferLine == -1 {
return false
}
}
return view.OffscreenLineCount() >= primaryBufferLine+view.InnerHeight()
},
Apply: func(swapIn func()) {
anchor, bufferLine := primaryAnchor, primaryBufferLine
if bufferLine == -1 {
if a, line, ok := findComplete(self.resolveDiffLines(view.OffscreenDiffLineContents())); ok {
anchor, bufferLine = a, line
}
}
swapIn()
if bufferLine != -1 {
if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok {
place(anchor, viewLine)
}
}
manager.ClearRestoreForNextTask()
},
})
}
// restoreDiffLinePositionOnRerender installs a restore that, as view next re-renders,
// lands on a row that match accepts as one of the given candidate identities, and calls
// place with that candidate and its view line. candidates are in priority order
// (nearest first); the restore lands on the first the re-render still contains, found
// incrementally for the nearest and at the EOF swap for any farther fallback (candidates
// aren't in load order, so a nearer one can load after a farther one and we mustn't
// commit prematurely). If none turns up, place isn't called. match decides what "still
// contains" means, since the stable notion of identity differs by what changed in the
// re-render (see diffLineMatch).
//
// It is the identity-matched restore behind escaping back to the focused main view (one
// candidate — the line the patch explorer had selected) and preserving a diff view's
// position across a -U context-size change (several candidates around the anchor, placed
// back where they were; see PreserveDiffPositionOnRerender). The post-stage reveal uses
// the positional restore instead (see revealChangeLineAtOrdinal).
func (self *StagingHelper) restoreDiffLinePositionOnRerender(view *gocui.View, candidates []diffLineAnchor, match diffLineMatch, place func(anchor diffLineAnchor, viewLine int)) {
if len(candidates) == 0 {
return
}
self.installDiffLineRestore(view,
func(rows []gocui.DiffLineContent, offset int) (diffLineAnchor, int, bool) {
for j, content := range rows {
if info, ok := self.diffLineInfoPerRow(content); ok && match(candidates[0].identity, info) {
return candidates[0], offset + j, true
}
}
return diffLineAnchor{}, 0, false
},
func(resolved []resolvedDiffLine) (diffLineAnchor, int, bool) {
for _, candidate := range candidates {
if line := findResolvedDiffLine(resolved, candidate.identity, match, 0); line != -1 {
return candidate, line, true
}
}
return diffLineAnchor{}, 0, false
},
place,
)
}
// revealChangeLineAtOrdinal installs a restore that, as view next re-renders, selects
// the change line at the given ordinal among the diff's change lines (clamped to the
// last when the re-render has fewer). It is the positional restore behind the post-stage
// reveal: see RevealSelectionAfterStaging for why an ordinal, not an identity.
func (self *StagingHelper) revealChangeLineAtOrdinal(view *gocui.View, ordinal int, place func(viewLine int)) {
seen := 0 // change lines counted so far by the incremental scan
self.installDiffLineRestore(view,
func(rows []gocui.DiffLineContent, offset int) (diffLineAnchor, int, bool) {
for j, content := range rows {
if info, ok := self.diffLineInfoPerRow(content); ok && info.IsChange() {
if seen == ordinal {
return diffLineAnchor{}, offset + j, true
}
seen++
}
}
return diffLineAnchor{}, 0, false
},
func(resolved []resolvedDiffLine) (diffLineAnchor, int, bool) {
last, count := -1, 0
for i, r := range resolved {
if r.ok && r.info.IsChange() {
last = i
if count == ordinal {
return diffLineAnchor{}, i, true
}
count++
}
}
// Fewer change lines than the ordinal (the acted-on line was at or near the
// end): clamp to the last surviving change.
return diffLineAnchor{}, last, last != -1
},
func(_ diffLineAnchor, viewLine int) { place(viewLine) },
)
}
// PreserveDiffPositionOnRerender remembers where a diff view is anchored and, when
// it next re-renders, restores that position instead of letting it jump to the top.
// It is the diff-line primitive's context-change consumer: the increase/decrease
// context-size keybindings re-render the diff with a different git command, which
// otherwise resets the scroll. Call it on the view about to be re-rendered, right
// before triggering the re-render.
//
// The anchor is the selected line if a selection is showing, otherwise the line in
// the middle of the visible content (so what stays put is the line you're most likely
// looking at, not the top edge). We'd like to keep the anchor line itself, but it may
// not survive the re-render: a context line vanishes when the context size shrinks. So
// we collect the
// lines around the anchor as fallbacks (nearest first; see nearbyDiffLines) and land
// on the nearest one that survives — the anchor itself when it does, otherwise the
// closest surviving line, which minimises scrolling. Each is put back at the same
// screen row it was on. A showing selection is re-established on the landed line;
// otherwise the view stays in scroll mode.
//
// A range or hunk selection has two ends, so its far end (the range anchor opposite the
// cursor) is remembered by patch identity too and put back by that identity in the
// re-render — not by screen line, which a restructuring pager (e.g. delta side-by-side)
// makes mean something different. So the selection still spans the same lines even when
// the diff is laid out differently. Restoring the cursor is enough for a single-line
// selection; this just extends the same restore to the other end.
func (self *StagingHelper) PreserveDiffPositionOnRerender(view *gocui.View) {
// If the view isn't the one currently shown in its window it won't be the one
// re-rendered (e.g. the merge-conflicts view occupies the main window), so a
// restore set on it would linger and wrongly suppress a later render's scroll
// reset. There's nothing to preserve then.
if !view.Visible {
return
}
showSelection := view.Highlight
anchorViewLine := view.MiddleVisibleLineIdx()
if showSelection {
anchorViewLine = view.SelectedLineIdx()
}
// The far end of a range/hunk selection, by patch identity, to re-establish below;
// not set (false) for a single-line selection.
farEnd, hasFarEnd := types.DiffLineInfo{}, false
if showSelection {
farEnd, hasFarEnd = self.selectionFarEndIdentity(view)
}
self.restoreDiffLinePositionOnRerender(view, self.nearbyDiffLines(view, anchorViewLine), matchByPatchLine, func(anchor diffLineAnchor, viewLine int) {
// Put the landed line back on the screen row it was captured on, clamped into
// the view in case it was off-screen (a fallback line can be), so the restore
// always lands somewhere visible.
row := max(0, min(anchor.row, view.InnerHeight()-1))
view.SetOrigin(0, viewLine-row)
if showSelection {
// Re-establish the range's far end (by the identity captured above) before
// placing the cursor, so the selection spans the same lines again. Start from
// a clean slate; if the far end didn't survive the re-render, leave a single
// line. SetOrigin already placed the row, so the cursor move mustn't scroll.
view.CancelRangeSelect()
if hasFarEnd {
if farViewLine, ok := self.findDiffLine(view, farEnd); ok {
view.SetRangeSelectStart(farViewLine)
}
}
view.FocusPoint(0, viewLine, false)
view.Highlight = true
view.HighlightInactive = false
}
})
}
// selectionFarEndIdentity returns the patch identity of a range or hunk selection's far
// end — the range anchor opposite the cursor — so a re-render can put the range back over
// the same lines (see PreserveDiffPositionOnRerender). ok is false for a single-line
// selection, or when that end doesn't resolve to a diff line.
func (self *StagingHelper) selectionFarEndIdentity(view *gocui.View) (types.DiffLineInfo, bool) {
first, last := view.SelectedLineRange()
if first == last {
return types.DiffLineInfo{}, false
}
farEnd := last
if view.SelectedLineIdx() == last {
farEnd = first
}
return self.GetDiffLineInfoForView(view, farEnd)
}
// findDiffLine returns the view line of the first row in view's currently displayed diff
// whose patch identity matches the given one, or false if none does — the lookup behind
// restoring a remembered position (a range selection's far end) once the diff has
// re-rendered.
func (self *StagingHelper) findDiffLine(view *gocui.View, identity types.DiffLineInfo) (int, bool) {
resolved := self.resolveDiffLines(view.DiffLineContents())
line := findResolvedDiffLine(resolved, identity, matchByPatchLine, 0)
if line == -1 {
return 0, false
}
return view.ViewLineForBufferLine(line)
}
// nearbyDiffLines collects the resolvable diff lines around the given view line in
// view's displayed diff, as restore candidates ordered by proximity (the anchor line
// itself first, then outward, preferring at-or-below on ties), each tagged with the
// screen row it was on. Expansion in each direction stops once it reaches a change
// line (addition/deletion), which always survives a context-size change — so the list
// always ends in a guaranteed survivor, while still offering the nearer context lines
// as preferred candidates. The restore (see restoreDiffLinePositionOnRerender) lands
// on the first of these the re-render still contains.
func (self *StagingHelper) nearbyDiffLines(view *gocui.View, anchorViewLine int) []diffLineAnchor {
contents := view.DiffLineContents()
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return nil
}
resolved := self.resolveDiffLines(contents)
originY := view.OriginY()
var anchors []diffLineAnchor
// collect appends the line at bufferLine if it resolves, and reports whether it's
// a change line (a guaranteed survivor, so expansion past it isn't needed).
collect := func(bufferLine int) (atChangeLine bool) {
r := resolved[bufferLine]
if !r.ok {
return false
}
if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok {
anchors = append(anchors, diffLineAnchor{identity: r.info, row: viewLine - originY})
}
return r.info.IsChange()
}
if collect(anchor) {
return anchors
}
below, above := anchor+1, anchor-1
for below < len(contents) || above >= 0 {
if below < len(contents) {
if collect(below) {
below = len(contents) // stop expanding past the first change line below
} else {
below++
}
}
if above >= 0 {
if collect(above) {
above = -1 // stop expanding past the first change line above
} else {
above--
}
}
}
return anchors
}
// resolvedDiffLine is one line's resolved patch identity plus whether it could be
// resolved, the element of the table resolveDiffLines produces.
type resolvedDiffLine struct {
info types.DiffLineInfo
ok bool
}
// resolveDiffLines recovers the patch-space identity of every line of a diff's
// per-line content (see gocui.DiffLineContent) in one pass, indexed 1:1 with
// contents. It is the batch form of the inverse diff-line primitive, used by the
// whole-buffer scans — the position restore (nearbyDiffLines / Apply) and the
// focused main view's navigation. Resolving line-by-line would re-run the buffer-
// parse backend's whole-section parse once per line (O(n²) on a large single-file
// diff); this parses the buffer-parse backend once for the whole buffer
// (parseAllDiffLinesFromBuffer) and applies the per-line backend precedence
// (resolveDiffLine) on top.
func (self *StagingHelper) resolveDiffLines(contents []gocui.DiffLineContent) []resolvedDiffLine {
bufferParsed := parseAllDiffLinesFromBuffer(diffLineTexts(contents))
resolved := make([]resolvedDiffLine, len(contents))
for i, content := range contents {
info, ok := self.resolveDiffLine(content, bufferParsed[i])
resolved[i] = resolvedDiffLine{info, ok}
}
return resolved
}
// diffLineInfoFromContents recovers the patch-space identity of a single buffer
// line at idx (see resolveDiffLine for the backend precedence). It is the forward
// consumers' resolver (click/enter/edit/PR, via GetDiffLineInfo on the displayed
// view); to resolve every line of a buffer, use resolveDiffLines, which parses the
// buffer-parse backend once rather than once per line.
func (self *StagingHelper) diffLineInfoFromContents(contents []gocui.DiffLineContent, idx int) (types.DiffLineInfo, bool) {
if idx < 0 || idx >= len(contents) {
return types.DiffLineInfo{}, false
}
parsed, ok := parseDiffLineFromBuffer(diffLineTexts(contents), idx)
return self.resolveDiffLine(contents[idx], bufferLineParse{parsed, ok})
}
// resolveDiffLine applies the diff-line backends to one rendered row in order of
// fidelity, given the row's content and the buffer-parse backend's already-computed
// result for it:
//
// - mechanism #2: per-line OSC metadata emitted by a patched pager (delta),
// which carries the side directly and so serves the renderings #1 can't parse
// (delta's default mode, --line-numbers, diff-so-fancy);
// - mechanism #1: parsing the decolorized buffer (bufferParsed), which serves
// structure-preserving renderings (no pager, git diff --color, delta --color-only);
// - delta's lazygit-edit:// hyperlinks; the hyperlink can't convey the side, so
// its result is reported as a non-deletion content line.
func (self *StagingHelper) resolveDiffLine(content gocui.DiffLineContent, bufferParsed bufferLineParse) (types.DiffLineInfo, bool) {
if info, ok := self.diffLineInfoFromMetadata(content.Metadata); ok {
return info, true
}
if bufferParsed.ok {
return self.diffLineInfoFromParsed(bufferParsed.parsed), true
}
return self.diffLineInfoFromHyperlink(content.Hyperlink)
}
// diffLineInfoPerRow resolves a single rendered row using only the backends that can
// place it from material carried on the row itself — OSC metadata or a lazygit-edit
// hyperlink. It omits the buffer-parse backend, which needs the surrounding
// file/hunk lines and so can't resolve a row while the diff is still streaming in;
// the incremental restore scan uses this, and resolves the buffer-parse case once
// the diff is complete (see restoreDiffLinePositionOnRerender's Apply).
func (self *StagingHelper) diffLineInfoPerRow(content gocui.DiffLineContent) (types.DiffLineInfo, bool) {
if info, ok := self.diffLineInfoFromMetadata(content.Metadata); ok {
return info, true
}
return self.diffLineInfoFromHyperlink(content.Hyperlink)
}
// diffLineTexts extracts the decolorized text of each row — the input the
// buffer-parse backend (mechanism #1) works on.
func diffLineTexts(contents []gocui.DiffLineContent) []string {
texts := make([]string, len(contents))
for i, content := range contents {
texts[i] = content.Text
}
return texts
}
// diffLineInfoFromMetadata reads mechanism #2's per-line OSC metadata. The
// payload is positional and ';'-delimited — version;type;new-line;old-line;file
// — with the file last so it may itself contain ';', and old-line empty unless
// the line is a deletion. See diff-line-metadata-notes.md §9.2.
func (self *StagingHelper) diffLineInfoFromMetadata(payload string) (types.DiffLineInfo, bool) {
if payload == "" {
return types.DiffLineInfo{}, false
}
parsed, ok := parseDiffLineMetadata(payload)
if !ok {
return types.DiffLineInfo{}, false
}
// The pager may emit an absolute or a repo-relative path (whichever is
// convenient for it); normalize to the absolute path the consumers expect.
path := parsed.RelPath
if !filepath.IsAbs(path) {
path = filepath.Join(self.c.Git().RepoPaths.WorktreePath(), path)
}
return types.DiffLineInfo{
Path: path,
Type: parsed.Type,
NewLine: parsed.NewLine,
OldLine: parsed.OldLine,
}, true
}
// diffLineInfoFromParsed turns the buffer parser's repo-relative result into the
// absolute-path identity the consumers expect (mechanism #1).
func (self *StagingHelper) diffLineInfoFromParsed(parsed parsedDiffLine) types.DiffLineInfo {
return types.DiffLineInfo{
Path: filepath.Join(self.c.Git().RepoPaths.WorktreePath(), parsed.RelPath),
Type: parsed.Type,
NewLine: parsed.NewLine,
OldLine: parsed.OldLine,
}
}
// RefreshInclusionGutter updates the focused main view's inclusion gutter to reflect
// the custom patch being built. The gutter — a marker in a reserved left column on
// every change line currently in the patch — is shown only while the main view holds
// focus AND the panel beneath it builds a patch (commits / commit files) with a patch
// active; otherwise it's hidden and the diff renders flush left. It is a focused-main-
// view affordance: browsing the side panel, where the main view shows a preview, shows
// no markers, and it tracks the secondary patch view's visibility (a patch that's
// active but momentarily empty still shows the gutter). Call it whenever focus, the
// patch membership, or the displayed content changes. It only repaints the gutter over
// the existing content — the diff is unaffected by patch membership, so it never
// re-renders.
func (self *StagingHelper) RefreshInclusionGutter() {
// The gutter is painted on the Normal (commit-diff) pane, but it's a focused-main-view
// affordance of the whole pair: it stays visible while either pane holds focus, so it
// doesn't vanish when you tab to the secondary custom-patch pane.
v := self.c.Contexts().Normal.GetView()
// Check focus first: the gutter only shows while the focused main view holds focus, and
// NextInStack below requires the context to be in the stack — which it is exactly when
// it's the current one. The side panel is found beneath whichever pane is current.
patchBuilder := self.c.Git().Patch.PatchBuilder
current := self.c.Context().CurrentStatic()
focused := current.GetKey() == self.c.Contexts().Normal.GetKey() ||
current.GetKey() == self.c.Contexts().NormalSecondary.GetKey()
if !focused || !patchBuilder.Active() {
v.SetInclusionGutter(false, nil)
return
}
sidePanel := self.c.Context().NextInStack(current)
diffMainView, ok := sidePanel.(types.DiffMainViewContext)
if !ok || diffMainView.GetDiffMainViewType() != types.DiffMainViewTypePatchBuilding {
v.SetInclusionGutter(false, nil)
return
}
resolved := self.resolveDiffLines(v.DiffLineContents())
// The patch builder keys files by their repo-relative path; gather each shown
// file's included change-line identities once, keyed by the absolute path the
// resolved rows carry.
includedByPath := map[string]map[patch.LineIdentity]bool{}
includedFor := func(absPath string) map[patch.LineIdentity]bool {
if existing, ok := includedByPath[absPath]; ok {
return existing
}
identities := map[patch.LineIdentity]bool{}
if relPath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), absPath); err == nil {
for _, id := range patchBuilder.IncludedLineIdentities(filepath.ToSlash(relPath)) {
identities[id] = true
}
}
includedByPath[absPath] = identities
return identities
}
marks := make([]bool, len(resolved))
for i, r := range resolved {
if !r.ok || !r.info.IsChange() {
continue
}
lineNumber, isDeletion := r.info.PatchSelectLine()
marks[i] = includedFor(r.info.Path)[patch.LineIdentity{LineNumber: lineNumber, IsDeletion: isDeletion}]
}
v.SetInclusionGutter(true, marks)
}
func (self *StagingHelper) diffLineInfoFromHyperlink(hyperlink string) (types.DiffLineInfo, bool) {
matches := lazygitEditURLRegexp.FindStringSubmatch(hyperlink)
if matches == nil {
return types.DiffLineInfo{}, false
}
return types.DiffLineInfo{
// delta emits an absolute path here, which is what the consumers want.
Path: matches[1],
// The hyperlink carries no side, so it can't distinguish a deletion from
// an addition or context line; report it as a plain content line.
Type: types.DiffLineOther,
NewLine: utils.MustConvertToInt(matches[2]),
}, true
}
// --- The raw-diff fallback for unsupported diff renderers ---
//
// The focused main view is the staging surface, so it must be able to resolve the
// diff it shows to patch-space. A renderer that restructures the diff without emitting
// our metadata (stock delta-default, plain difftastic, `cat -n`) produces output we
// can't resolve, so when such a diff is focused we re-render it raw (git's own
// colour, no renderer) — the same content a no-renderer setup shows, which the buffer
// parser handles. Browsing keeps the pretty output; only focusing to act switches to
// raw.
//
// This is not only about pagers: git's own word-diff formats (a rawGit renderer whose
// arguments include --color-words or --word-diff) restructure the diff just as much,
// their markup being inline, and a git that speaks the protocol annotates them for
// exactly that reason. So the question is asked of whatever renders, git included.
//
// Whether a renderer is usable is decided by probing it (see
// DiffCommands.ProbePagerEmitsDiffMetadata) — a renderer-level, content-independent
// fact, so the verdict is stable (a binary file, which has no change lines under any
// renderer, can't mislead it) and known before we render, so we never render pretty
// only to discover mid-flight that we should have rendered raw.
// pagerSupportsMetadata returns whether the current pager speaks the metadata
// protocol, probing it once and caching the result, re-probing when the pager changes.
func (self *StagingHelper) pagerSupportsMetadata() bool {
sig := self.currentPagerSignature()
if self.pagerMetadataSupport == nil || sig != self.pagerSupportSig {
result := self.c.Git().Diff.ProbePagerEmitsDiffMetadata()
self.pagerMetadataSupport = &result
self.pagerSupportSig = sig
}
return *self.pagerMetadataSupport
}
// currentPagerSignature identifies the current pager, so the cached verdict resets
// when it changes (the user cycles pagers, or reloads a changed config). The pager
// command's width placeholder is irrelevant to its identity, so a fixed width is used.
func (self *StagingHelper) currentPagerSignature() string {
pc := self.c.State().GetDiffRendererConfigManager()
index, _ := pc.CurrentDiffRendererIndex()
return fmt.Sprintf("%d\x00%s\x00%s\x00%s",
index, pc.GetExternalDiffCommand(3), pc.GetStdinFilterCommand(0),
strings.Join(pc.GetRawGitArgs(), "\x00"))
}
// MainViewPagerConfigured reports whether any custom pager is in effect for the
// focused main view's diff — an external diff command, git's diff.external config,
// or a stdin pager. With no pager the diff is already raw and resolvable, so the
// raw-diff fallback only applies when one is configured.
func (self *StagingHelper) MainViewPagerConfigured() bool {
pc := self.c.State().GetDiffRendererConfigManager()
return pc.GetDiffRendererType() != config.DiffRendererType_RawGit
}
func (self *StagingHelper) rendererIsRawGitWithArgs() bool {
pc := self.c.State().GetDiffRendererConfigManager()
return pc.GetDiffRendererType() == config.DiffRendererType_RawGit &&
len(pc.GetRawGitArgs()) > 0
}
// mainViewDiffNeedsMetadata reports whether the diff shown in the main view is one we
// can't recover the patch-space identity of a row from by reading the diff's own text,
// and so can only act on through the metadata records. Any custom renderer restructures
// the diff; so does git itself once the renderer's arguments select a word diff, whose
// markup is inline. Plain `git diff` output is its own answer and needs no records.
func (self *StagingHelper) mainViewDiffNeedsMetadata() bool {
return self.MainViewPagerConfigured() || self.rendererIsRawGitWithArgs()
}
// DiffMainViewShouldRenderRaw reports whether a side panel rendering its diff into
// the focused main view should bypass the diff renderer and render the raw
// (git-coloured) diff. This holds while the main view holds focus and the diff we would
// otherwise show needs the metadata records to be resolvable but won't carry them.
// Side panels consult it when building their main-view diff (see e.g.
// FilesController.GetOnRenderToMain) so that a re-render while focused — after staging
// a hunk, say — stays raw.
func (self *StagingHelper) DiffMainViewShouldRenderRaw() bool {
return self.focusedOnMainView() && self.mainViewDiffNeedsMetadata() &&
!self.pagerSupportsMetadata()
}
func (self *StagingHelper) focusedOnMainView() bool {
current := self.c.Context().CurrentStatic().GetKey()
return current == self.c.Contexts().Normal.GetKey() ||
current == self.c.Contexts().NormalSecondary.GetKey()
}
// RenderFocusedMainViewRaw installs a restore that calls place once the focused main
// view's raw re-render lands, then triggers that re-render via the side panel beneath.
// It's used when focusing a diff under a pager that doesn't speak the protocol:
// DiffMainViewShouldRenderRaw is already true (the verdict is probed, not derived from
// this render), so the side panel renders raw; place then establishes the selection on
// the result.
func (self *StagingHelper) RenderFocusedMainViewRaw(view *gocui.View, sidePanel types.Context, place func()) {
manager := self.c.GetOrCreateViewBufferManagerForView(view)
if manager == nil {
return
}
manager.SetRestoreForNextTask(&tasks.RenderRestore{
// Hold the first paint until end of input so the whole diff is read before we
// place the selection (a change line near the end would otherwise be missed).
FirstPaintReady: func() bool { return false },
Apply: func(swapIn func()) {
swapIn()
manager.ClearRestoreForNextTask()
// Apply runs on the task's goroutine; hop to the UI thread to place the
// selection, which reads the swapped-in content and touches view state.
self.c.OnUIThread(func() error {
place()
return nil
})
},
})
if sidePanel != nil {
sidePanel.HandleRenderToMain()
}
}
@@ -703,9 +703,21 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() {
self.c.Tr.ExecCommandHere + "\n\n" + commit.Name)
} else {
refRange := self.context().GetSelectedRefRangeForDiffFiles()
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange)
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange, renderRaw)
}
// Keep the inclusion gutter in step with the content as this diff
// (re-)renders. It's a no-op unless the main view is focused and a patch is
// being built from this panel; a patch toggle re-renders this same diff, so
// the marks recomputed over the current content stay valid through the swap.
self.c.Helpers().Staging.RefreshInclusionGutter()
// If this re-render is a commit rewrite under the focused main view (drop,
// move-patch-out, undo, …), keep the selection on a surviving change rather
// than leaving the stale range painted.
preserveFocusedMainViewSelectionAcrossContentChange(self.c, task)
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
@@ -720,16 +732,35 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() {
}
func secondaryPatchPanelUpdateOpts(c *ControllerCommon) *types.ViewUpdateOpts {
if c.Git().Patch.PatchBuilder.Active() {
patch := c.Git().Patch.PatchBuilder.RenderAggregatedPatch(false)
patchBuilder := c.Git().Patch.PatchBuilder
if !patchBuilder.Active() {
return nil
}
// Bring the patch's diff trees up to date if the patch changed since they were last
// built (a no-op on a plain re-render, e.g. navigating commits).
if err := c.Git().Patch.EnsureCustomPatchDiffTrees(); err != nil {
c.Log.Error(err)
}
if patchBuilder.TempDir() == "" {
// The patch wasn't materialized (its temp dir couldn't be created); fall back to
// rendering the aggregated patch directly with git's own colours.
return &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(patch),
Task: types.NewRenderStringWithoutScrollTask(patchBuilder.RenderAggregatedPatch(false)),
Title: c.Tr.CustomPatch,
}
}
return nil
// Render the custom patch the same way the main view renders its diff — through the
// pager, or raw (git's own colour) under the raw-diff fallback / when no pager is
// configured — by re-diffing the two file trees the patch was materialized into.
renderRaw := c.Helpers().Staging.DiffMainViewShouldRenderRaw()
cmdObj := c.Git().Diff.CustomPatchDiffCmdObj(patchBuilder.TempDir(), renderRaw)
return &types.ViewUpdateOpts{
Task: types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd()),
Title: c.Tr.CustomPatch,
}
}
func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,316 @@
package controllers
import (
"fmt"
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
// primaryPatchActionFromFocusedMainView routes the primary action (space) in a commit
// panel's focused main view by which pane it was pressed in: the main pane toggles the
// selection into or out of the custom patch, while the secondary pane — which previews the
// patch being built — removes the selection from the patch (mirroring how space in the
// staging view's staged pane unstages). Both commit-panel controllers go through here so
// the routing lives in one place.
func primaryPatchActionFromFocusedMainView(
c *ControllerCommon,
mainViewName string,
firstLineIdx int,
lastLineIdx int,
from string,
to string,
reverse bool,
canRebase bool,
refresh func(),
) error {
if mainViewName == c.Contexts().NormalSecondary.GetViewName() {
return removePatchLinesFromFocusedMainView(c, mainViewName, firstLineIdx, lastLineIdx, refresh)
}
return togglePatchFromFocusedMainView(c, mainViewName, firstLineIdx, lastLineIdx,
from, to, reverse, canRebase, refresh)
}
// removePatchLinesFromFocusedMainView removes the selected change line(s) from the custom
// patch when space is pressed in the secondary pane (the custom-patch preview). Every line
// shown there is already in the patch, so this only ever removes — unlike the main pane's
// toggle. The selection is mapped to patch-line indices by its ordinal among the shown
// change lines (see ChangeLineOrdinalsInViewRange / IncludedChangeLineIndices) rather than
// by line number, which the aggregated patch renumbers (so matching additions by number is
// unreliable). Refresh then re-renders the smaller patch and the selection is re-established
// by its change-line ordinal, like the toggle path.
func removePatchLinesFromFocusedMainView(
c *ControllerCommon,
mainViewName string,
firstLineIdx int,
lastLineIdx int,
refresh func(),
) error {
ordinalsByFile := c.Helpers().Staging.ChangeLineOrdinalsInViewRange(mainViewName, firstLineIdx, lastLineIdx)
if len(ordinalsByFile) == 0 {
return nil
}
patchBuilder := c.Git().Patch.PatchBuilder
for absPath, ordinals := range ordinalsByFile {
filename := patchFilename(c, absPath)
if filename == "" {
continue
}
included := patchBuilder.IncludedChangeLineIndices(filename)
indices := make([]int, 0, len(ordinals))
for _, ordinal := range ordinals {
if ordinal >= 0 && ordinal < len(included) {
indices = append(indices, included[ordinal])
}
}
if len(indices) == 0 {
continue
}
if err := patchBuilder.RemoveFileLineRange(filename, "", indices); err != nil {
return err
}
}
refresh()
// A removal shrinks the patch shown in the secondary, so the removed lines are consumed
// from its diff; preserving the ordinal (advanceBy 0) lands on the next surviving line,
// like unstaging.
revealSelectionAfterPrimaryAction(c, mainViewName, mainViewName, firstLineIdx, 0)
return nil
}
// togglePatchFromFocusedMainView toggles the selected diff line(s) — a single line, a
// range, or a hunk — into or out of the custom patch being built for (from, to,
// reverse). It is the patch-building counterpart of the files panel's staging handler,
// shared by the panels that build a patch from a focused main view: the commit files
// panel (the per-file diff) and the commits / sub-commits / stash panels (the
// whole-commit diff). The selection is resolved to change-line identities (shared with
// staging) and mapped to the patch builder's per-file line indices.
//
// Building a patch needs the patch builder started for this target; if a patch for a
// different target is active, we confirm before discarding it (as entering the patch
// builder does). The diff itself is unchanged by a toggle, so refresh — supplied by the
// caller, since what needs re-rendering differs per panel — re-renders the same diff
// command (keeping its scroll) and the secondary patch view, and the inclusion gutter
// rides that re-render. That re-render (and its layout re-wrap when the secondary view
// first appears) is in view-line space, which moves the selection, so we re-establish it
// afterwards by its width-independent change-line ordinal.
func togglePatchFromFocusedMainView(
c *ControllerCommon,
mainViewName string,
firstLineIdx int,
lastLineIdx int,
from string,
to string,
reverse bool,
canRebase bool,
refresh func(),
) error {
infos := c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
if len(infos) == 0 {
return nil
}
patchBuilder := c.Git().Patch.PatchBuilder
mustDiscardPatch := patchBuilder.Active() && patchBuilder.NewPatchRequired(from, to, reverse)
return c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
Title: c.Tr.DiscardPatch,
Prompt: c.Tr.DiscardPatchConfirm,
HandleConfirm: func() error {
if mustDiscardPatch {
patchBuilder.Reset()
}
if !patchBuilder.Active() {
patchBuilder.Start(from, to, reverse, canRebase)
}
if err := togglePatchLines(c, infos); err != nil {
return err
}
refresh()
// A toggle doesn't change the diff, so source and target are the same pane; the
// re-render (and the layout re-wrap when the secondary view first appears) still
// moves the selection in view-line space, so re-establish it — advanced past the
// just-toggled change lines to the next stageable hunk/line, since they aren't
// consumed (the diff is unchanged), unlike staging.
revealSelectionAfterPrimaryAction(c, mainViewName, mainViewName, firstLineIdx, len(infos))
return nil
},
})
}
// togglePatchLines toggles the change lines identified by infos into or out of the
// custom patch. The direction is decided once, from the first selected change line (in
// view order) — already in the patch means remove the whole selection, otherwise add it
// — and applied to every file the selection spans, mirroring how the patch explorer
// toggles a range.
func togglePatchLines(c *ControllerCommon, infos []types.DiffLineInfo) error {
patchBuilder := c.Git().Patch.PatchBuilder
// Group the selected change lines by file (in view order), then resolve each file's
// identities to patch-line indices in one pass.
var order []string
identitiesByFile := map[string][]patch.LineIdentity{}
for _, info := range infos {
filename := patchFilename(c, info.Path)
if filename == "" {
continue
}
if _, seen := identitiesByFile[filename]; !seen {
order = append(order, filename)
}
lineNumber, isDeletion := info.PatchSelectLine()
identitiesByFile[filename] = append(identitiesByFile[filename],
patch.LineIdentity{LineNumber: lineNumber, IsDeletion: isDeletion})
}
if len(order) == 0 {
return nil
}
indicesByFile := map[string][]int{}
for filename, identities := range identitiesByFile {
indices, err := patchBuilder.PatchLineIndicesForLines(filename, identities)
if err != nil {
return err
}
indicesByFile[filename] = indices
}
// Decide the direction from the first selected change line.
firstFile := order[0]
included, err := patchBuilder.GetFileIncLineIndices(firstFile, "")
if err != nil {
return err
}
remove := len(indicesByFile[firstFile]) > 0 && lo.Contains(included, indicesByFile[firstFile][0])
toggle := patchBuilder.AddFileLineRange
if remove {
toggle = patchBuilder.RemoveFileLineRange
}
for _, filename := range order {
indices := indicesByFile[filename]
if len(indices) == 0 {
continue
}
if err := toggle(filename, "", indices); err != nil {
return err
}
}
return nil
}
// discardFromCommitDisabledReason reports why discarding lines from the commit shown in
// the focused main view is unavailable, or nil when it's available. It mirrors the patch
// builder's own guard: the diff must be a local commit's (canRebase), no rebase may be in
// progress, and the diff context size must be non-zero (a patch can't be built from a
// zero-context diff). Stash and sub-commits of another branch are never rebaseable, so
// they always get the local-commits reason.
//
// Discard is never offered in the secondary pane: it previews the custom patch, so
// discarding "from the commit" there would act on lines shown only as the patch — and
// space already removes them from the patch — which would be confusing.
func discardFromCommitDisabledReason(c *ControllerCommon, mainViewName string, canRebase bool) *types.DisabledReason {
if mainViewName == c.Contexts().NormalSecondary.GetViewName() {
return &types.DisabledReason{Text: c.Tr.CannotDiscardFromCustomPatchView}
}
if !canRebase {
return &types.DisabledReason{Text: c.Tr.CanOnlyDiscardFromLocalCommits}
}
if c.Git().Status.WorkingTreeState().Any() {
return &types.DisabledReason{Text: c.Tr.CantPatchWhileRebasingError}
}
if c.UserConfig().Git.DiffContextSize == 0 {
return &types.DisabledReason{Text: fmt.Sprintf(c.Tr.Actions.NotEnoughContextToRemoveLines,
c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)}
}
return nil
}
// discardSelectionFromCommit discards the selected diff line(s) — a single line, a
// range, or a hunk — from the commit whose diff the focused main view shows, by building
// a one-off patch from the selection and removing it from the commit via a rebase. It is
// the patch-building counterpart of the working-tree discard the files panel does, and
// mirrors the patch builder's own "discard lines from commit": any in-progress custom
// patch is reset first (the confirm prompt warns when one exists), then a fresh patch
// holding exactly the selection is built for (from, to, reverse) and removed from the
// commit. The caller has already established (via canRebase) that the commit is on a
// local branch; the target commit is the one identified by `to`.
func discardSelectionFromCommit(
c *ControllerCommon,
mainViewName string,
firstLineIdx int,
lastLineIdx int,
from string,
to string,
reverse bool,
canRebase bool,
) error {
infos := c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
if len(infos) == 0 {
return nil
}
commitIndex := -1
for i, commit := range c.Model().Commits {
if commit.Hash() == to {
commitIndex = i
break
}
}
if commitIndex == -1 {
return nil
}
patchBuilder := c.Git().Patch.PatchBuilder
prompt := lo.Ternary(patchBuilder.Active(),
c.Tr.DiscardLinesFromCommitPromptWithReset,
c.Tr.DiscardLinesFromCommitPrompt)
c.Confirm(types.ConfirmOpts{
Title: c.Tr.DiscardLinesFromCommitTitle,
Prompt: prompt,
HandleConfirm: func() error {
// Build a fresh patch holding exactly the selection: reset any active patch,
// then add the selected lines (togglePatchLines adds them, the patch being empty).
if patchBuilder.Active() {
patchBuilder.Reset()
}
patchBuilder.Start(from, to, reverse, canRebase)
if err := togglePatchLines(c, infos); err != nil {
return err
}
if patchBuilder.IsEmpty() {
return nil
}
return c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: c.Tr.RebasingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
c.LogAction(c.Tr.Actions.RemovePatchFromCommit)
err := c.Git().Patch.DeletePatchesFromCommit(c.Model().Commits, commitIndex)
// The rebase rewrites the commit, so the focused main view's selection is
// re-established on the next surviving change as the diff re-renders (see
// preserveFocusedMainViewSelectionAcrossContentChange).
return c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
})
},
})
return nil
}
// patchFilename maps a diff line's absolute path to the key the patch builder stores
// the file under — its repo-relative, slash-separated path.
func patchFilename(c *ControllerCommon, absPath string) string {
relPath, err := filepath.Rel(c.Git().RepoPaths.WorktreePath(), absPath)
if err != nil {
return ""
}
return filepath.ToSlash(relPath)
}
@@ -151,9 +151,17 @@ func (self *PatchExplorerController) GetMouseKeybindings(opts types.KeybindingsO
return self.withRenderAndFocus(self.HandleMouseDown)()
}
line := -1
isDeletion := false
if info, ok := self.c.Helpers().Staging.GetDiffLineInfo(self.context.GetWindowName(), opts.Y); ok {
line, isDeletion = info.PatchSelectLine()
}
self.c.Context().Push(self.context, types.OnFocusOpts{
ClickedWindowName: self.context.GetWindowName(),
ClickedViewLineIdx: opts.Y,
ClickedWindowName: self.context.GetWindowName(),
ClickedViewLineIdx: opts.Y,
ClickedViewRealLineIdx: line,
ClickedViewRealLineIsDeletion: isDeletion,
})
return nil
@@ -45,9 +45,10 @@ func (self *ReflogCommitsController) GetOnRenderToMain() func() {
if commit == nil {
task = types.NewRenderStringTask("No reflog history")
} else {
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit))
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit), renderRaw)
task = types.NewRunPtyTask(cmdObj.GetCmd())
task = types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd())
}
self.c.RenderToMainViews(types.RefreshMainOpts{
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -175,7 +176,7 @@ func (self *StagingController) Escape() error {
return nil
}
self.c.Context().Pop()
helpers.EscapeFromPatchExplorer(self.c.HelperCommon, self.c.Helpers().Staging, self.context)
return nil
}
+14 -2
View File
@@ -93,12 +93,23 @@ func (self *StashController) GetOnRenderToMain() func() {
task = types.NewRenderStringTask(self.c.Tr.NoStashEntries)
} else {
prefix := style.FgYellow.Sprintf("%s\n\n", stashEntry.Description())
task = types.NewRunPtyTaskWithPrefix(
self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index).GetCmd(),
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
task = types.NewMainViewDiffTaskWithPrefix(
renderRaw,
self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index, renderRaw).GetCmd(),
prefix,
)
}
// Keep the inclusion gutter in step with the content as this diff
// (re-)renders; a no-op unless the main view is focused and a patch is being
// built from this panel. See LocalCommitsController.GetOnRenderToMain.
self.c.Helpers().Staging.RefreshInclusionGutter()
// Preserve the focused-main-view selection across a content change. See
// LocalCommitsController.GetOnRenderToMain.
preserveFocusedMainViewSelectionAcrossContentChange(self.c, task)
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
@@ -106,6 +117,7 @@ func (self *StashController) GetOnRenderToMain() func() {
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: task,
},
Secondary: secondaryPatchPanelUpdateOpts(self.c),
})
})
}
+12 -1
View File
@@ -46,9 +46,19 @@ func (self *SubCommitsController) GetOnRenderToMain() func() {
task = types.NewRenderStringTask("No commits")
} else {
refRange := self.context().GetSelectedRefRangeForDiffFiles()
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange)
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange, renderRaw)
}
// Keep the inclusion gutter in step with the content as this diff
// (re-)renders; a no-op unless the main view is focused and a patch is being
// built from this panel. See LocalCommitsController.GetOnRenderToMain.
self.c.Helpers().Staging.RefreshInclusionGutter()
// Preserve the focused-main-view selection across a commit rewrite. See
// LocalCommitsController.GetOnRenderToMain.
preserveFocusedMainViewSelectionAcrossContentChange(self.c, task)
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
@@ -56,6 +66,7 @@ func (self *SubCommitsController) GetOnRenderToMain() func() {
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: task,
},
Secondary: secondaryPatchPanelUpdateOpts(self.c),
})
})
}
+1 -1
View File
@@ -123,7 +123,7 @@ func (self *SubmodulesController) GetOnRenderToMain() func() {
if file == nil {
task = types.NewRenderStringTask(prefix)
} else {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, nil)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, false, nil)
task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix)
}
}
@@ -4,6 +4,9 @@ import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -50,6 +53,49 @@ func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOp
return bindings
}
func (self *SwitchToDiffFilesController) GetFocusedMainViewActions() types.FocusedMainViewActions {
return self
}
func (self *SwitchToDiffFilesController) OnClick(mainViewName string, clickedLineIdx int) error {
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
if !ok {
return nil
}
line, isDeletion := info.PatchSelectLine()
// Capture before self.enter() pushes the commit files panel, which
// re-renders the main view. We escape "all the way out" to this side
// panel (skipping the commit files panel), then focus the main view.
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context)
if err := self.enter(); err != nil {
return err
}
context := self.c.Contexts().CommitFiles
var node *filetree.CommitFileNode
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
if err != nil {
return err
}
relativePath = "./" + relativePath
context.CommitFileTreeViewModel.ExpandToPath(relativePath)
self.c.PostRefreshUpdate(context)
idx, ok := context.CommitFileTreeViewModel.GetIndexForPath(relativePath)
if !ok {
return nil
}
context.SetSelectedLineIdx(idx)
context.GetViewTrait().FocusPoint(
context.ModelIndexToViewIndex(idx), false)
node = context.GetSelected()
return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
}
func (self *SwitchToDiffFilesController) Context() types.Context {
return self.context
}
@@ -69,16 +115,7 @@ func (self *SwitchToDiffFilesController) enter() error {
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
commitFilesContext := self.c.Contexts().CommitFiles
canRebase := self.context.CanRebase()
if canRebase {
if self.c.Modes().Diffing.Active() {
if self.c.Modes().Diffing.Ref != ref.RefName() {
canRebase = false
}
} else if refsRange != nil {
canRebase = false
}
}
canRebase := self.canRebase(ref, refsRange)
commitFilesContext.ClearFilter()
commitFilesContext.ReInit(ref, refsRange)
@@ -106,6 +143,79 @@ func (self *SwitchToDiffFilesController) enter() error {
return nil
}
// canRebase reports whether patches built from the selected ref may modify commits —
// true only for commits of the currently checked-out branch, and not while diffing a
// different ref or over a range. Shared by entering the commit files panel and toggling
// patch lines straight from the main view.
func (self *SwitchToDiffFilesController) canRebase(ref models.Ref, refsRange *types.RefRange) bool {
canRebase := self.context.CanRebase()
if canRebase {
if self.c.Modes().Diffing.Active() {
if self.c.Modes().Diffing.Ref != ref.RefName() {
canRebase = false
}
} else if refsRange != nil {
canRebase = false
}
}
return canRebase
}
// PrimaryAction toggles the selected line(s) of the whole-commit diff into or out of the
// custom patch when space is pressed in the focused main view of the commits / sub-commits
// / stash panels. The patch target is the panel's selected ref (or range), matching the
// diff the main view shows. Unlike the commit files panel there are no per-file patch
// indicators to update, so the toggle refreshes cheaply: it re-renders just this panel's
// main + secondary views (leaving the commit list untouched, which a list refresh would
// needlessly reload on every keystroke), re-running the same diff command (scroll
// preserved) and repainting the inclusion gutter.
func (self *SwitchToDiffFilesController) PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error {
ref := self.context.GetSelectedRef()
if ref == nil {
return nil
}
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
from, to := context.FromAndToForDiff(ref, refsRange)
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
canRebase := self.canRebase(ref, refsRange)
return primaryPatchActionFromFocusedMainView(self.c, mainViewName, firstLineIdx, lastLineIdx,
from, to, reverse, canRebase,
func() {
self.c.OnUIThread(func() error {
self.c.PostRefreshUpdate(self.context)
return nil
})
})
}
// DiscardSelection removes the selected line(s) of the whole-commit diff from the commit,
// via a rebase. Same target derivation as the patch toggle; only enabled for local
// commits (see DiscardSelectionDisabledReason), so stash and other-branch sub-commits
// don't reach the rebase.
func (self *SwitchToDiffFilesController) DiscardSelection(mainViewName string, firstLineIdx int, lastLineIdx int) error {
ref := self.context.GetSelectedRef()
if ref == nil {
return nil
}
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
from, to := context.FromAndToForDiff(ref, refsRange)
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
canRebase := self.canRebase(ref, refsRange)
return discardSelectionFromCommit(self.c, mainViewName, firstLineIdx, lastLineIdx, from, to, reverse, canRebase)
}
func (self *SwitchToDiffFilesController) DiscardSelectionDisabledReason(mainViewName string) *types.DisabledReason {
canRebase := false
if ref := self.context.GetSelectedRef(); ref != nil {
canRebase = self.canRebase(ref, self.context.GetSelectedRefRangeForDiffFiles())
}
return discardFromCommitDisabledReason(self.c, mainViewName, canRebase)
}
func (self *SwitchToDiffFilesController) canEnter() *types.DisabledReason {
refRange := self.context.GetSelectedRefRangeForDiffFiles()
if refRange != nil {
@@ -2,6 +2,7 @@ package controllers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -61,21 +62,38 @@ func (self *SwitchToFocusedMainViewController) Context() types.Context {
}
func (self *SwitchToFocusedMainViewController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
return self.focusMainView(self.c.Contexts().Normal)
return self.focusMainView(self.c.Contexts().Normal, opts.Y)
}
func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error {
return self.focusMainView(self.c.Contexts().NormalSecondary)
return self.focusMainView(self.c.Contexts().NormalSecondary, opts.Y)
}
func (self *SwitchToFocusedMainViewController) handleFocusMainView() error {
return self.focusMainView(self.c.Contexts().Normal)
// Focusing by keyboard doesn't point at any particular line: in a diff view
// we start at the first change block (like entering the staging view), in a
// non-diff view we show no selection and the user just scrolls. Clicking does
// point at a line, so it selects that line instead (focusMainView's
// clickedLineIdx).
return self.focusMainView(self.c.Contexts().Normal, -1)
}
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context) error {
if context, ok := mainViewContext.(types.ISearchableContext); ok {
context.ClearSearchString()
}
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext *context.MainContext, clickedLineIdx int) error {
mainViewContext.ClearSearchString()
self.c.Context().Push(mainViewContext, types.OnFocusOpts{})
if !sidePanelShowsDiff(self.context) {
// Non-diff main content (e.g. a branch's commit log): focus only, no
// selection, since there's nothing to act on.
return nil
}
// A click points at a specific line (clickedLineIdx); keyboard focus (-1) starts at
// the first change block. Either way, if the pager produced an unresolvable diff
// this re-renders it raw first (see establishFocusedDiffSelection).
establishFocusedDiffSelection(self.c, mainViewContext, clickedLineIdx)
// The inclusion gutter is refreshed by the main view's focus handler (it's shown
// only while focused, so it tracks focus changes there).
return nil
}
@@ -1,100 +0,0 @@
package controllers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type ViewSelectionControllerFactory struct {
c *ControllerCommon
}
func NewViewSelectionControllerFactory(c *ControllerCommon) *ViewSelectionControllerFactory {
return &ViewSelectionControllerFactory{
c: c,
}
}
func (self *ViewSelectionControllerFactory) Create(context types.Context) types.IController {
return &ViewSelectionController{
baseController: baseController{},
c: self.c,
context: context,
}
}
type ViewSelectionController struct {
baseController
c *ControllerCommon
context types.Context
}
func (self *ViewSelectionController) Context() types.Context {
return self.context
}
func (self *ViewSelectionController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
return []*types.Binding{
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextLine},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom},
}
}
func (self *ViewSelectionController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
return []*gocui.ViewMouseBinding{}
}
func (self *ViewSelectionController) handleLineChange(delta int) {
v := self.Context().GetView()
if delta < 0 {
v.ScrollUp(-delta)
} else {
v.ScrollDown(delta)
self.c.ReadLinesToFillView(v)
}
}
func (self *ViewSelectionController) handlePrevLine() error {
self.handleLineChange(-1)
return nil
}
func (self *ViewSelectionController) handleNextLine() error {
self.handleLineChange(1)
return nil
}
func (self *ViewSelectionController) handlePrevPage() error {
self.handleLineChange(-self.context.GetViewTrait().PageDelta())
return nil
}
func (self *ViewSelectionController) handleNextPage() error {
self.handleLineChange(self.context.GetViewTrait().PageDelta())
return nil
}
func (self *ViewSelectionController) handleGotoTop() error {
v := self.Context().GetView()
self.handleLineChange(-v.ViewLinesHeight())
return nil
}
func (self *ViewSelectionController) handleGotoBottom() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
v := self.Context().GetView()
self.handleLineChange(v.ViewLinesHeight())
return nil
})
})
}
return nil
}
+1
View File
@@ -501,6 +501,7 @@ func (gui *Gui) onUserConfigLoaded() error {
gui.setColorScheme()
gui.configureViewProperties()
gui.applyCurrentPagerSelectionStyle()
gui.g.SearchEscapeKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.Return)
gui.g.NextSearchMatchKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.NextMatch)
+4
View File
@@ -185,6 +185,10 @@ func (self *guiCommon) ReadLinesToFillView(view *gocui.View) {
self.gui.readLinesToFillView(view)
}
func (self *guiCommon) GetOrCreateViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager {
return self.gui.getManager(view)
}
func (self *guiCommon) State() types.IStateAccessor {
return self.gui.stateAccessor
}
+7 -1
View File
@@ -88,7 +88,13 @@ func (gui *Gui) layout(g *gocui.Gui) error {
if !view.CanScrollPastBottom {
maxOriginY -= newHeight - 1
}
if oldOriginY := view.OriginY(); oldOriginY > maxOriginY {
// Don't scroll up while the view's content is still being loaded: its
// height only reflects what has been read so far, so clamping to it now
// would yank the view to the top even though more content is on the way
// (e.g. when re-rendering a diff the user was scrolled into).
manager := gui.getViewBufferManagerForView(view)
stillLoading := manager != nil && manager.IsLoading()
if oldOriginY := view.OriginY(); oldOriginY > maxOriginY && !stillLoading {
view.ScrollUp(oldOriginY - maxOriginY)
// the view might not have scrolled actually (if it was at the limit
// already), so we need to check if it did
+14 -10
View File
@@ -107,16 +107,6 @@ func (gui *Gui) allMainContextPairs() []types.MainContextPair {
}
func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
// need to reset scroll positions of all other main views
for _, pair := range gui.allMainContextPairs() {
if pair.Main != opts.Pair.Main {
pair.Main.GetView().SetOrigin(0, 0)
}
if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary {
pair.Secondary.GetView().SetOrigin(0, 0)
}
}
gui.moveMainContextPairToTop(opts.Pair)
if opts.Main != nil {
@@ -129,6 +119,20 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
opts.Pair.Secondary.GetView().Clear()
}
// Reset the scroll positions of all the other main views. We do this after
// moving this pair to the top (which copies the previously-shown view's
// content into the now-visible one to avoid a blank frame): resetting first
// would zero that source view's scroll before it gets copied, forcing the
// placeholder to the top instead of leaving it where the screen already was.
for _, pair := range gui.allMainContextPairs() {
if pair.Main != opts.Pair.Main {
pair.Main.GetView().SetOrigin(0, 0)
}
if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary {
pair.Secondary.GetView().SetOrigin(0, 0)
}
}
gui.splitMainPanel(opts.Secondary != nil)
}
+35 -5
View File
@@ -45,7 +45,7 @@ const (
HUNK
)
func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *State, useHunkModeByDefault bool) *State {
func NewState(diff string, selectedLineIdx int, selectedRealLineIdx int, selectedRealLineIsDeletion bool, view *gocui.View, oldState *State, useHunkModeByDefault bool, selectLineInDefaultMode bool) *State {
if oldState != nil && diff == oldState.diff && selectedLineIdx == -1 {
// if we're here then we can return the old state. If selectedLineIdx was not -1
// then that would mean we were trying to click and potentially drag a range, which
@@ -61,6 +61,22 @@ func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *Stat
viewLineIndices, patchLineIndices := wrapPatchLines(diff, view)
if selectedRealLineIdx != -1 {
// Look the source line number up in the freshly parsed patch. A deletion
// is identified by its old-file line number (two consecutive deletions
// share a new-file line number), everything else by its new-file one.
// The result is a patch line index, but selectedLineIdx is in view-line
// (wrapped) space, so convert it; without this the landing line is off by
// the number of wrapped lines above it.
var patchLineIdx int
if selectedRealLineIsDeletion {
patchLineIdx = patch.PatchLineForOldLineNumber(selectedRealLineIdx)
} else {
patchLineIdx = patch.PatchLineForLineNumber(selectedRealLineIdx)
}
selectedLineIdx = viewLineIndices[lo.Clamp(patchLineIdx, 0, len(viewLineIndices)-1)]
}
rangeStartLineIdx := 0
if oldState != nil {
rangeStartLineIdx = oldState.rangeStartLineIdx
@@ -76,14 +92,28 @@ func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *Stat
userEnabledHunkMode = oldState.userEnabledHunkMode
}
// if we have clicked from the outside to focus the main view we'll pass in a non-negative line index so that we can instantly select that line
// A non-negative line index means we were given a specific line to select:
// either by clicking or pressing enter on a line in a focused main view, or
// by clicking directly on the patch explorer view to focus it.
if selectedLineIdx >= 0 {
// Clamp to the number of wrapped view lines; index might be out of
// bounds if a custom diff renderer is being used which produces more lines
selectedLineIdx = min(selectedLineIdx, len(viewLineIndices)-1)
selectedLineIdx = min(selectedLineIdx, len(patchLineIndices)-1)
selectMode = RANGE
rangeStartLineIdx = selectedLineIdx
if selectLineInDefaultMode {
// Diving in from a focused main view: keep the default select mode
// computed above. In hunk mode the selection covers the block of
// changes around the line, so snap to a change line if the given one
// is a context line (just as toggling hunk mode does).
if selectMode == HUNK {
selectedLineIdx = viewLineIndices[patch.GetNextChangeIdx(patchLineIndices[selectedLineIdx])]
}
} else {
// Clicking directly on the view starts a range selection that can be
// extended by dragging.
selectMode = RANGE
rangeStartLineIdx = selectedLineIdx
}
} else if oldState != nil {
// if we previously had a selectMode of RANGE, we want that to now be line again (or hunk, if that's the default)
if oldState.selectMode != RANGE {
+1
View File
@@ -230,6 +230,7 @@ M file1
func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
return "", nil
},
nil,
)
patchBuilder.Start("from", "to", false, false)
result := RenderCommitFileTree(viewModel, patchBuilder, false, &config.CustomIconsConfig{})
+31
View File
@@ -65,6 +65,15 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
// Set LAZYGIT_COLUMNS for diff renderer scripts that can't query the terminal width directly.
cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", width))
// Advertise the diff-line metadata protocol versions we understand, so that
// whatever renders the diff annotates each line with an OSC sequence we can read
// back (see diff-line-metadata-notes.md). Set before the no-pty path below,
// because git renders the diff itself there and is one of the things that speaks
// the protocol — for its word-diff formats, whose inline markup we could not
// otherwise resolve. Anything that doesn't understand the variable ignores it, so
// this is safe to set unconditionally.
cmd.Env = append(cmd.Env, "OSC1717=V1")
if gui.stateAccessor.GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit {
// If we're not using a custom diff renderer, then we don't need to use a pty
return gui.newCmdTask(view, cmd, prefix)
@@ -72,6 +81,16 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
cmd.Args = withPtyGitConfig(cmd.Args, runtime.GOOS)
// Mark the view as loading synchronously now, before the layout pass: the
// actual task is created in afterLayout (below), which runs after layout, so
// without this the next layout pass would clamp the scroll position to the
// not-yet-loaded content.
gui.getManager(view).StartLoading()
// Hold the scrollbar at its current height while the re-render loads, so the
// thumb doesn't shrink and snap back when the first partial paint swaps in
// (see the matching call in newCmdTask).
view.FreezeScrollbarHeight()
// Run the pty after layout so that it gets the correct size
gui.afterLayout(func() error {
// Need to get the width and the pager command again because the layout might have
@@ -137,6 +156,18 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
}
linesToRead := gui.linesToReadFromCmdTask(view)
// As in newCmdTask: if a restore is pending for this content (returning to a
// focused main view on escape), let the task re-establish the scroll
// position and selection as it first paints, reading to end of input so a
// deep target line is found and the scrollbar ends up accurate.
restore := manager.GetRestoreForNextTask()
if restore != nil {
linesToRead.Restore = restore
linesToRead.Total = -1
}
// New content scrolls back to the top at the first paint; same content keeps
// its scroll, and a restore places the scroll itself. See LinesToRead.ResetOrigin.
linesToRead.ResetOrigin = restore == nil && cmdStr != manager.GetTaskKey()
return manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr)
})
+33 -6
View File
@@ -18,6 +18,15 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
).Debug("RunCommand")
manager := gui.getManager(view)
// Mark the view as loading synchronously (before the task's goroutine runs
// and before the next layout pass) so the layout doesn't clamp the scroll
// position to the not-yet-loaded content.
manager.StartLoading()
// Hold the scrollbar at the height the view has now (the previous render),
// while it still shows that render: once the re-render swaps in its first
// partial paint the displayed buffer is briefly short, and we don't want the
// thumb to shrink and snap back as the rest loads.
view.FreezeScrollbarHeight()
// Snapshot the view width here, on the UI thread, so the task goroutine
// doesn't read the view's live dimensions while it streams output. It's
@@ -43,6 +52,19 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
}
linesToRead := gui.linesToReadFromCmdTask(view)
// If a restore is pending for this content (returning to a focused main view
// on escape), let the task re-establish the scroll position and selection as
// it first paints. It also reads to end of input so a deep target line is
// found and the scrollbar ends up accurate. See RenderRestore.
restore := manager.GetRestoreForNextTask()
if restore != nil {
linesToRead.Restore = restore
linesToRead.Total = -1
}
// New content (a different command than the view last showed) scrolls back to
// the top, at the first paint; same content keeps its scroll, and a restore
// places the scroll itself. See LinesToRead.ResetOrigin.
linesToRead.ResetOrigin = restore == nil && cmdStr != manager.GetTaskKey()
if err := manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr); err != nil {
gui.c.Log.Error(err)
}
@@ -136,12 +158,10 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
gui.Log,
view,
func() {
// we could clear here, but that actually has the effect of causing a flicker
// where the view may contain no content momentarily as the gui refreshes.
// Instead, we're rewinding the write pointer so that we will just start
// overwriting the existing content from the top down. Once we've reached
// the end of the content do display, we call view.FlushStaleCells() to
// clear out the remaining content from the previous render.
// Called before showing the "loading..." indicator: clear the
// displayed buffer so only "loading..." is shown. The actual content
// is rendered off-screen (beginRender below) and swapped in, so it
// never overwrites the displayed buffer incrementally.
view.Reset()
},
func() {
@@ -153,6 +173,11 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
gui.renderContentOnly()
},
func() {
// The content is fully loaded now, so let the scrollbar track it
// directly again (it was held at the previous render's height while
// loading, see FreezeScrollbarHeight).
view.UnfreezeScrollbarHeight()
// Need to check if the content of the view is well past the origin.
linesHeight := view.ViewLinesHeight()
_, originY := view.Origin()
@@ -167,6 +192,8 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
func() {
view.SetOrigin(0, 0)
},
view.BeginOffscreenRender,
view.SwapInOffscreenRender,
func() gocui.Task {
// A background task: rendering content into a view is display
// work, not lazygit driving a git operation, so it must not
+4
View File
@@ -67,6 +67,10 @@ type IGuiCommon interface {
// return the view buffer manager for the given view, or nil if it doesn't have one
GetViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager
// return the view buffer manager for the given view, creating it if the view
// hasn't rendered yet. Used to install a render restore on a pane that is about
// to appear (e.g. the secondary half when a stage/unstage splits the diff).
GetOrCreateViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager
// read enough lines into the given view's buffer to fill it at its current
// scroll position, plus some read-ahead for smooth scrolling
+124 -8
View File
@@ -95,9 +95,10 @@ type IBaseContext interface {
// that the generic ListController can be specialized by view-specific controllers.
// We'll need to think of a better way to do this.
AddOnDoubleClickFn(func() error)
// Likewise for the focused main view: we need this to communicate between a
// side panel controller and the focused main view controller.
AddOnClickFocusedMainViewFn(func(mainViewName string, clickedLineIdx int) error)
// Likewise for the focused main view: this is how a side panel controller exposes
// the actions its diff supports there (diving in, staging, patch toggling, …) to
// the focused main view controller. nil for panels with no such actions.
AddFocusedMainViewActions(FocusedMainViewActions)
// Adding on to the above, this is so that a list-specific handler can register
// a hook for doing additional click handling
AddOnClickFn(func(opts gocui.ViewMouseBindingOpts) error)
@@ -174,6 +175,42 @@ type DiffableContext interface {
RefForAdjustingLineNumberInDiff() string
}
// DiffMainViewContext is implemented by the side panel contexts whose focused
// main view shows a unified diff — files, local commits, sub-commits, reflog,
// stash, and commit files — as opposed to a commit log or other non-diff content
// (branches, tags, status, …). It is distinct from DiffableContext, which is
// about producing a diff between two refs for the diff menu. Implementing it is
// the signal for whether to show a selection in the focused main view: a selection
// is only meaningful where there are diff lines to act on (stage, edit, jump by
// hunk, open in a pull request). The returned type additionally classifies what the
// primary action (space) does there.
type DiffMainViewContext interface {
Context
GetDiffMainViewType() DiffMainViewType
}
// DiffMainViewType classifies what the primary action (space) does in a side panel's
// focused main view — and, for DiffMainViewTypePatchBuilding, that the focused main
// view shows the inclusion gutter (it marks which change lines are in the patch, so
// it's meaningful only beneath a patch-building panel, and even then only while a
// patch is active).
type DiffMainViewType int
const (
// DiffMainViewTypeNone: a diff is shown — so the focused main view still has a
// selection to edit, jump by hunk, or open in a pull request — but the primary
// action does nothing. Currently the reflog (building a patch from it is a deferred
// gap).
DiffMainViewTypeNone DiffMainViewType = iota
// DiffMainViewTypeStaging: the primary action stages/unstages into the working tree
// (the files panel).
DiffMainViewTypeStaging
// DiffMainViewTypePatchBuilding: the primary action toggles the selection into a
// custom patch (the commit files / commits / sub-commits / stash panels).
DiffMainViewTypePatchBuilding
)
type IListContext interface {
Context
@@ -205,6 +242,34 @@ type IPatchExplorerContext interface {
NavigateTo(selectedLineIdx int)
GetMutex() *deadlock.Mutex
IsPatchExplorerContext() // used for type switch
// See FocusedMainViewSnapshot. Nil unless this patch explorer was entered
// from a focused main view.
GetFocusedMainViewSnapshot() *FocusedMainViewSnapshot
SetFocusedMainViewSnapshot(*FocusedMainViewSnapshot)
}
// FocusedMainViewSnapshot records where a focused main view was when we dived
// into a patch explorer (staging or patch building) from it, so that escaping
// returns us to the same place with the main view focused again. It is nil when
// the patch explorer was entered the normal way (through a side panel), in which
// case escape just pops to that side panel.
type FocusedMainViewSnapshot struct {
// The side panel to land on first; pushing it re-renders the original
// content into the main view. For commits/stash this is the originating side
// panel (skipping the commit files panel we passed through), preserving the
// pre-existing "escape all the way out" behavior.
SidePanel Context
// The side panel's selected line, to restore before re-rendering it. Diving
// into staging can change the side panel's selection (e.g. from a directory
// to a file in the files panel); restoring it makes the main view show the
// same content again. -1 if the side panel isn't a list.
SidePanelSelectedLineIdx int
// The focused main view context to focus afterwards. Where in it to scroll to
// and select is not captured here: on escape we land on the line the patch
// explorer ended up selecting, found by its patch identity in the re-rendered
// content, which survives the diff changing in a way a saved index wouldn't.
MainView Context
}
type IViewTrait interface {
@@ -227,8 +292,28 @@ type IViewTrait interface {
}
type OnFocusOpts struct {
ClickedWindowName string
ClickedViewLineIdx int
ClickedWindowName string
ClickedViewLineIdx int
// A source line number identifying the line to land on in the patch
// explorer. If not -1, takes precedence over ClickedViewLineIdx. It is a
// new-file line number, unless ClickedViewRealLineIsDeletion is set, in which
// case it is an old-file line number (two consecutive deletions share a
// new-file line number, so only the old-file number identifies a deletion).
ClickedViewRealLineIdx int
// Whether ClickedViewRealLineIdx is an old-file line number for a deletion;
// see above.
ClickedViewRealLineIsDeletion bool
// When entering a patch explorer (staging or patch building) by clicking or
// pressing enter on a line in a focused main view, we select that line using
// the default select mode (hunk or line, per the UseHunkModeInStagingView
// config), the same as when entering through the side panel. Clicking
// directly on the patch explorer view instead starts a range selection that
// can be extended by dragging.
SelectLineInDefaultMode bool
ScrollSelectionIntoView bool
}
@@ -263,9 +348,39 @@ type HasKeybindings interface {
// decides not to do anything with the click.
GetOnClick() func(opts gocui.ViewMouseBindingOpts) error
// Implement this in a side-panel controller to get called when there's a click in the main view
// that belongs to your panel while the main view is already focused.
GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error
// Implement this in a side-panel controller to expose the actions its diff
// supports when shown in the focused main view (see FocusedMainViewActions).
// Return nil for a panel whose diff offers none.
GetFocusedMainViewActions() FocusedMainViewActions
}
// FocusedMainViewActions is the set of actions a side panel offers on its diff while
// that diff is shown in the focused main view. The focused main view controller owns
// the keybindings and the selection mechanics and dispatches to whichever panel is
// beneath it; what each action means is the panel's business — the working-tree files
// panel stages, the commit panels toggle the selection into a custom patch. The
// inclusive view-line range passed to the action methods is the current selection (a
// single line, a range, or a hunk). mainViewName identifies which of the two main
// panes the user acted in.
type FocusedMainViewActions interface {
// OnClick dives into staging / patch-building for the clicked line, the same way a
// click in the panel's own view does. Also used by enter (on the selected line).
OnClick(mainViewName string, clickedLineIdx int) error
// PrimaryAction acts on the selected diff line(s) when the user presses space:
// stage/unstage for the working-tree files panel, toggle into/out of the custom
// patch for the commit panels. The handler re-renders the diff and re-establishes
// the selection itself (staging can move the acted-on side to the other pane,
// which the handler then focuses).
PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error
// DiscardSelection discards the selected diff line(s) when the user presses the
// remove key: from the working tree for the files panel, or from the commit (via a
// rebase) for the commit panels. DiscardSelectionDisabledReason reports why discard
// is unavailable here — e.g. the diff isn't a local commit's, or a rebase is in
// progress — or nil when it's available.
DiscardSelection(mainViewName string, firstLineIdx int, lastLineIdx int) error
DiscardSelectionDisabledReason(mainViewName string) *DisabledReason
}
type IController interface {
@@ -326,6 +441,7 @@ type IContextMgr interface {
CurrentSide() Context
CurrentPopup() []Context
NextInStack(context Context) Context
IsInStack(c Context) bool
IsCurrent(c Context) bool
IsCurrentOrParent(c Context) bool
ForEach(func(Context))
+102
View File
@@ -0,0 +1,102 @@
package types
// DiffLineType classifies a rendered diff row. It mirrors the per-line "type"
// of the metadata model described in diff-line-metadata-notes.md, so that the
// host-side buffer parser (mechanism #1) and the future pager-emitted OSC
// metadata (#2) produce the same shape.
type DiffLineType int
const (
DiffLineFileHeader DiffLineType = iota
DiffLineHunkHeader
DiffLineContext
DiffLineAdded
DiffLineDeleted
// DiffLineOther is anything that isn't one of the above (e.g. the
// "\ No newline at end of file" marker). It is also what a backend that
// can't determine the side reports — delta's lazygit-edit hyperlinks carry
// no side — so consumers treat it like a non-deletion content line.
DiffLineOther
)
// DiffLineInfo is the patch-space identity of a rendered diff row, as recovered
// by StagingHelper.GetDiffLineInfo. It is the single shape the focused main view
// and patch explorer consumers act on, regardless of which backend produced it.
type DiffLineInfo struct {
// Path is the absolute path of the file the line belongs to.
Path string
Type DiffLineType
// NewLine is the line's position in the new file. Set for all content lines
// (for a deletion it is the new-file position the deletion sits at) and for
// hunk headers (the first line of the hunk they head). Not meaningful for
// file headers: a pager's `f` record carries no line number (so the OSC
// backend reports 0), and the buffer parser reports 1.
NewLine int
// OldLine is the line's position in the old file. Set only for deletions.
OldLine int
}
// IsChange reports whether the row is an added or deleted line, as opposed to a
// context line or a header. It mirrors patch.PatchLine.IsChange, and is how both
// the context-change scroll preservation (anchor on a surviving change line) and
// the focused main view's hunk navigation (a "hunk" is a block of consecutive
// change lines) classify a rendered row.
func (self DiffLineInfo) IsChange() bool {
return self.Type == DiffLineAdded || self.Type == DiffLineDeleted
}
// PatchSelectLine returns the source line to land on when diving into the patch
// explorer for this row, in source-line-number space so it survives the patch
// being regenerated. For a deletion it is the old-file line number — two
// consecutive deletions share a new-file line number, so only the old-file
// number tells them apart — and for everything else the new-file line number.
func (self DiffLineInfo) PatchSelectLine() (lineNumber int, isDeletion bool) {
if self.Type == DiffLineDeleted {
return self.OldLine, true
}
return self.NewLine, false
}
// SamePatchLine reports whether two identities point at the same source line of
// the same file. It is how the escape restore matches the line the patch explorer
// had selected against the rows of the focused main view as it re-renders: the
// comparison is in source-line-number space (PatchSelectLine), which survives the
// diff being regenerated, rather than a fragile view-line index.
//
// A backend that can't determine the side (delta's lazygit-edit hyperlinks report
// DiffLineOther) yields a non-deletion identity, so a deletion captured from a
// full-fidelity backend won't match such a row — the restore then just doesn't
// find its line, which is the acceptable degradation for that pager config.
//
// A header row shares its line number with a content row — a hunk header carries
// the hunk's first line — so headers only match headers of the same kind:
// otherwise a restore aiming at a hunk's first content line would land one row up
// on the header above it (or vice versa).
func (self DiffLineInfo) SamePatchLine(other DiffLineInfo) bool {
if self.Path != other.Path {
return false
}
if self.isHeader() != other.isHeader() {
return false
}
if self.isHeader() && self.Type != other.Type {
return false
}
selfLine, selfIsDeletion := self.PatchSelectLine()
otherLine, otherIsDeletion := other.PatchSelectLine()
return selfLine == otherLine && selfIsDeletion == otherIsDeletion
}
func (self DiffLineInfo) isHeader() bool {
return self.Type == DiffLineFileHeader || self.Type == DiffLineHunkHeader
}
// PullRequestAnchor returns the side ("L"/"R") and line number to anchor a
// GitHub PR deep-link at: the left/old side for a deletion, the right/new side
// otherwise.
func (self DiffLineInfo) PullRequestAnchor() (side string, lineNumber int) {
if self.Type == DiffLineDeleted {
return "L", self.OldLine
}
return "R", self.NewLine
}
+81
View File
@@ -0,0 +1,81 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSamePatchLine(t *testing.T) {
scenarios := []struct {
name string
a, b DiffLineInfo
expected bool
}{
{
"content lines at the same new-file line match",
DiffLineInfo{Path: "foo", Type: DiffLineContext, NewLine: 10},
DiffLineInfo{Path: "foo", Type: DiffLineAdded, NewLine: 10},
true,
},
{
"different files don't match",
DiffLineInfo{Path: "foo", Type: DiffLineContext, NewLine: 10},
DiffLineInfo{Path: "bar", Type: DiffLineContext, NewLine: 10},
false,
},
{
"a deletion doesn't match a non-deletion at the same position",
DiffLineInfo{Path: "foo", Type: DiffLineDeleted, NewLine: 10, OldLine: 10},
DiffLineInfo{Path: "foo", Type: DiffLineContext, NewLine: 10},
false,
},
// A hunk header carries the new-file line of the hunk's first line, so
// it shares its number with that content line; the header/content guard
// is what keeps a restore aiming at one from landing on the other.
{
"a hunk header doesn't match the hunk's first content line",
DiffLineInfo{Path: "foo", Type: DiffLineHunkHeader, NewLine: 10},
DiffLineInfo{Path: "foo", Type: DiffLineContext, NewLine: 10},
false,
},
{
"a content line doesn't match a hunk header at its line",
DiffLineInfo{Path: "foo", Type: DiffLineContext, NewLine: 10},
DiffLineInfo{Path: "foo", Type: DiffLineHunkHeader, NewLine: 10},
false,
},
{
"hunk headers of the same hunk match",
DiffLineInfo{Path: "foo", Type: DiffLineHunkHeader, NewLine: 10},
DiffLineInfo{Path: "foo", Type: DiffLineHunkHeader, NewLine: 10},
true,
},
{
"hunk headers of different hunks don't match",
DiffLineInfo{Path: "foo", Type: DiffLineHunkHeader, NewLine: 10},
DiffLineInfo{Path: "foo", Type: DiffLineHunkHeader, NewLine: 25},
false,
},
{
"file headers of the same file match",
DiffLineInfo{Path: "foo", Type: DiffLineFileHeader},
DiffLineInfo{Path: "foo", Type: DiffLineFileHeader},
true,
},
// A whole-file deletion's hunk header carries new-line 0, the same
// number a file header reports; only the type tells them apart.
{
"a file header doesn't match a deleted file's hunk header",
DiffLineInfo{Path: "foo", Type: DiffLineFileHeader},
DiffLineInfo{Path: "foo", Type: DiffLineHunkHeader, NewLine: 0},
false,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, s.a.SamePatchLine(s.b))
})
}
}
+23
View File
@@ -98,3 +98,26 @@ func NewRunPtyTask(cmd *exec.Cmd) *RunPtyTask {
func NewRunPtyTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunPtyTask {
return &RunPtyTask{Cmd: cmd, Prefix: prefix}
}
// NewMainViewDiffTask builds the task for rendering a diff into the main view,
// choosing between the normal pty task and the focused main view's raw-diff
// fallback. When renderRaw is set (the focused main view needs to act on a diff the
// configured pager can't resolve) it uses a plain command task, which — unlike the
// pty task — doesn't pipe the diff through a stdin pager (GIT_PAGER); the external
// diff command, if any, is suppressed in the cmd itself (its ignoreExternalDiff arg).
// The caller passes the same renderRaw to the diff-cmd builder so the two stay in step.
func NewMainViewDiffTask(renderRaw bool, cmd *exec.Cmd) UpdateTask {
if renderRaw {
return NewRunCommandTask(cmd)
}
return NewRunPtyTask(cmd)
}
// NewMainViewDiffTaskWithPrefix is NewMainViewDiffTask for a diff rendered with a
// leading prefix (e.g. a range-diff or stash header).
func NewMainViewDiffTaskWithPrefix(renderRaw bool, cmd *exec.Cmd, prefix string) UpdateTask {
if renderRaw {
return NewRunCommandTaskWithPrefix(cmd, prefix)
}
return NewRunPtyTaskWithPrefix(cmd, prefix)
}
+19
View File
@@ -106,6 +106,14 @@ func (gui *Gui) createAllViews() error {
view.AutoRenderHyperLinks = true
}
gui.applyCurrentPagerSelectionStyle()
// The on-demand inclusion gutter marker for custom-patch building.
for _, view := range []*gocui.View{gui.Views.Main, gui.Views.Secondary} {
view.InclusionGutterMarker = "✓"
view.InclusionGutterMarkerColor = gocui.ColorGreen
}
gui.Views.Staging.Wrap = true
gui.Views.StagingSecondary.Wrap = true
gui.Views.PatchBuilding.Wrap = true
@@ -155,6 +163,17 @@ func (gui *Gui) createAllViews() error {
return nil
}
func (gui *Gui) applyCurrentPagerSelectionStyle() {
bgColorWidth := 0
if gui.stateAccessor != nil && gui.stateAccessor.GetDiffRendererConfigManager() != nil &&
gui.stateAccessor.GetDiffRendererConfigManager().GetNarrowSelectionHighlight() {
bgColorWidth = 2
}
gui.Views.Main.SelectedLineBgColorWidth = bgColorWidth
gui.Views.Secondary.SelectedLineBgColorWidth = bgColorWidth
}
func (gui *Gui) configureViewProperties() {
frameRunes := []rune{'─', '│', '┌', '┐', '└', '┘'}
switch gui.c.UserConfig().Gui.Border {
+10 -2
View File
@@ -396,6 +396,8 @@ type TranslationSet struct {
AskQuestion string
PrevHunk string
NextHunk string
PrevFile string
NextFile string
PrevConflict string
NextConflict string
SelectPrevHunk string
@@ -455,6 +457,7 @@ type TranslationSet struct {
CheckoutCommitFileTooltip string
CannotCheckoutWithModifiedFilesErr string
CanOnlyDiscardFromLocalCommits string
CannotDiscardFromCustomPatchView string
CannotDiscardFromMultipleCommits string
Remove string
DiscardOldFileChangeTooltip string
@@ -548,8 +551,10 @@ type TranslationSet struct {
EmptyPatchError string
EnterCommitFile string
EnterCommitFileTooltip string
EnterStaging string
ExitCustomPatchBuilder string
ExitFocusedMainView string
ToggleSelectionInFocusedMainView string
EnterUpstream string
InvalidUpstream string
NewRemote string
@@ -834,7 +839,6 @@ type TranslationSet struct {
SortOrderPrompt string
SortCommits string
SortCommitsTooltip string
CantChangeContextSizeError string
CantChangeRenameThresholdError string
OpenCommitInBrowser string
ViewBisectOptions string
@@ -1559,6 +1563,8 @@ func EnglishTranslationSet() *TranslationSet {
AskQuestion: "Ask Question",
PrevHunk: "Go to previous hunk",
NextHunk: "Go to next hunk",
PrevFile: "Go to previous file",
NextFile: "Go to next file",
PrevConflict: "Previous conflict",
NextConflict: "Next conflict",
SelectPrevHunk: "Previous hunk",
@@ -1618,6 +1624,7 @@ func EnglishTranslationSet() *TranslationSet {
CheckoutCommitFileTooltip: "Checkout file. This replaces the file in your working tree with the version from the selected commit.",
CannotCheckoutWithModifiedFilesErr: "You have local modifications for the file(s) you are trying to check out. You need to stash or discard these first.",
CanOnlyDiscardFromLocalCommits: "Changes can only be discarded from local commits",
CannotDiscardFromCustomPatchView: "Cannot discard from the custom patch view; press space to remove lines from the patch instead",
CannotDiscardFromMultipleCommits: "Changes cannot be discarded from a multiselection of commits",
Remove: "Remove",
DiscardOldFileChangeTooltip: "Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file.",
@@ -1712,8 +1719,10 @@ func EnglishTranslationSet() *TranslationSet {
EmptyPatchError: "Patch is still empty. Add some files or lines to your patch first.",
EnterCommitFile: "Enter file / Toggle directory collapsed",
EnterCommitFileTooltip: "If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory.",
EnterStaging: "Enter staging/patch building",
ExitCustomPatchBuilder: `Exit custom patch builder`,
ExitFocusedMainView: "Exit back to side panel",
ToggleSelectionInFocusedMainView: "Show/hide selection",
EnterUpstream: `Enter upstream as '<remote> <branchname>'`,
InvalidUpstream: "Invalid upstream. Must be in the format '<remote> <branchname>'",
NewRemote: `New remote`,
@@ -1993,7 +2002,6 @@ func EnglishTranslationSet() *TranslationSet {
SortBasedOnReflog: "(based on reflog)",
SortCommits: "Commit sort order",
SortCommitsTooltip: "Change the sort order of the commits in the commit log.\n\nThe default can be changed in the config file with the key 'git.log.sortOrder'.",
CantChangeContextSizeError: "Cannot change context while in patch building mode because we were too lazy to support it when releasing the feature. If you really want it, please let us know!",
CantChangeRenameThresholdError: "Cannot change the rename similarity threshold while in patch building mode, because the custom patch can't cope with a rename turning into a delete and add underneath it.",
OpenCommitInBrowser: "Open commit in browser",
ViewBisectOptions: "View bisect options",
+22
View File
@@ -367,6 +367,28 @@ func (self *ViewDriver) OriginYAtLeast(expected int) *ViewDriver {
return self
}
// SelectionIsShown asserts that the view is showing an active selection highlight.
func (self *ViewDriver) SelectionIsShown() *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
view := self.getView()
ok := view.Highlight && !view.HighlightInactive
return ok, fmt.Sprintf("%s: expected an active selection to be shown, but it wasn't", self.context)
})
return self
}
// SelectionIsHidden asserts that the view is showing no selection highlight (e.g. the
// focused main view over "No changed files", where there's nothing to select).
func (self *ViewDriver) SelectionIsHidden() *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
ok := !self.getView().Highlight
return ok, fmt.Sprintf("%s: expected no selection to be shown, but one was", self.context)
})
return self
}
// focus the view (assumes the view is a side-view)
func (self *ViewDriver) Focus() *ViewDriver {
viewName := self.getView().Name()
@@ -0,0 +1,98 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var BuildFromMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Build a custom patch by toggling a hunk into it from the focused main view of a commit's files, then apply it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.NewBranch("branch-a")
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
shell.Commit("first commit")
// Two separate change blocks, far enough apart to stay distinct hunks.
shell.NewBranch("branch-b")
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n")
shell.Commit("update")
shell.Checkout("branch-a")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Lines(
Contains("branch-a").IsSelected(),
Contains("branch-b"),
).
Press(keys.Universal.NextItem).
PressEnter()
t.Views().SubCommits().
IsFocused().
Lines(
Contains("update").IsSelected(),
Contains("first commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("file1").IsSelected(),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-three"),
).
// `a` extends the selection to the whole change block, then space toggles
// just that block into the custom patch — the other block stays out.
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-three"),
Contains("+THREE"),
).
PressPrimaryAction().
// After toggling the block in, the selection advances to the next stageable
// hunk (the NINE block) — like staging advances to the next hunk — across the
// re-render (which, when the secondary view first appears, also re-wraps the
// narrower diff).
SelectedLines(
Contains("-nine"),
Contains("+NINE"),
)
t.Views().Information().Content(Contains("Building patch"))
// The secondary view updates live with the cumulative patch — just the toggled
// block, not the other.
t.Views().Secondary().
ContainsLines(
Contains("-three"),
Contains("+THREE"),
).
Content(DoesNotContain("NINE"))
t.Common().SelectPatchOption(MatchesRegexp(`Apply patch$`))
// Only the toggled block reached the working tree: THREE is applied, NINE isn't.
t.Views().Files().
Focus().
Lines(
Contains("file1").IsSelected(),
)
t.Views().Main().
Content(Contains("THREE")).
Content(DoesNotContain("NINE"))
},
})
@@ -0,0 +1,62 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var BuildFromMainViewWithUnsupportedPager = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Build a custom patch from a commit's focused main view under a pager that restructures the diff without emitting metadata; it falls back to the raw diff so the selection is still toggleable",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
// `cat -n` numbers every line, which the buffer parser can't resolve, and cat
// emits no metadata, so the focused main view must fall back to the raw diff.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Command: "cat -n"},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("first commit")
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\n")
shell.Commit("update")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("update").IsSelected(),
Contains("first commit"),
)
// While browsing, the main view shows the pager's output (numbered lines).
t.Views().Main().Content(Contains(" +THREE"))
// Focusing the main view falls back to the raw diff, so the change line is
// resolved and the selection is toggleable into a custom patch.
t.Views().Commits().Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-three"),
).
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-three"),
Contains("+THREE"),
).
PressPrimaryAction()
t.Views().Information().Content(Contains("Building patch"))
t.Views().Secondary().
ContainsLines(
Contains("-three"),
Contains("+THREE"),
)
},
})
@@ -0,0 +1,96 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var BuildFromWholeCommitMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Build a custom patch from the whole-commit diff in a commits panel's focused main view (without entering the commit files), then apply it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.NewBranch("branch-a")
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.CreateFileAndAdd("file2", "alpha\nbeta\ngamma\ndelta\n")
shell.Commit("first commit")
// One commit touching two files, so the whole-commit diff is multi-file.
shell.NewBranch("branch-b")
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\n")
shell.UpdateFileAndAdd("file2", "alpha\nBETA\ngamma\ndelta\n")
shell.Commit("update")
shell.Checkout("branch-a")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Lines(
Contains("branch-a").IsSelected(),
Contains("branch-b"),
).
Press(keys.Universal.NextItem).
PressEnter()
// Focus the whole-commit diff straight from the sub-commits panel, rather than
// entering the commit files panel first.
t.Views().SubCommits().
IsFocused().
Lines(
Contains("update").IsSelected(),
Contains("first commit"),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
// The selection anchors on the first change line of the multi-file diff,
// which belongs to file1.
SelectedLines(
Contains("-three"),
).
// `a` extends to the whole change block, then space toggles just file1's block
// into the custom patch; file2's change, also in this diff, stays out.
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-three"),
Contains("+THREE"),
).
PressPrimaryAction().
// After toggling the block in, the selection advances to the next stageable
// block — file2's BETA block — across the re-render (which, when the secondary
// view first appears, re-wraps the narrower diff).
SelectedLines(
Contains("-beta"),
Contains("+BETA"),
)
t.Views().Information().Content(Contains("Building patch"))
// The secondary view shows the cumulative patch live — only file1's toggled
// block, not file2's change from the same commit.
t.Views().Secondary().
ContainsLines(
Contains("-three"),
Contains("+THREE"),
).
Content(DoesNotContain("BETA"))
t.Common().SelectPatchOption(MatchesRegexp(`Apply patch$`))
// Only file1's toggled block reached the working tree; file2 is untouched.
t.Views().Files().
Focus().
Lines(
Contains("file1").IsSelected(),
)
t.Views().Main().
Content(Contains("THREE")).
Content(DoesNotContain("BETA"))
},
})
@@ -0,0 +1,86 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var BuildMultiFileFromWholeCommitMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Build a custom patch spanning two files from a commit's whole-commit diff in the focused main view, by toggling each file's hunk, then apply it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
},
SetupRepo: func(shell *Shell) {
shell.NewBranch("branch-a")
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.CreateFileAndAdd("file2", "alpha\nbeta\ngamma\ndelta\n")
shell.Commit("first commit")
shell.NewBranch("branch-b")
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\n")
shell.UpdateFileAndAdd("file2", "alpha\nBETA\ngamma\ndelta\n")
shell.Commit("update")
shell.Checkout("branch-a")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Press(keys.Universal.NextItem).
PressEnter()
t.Views().SubCommits().
IsFocused().
Lines(
Contains("update").IsSelected(),
Contains("first commit"),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
// Hunk mode is the default here, so the first change block of the multi-file
// diff (file1's) is selected on focus.
SelectedLines(
Contains("-three"),
Contains("+THREE"),
).
PressPrimaryAction().
// Toggling the block in advances the selection to the next stageable block,
// which crosses the file boundary into file2's BETA block; toggle it in too.
SelectedLines(
Contains("-beta"),
Contains("+BETA"),
).
PressPrimaryAction().
SelectedLines(
Contains("-beta"),
Contains("+BETA"),
)
// The cumulative patch now spans both files.
t.Views().Secondary().
ContainsLines(
Contains("-three"),
Contains("+THREE"),
).
ContainsLines(
Contains("-beta"),
Contains("+BETA"),
)
t.Common().SelectPatchOption(MatchesRegexp(`Apply patch$`))
// Both files' changes reached the working tree.
t.Views().Files().
Focus().
ContainsLines(
Contains("file1"),
).
ContainsLines(
Contains("file2"),
)
},
})
@@ -0,0 +1,62 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DiscardLinesFromCommitMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Discard a line from a local commit straight from the focused main view, without entering the commit files",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("first commit")
shell.CreateFileAndAdd("file1", "1st line\n2nd line\n3rd line\n")
shell.Commit("commit to remove from")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("commit to remove from").IsSelected(),
Contains("first commit"),
).
// Focus the commit's diff straight from the commits panel, rather than
// entering the commit files panel first.
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("+1st line"),
).
// Move down to the second line and discard just that one from the commit.
Press(keys.Universal.NextItem).
SelectedLines(
Contains("+2nd line"),
).
Press(keys.Universal.Remove)
t.ExpectPopup().Confirmation().
Title(Equals("Discard lines from commit")).
Content(Equals("Are you sure you want to discard the selected lines from this commit?")).
Confirm()
// After the rebase the commit keeps only the 1st and 3rd lines, and the selection
// advances to the next surviving change (the 3rd line, now at the discarded line's
// ordinal) rather than staying stale — like staging or discarding in the files panel.
t.Views().Main().
ContainsLines(
Equals("+1st line"),
Equals("+3rd line"),
).
Content(DoesNotContain("2nd line")).
SelectedLines(
Contains("+3rd line"),
)
},
})
@@ -0,0 +1,71 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepSelectionAfterMovingPatchOutMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Moving a custom patch out of a commit from the focused main view re-establishes the (stale, multi-line) selection on a surviving change rather than leaving it painted over the shrunk diff",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("first commit")
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("commit to move a patch out of")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("commit to move a patch out of").IsSelected(),
Contains("first commit"),
).
// Focus the commit's diff straight from the commits panel, rather than
// entering the commit files panel first.
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("+one"),
).
// Toggle just the first line into a custom patch, then leave a multi-line
// range selected — the patch move below doesn't go through the focused-main-
// view action handlers, so without the preserve net this stale range would be
// left painted over the shrunk diff. (Toggling advances the selection to the
// next line, so go back to 'one' to anchor a range that spans the toggled line.)
PressPrimaryAction().
NavigateToLine(Contains("+one")).
Press(keys.Universal.ToggleRangeSelect).
NavigateToLine(Contains("+four")).
SelectedLines(
Contains("+one"),
Contains("+two"),
Contains("+three"),
Contains("+four"),
)
t.Common().SelectPatchOption(Contains("Move patch out into index"))
// The moved line ('one') is gone from the commit, and the stale multi-line range
// collapses to a single surviving change at the selection's top ordinal (now the
// 'two' line) — just like discarding from the commit does.
t.Views().Main().
IsFocused().
Content(DoesNotContain("+one")).
ContainsLines(
Equals("+two"),
Equals("+three"),
Equals("+four"),
Equals("+five"),
).
SelectedLines(
Contains("+two"),
)
},
})
@@ -0,0 +1,110 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RemoveLinesFromMainViewSecondary = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Remove a line from the custom patch by pressing space on it in the secondary (custom-patch) pane of the focused main view",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
// Line mode, so we can include only some of a hunk's additions and exercise the
// renumbering the aggregated patch applies to the included ones.
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.NewBranch("branch-a")
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("first commit")
// Three consecutive additions in a single hunk.
shell.NewBranch("branch-b")
shell.UpdateFileAndAdd("file1", "one\nADDED1\nADDED2\nADDED3\ntwo\nthree\n")
shell.Commit("update")
shell.Checkout("branch-a")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Press(keys.Universal.NextItem).
PressEnter()
t.Views().SubCommits().
IsFocused().
Lines(
Contains("update").IsSelected(),
Contains("first commit"),
).
Press(keys.Universal.FocusMainView)
// Include ADDED2 and ADDED3 in the patch but not ADDED1. Excluding ADDED1 shifts
// the new-file line numbers of ADDED2/ADDED3 in the aggregated patch, which is what
// used to make removing them by line number from the secondary act on the wrong line.
t.Views().Main().
IsFocused().
SelectedLines(
Contains("+ADDED1"),
).
Press(keys.Universal.NextItem).
SelectedLines(
Contains("+ADDED2"),
).
Press(keys.Universal.RangeSelectDown).
SelectedLines(
Contains("+ADDED2"),
Contains("+ADDED3"),
).
PressPrimaryAction()
// The cumulative patch holds ADDED2 and ADDED3 only.
t.Views().Secondary().
Content(Contains("+ADDED2")).
Content(Contains("+ADDED3")).
Content(DoesNotContain("ADDED1"))
// Tab into the secondary (custom-patch) pane; the selection lands on its first
// change line, ADDED2.
t.Views().Main().
Press(keys.Universal.TogglePanel)
t.Views().Secondary().
IsFocused().
SelectedLines(
Contains("+ADDED2"),
).
// Discarding from the commit makes no sense in the custom-patch preview, so it's
// disabled here (you remove from the patch with space instead).
Press(keys.Universal.Remove)
t.ExpectToast(Contains("Cannot discard from the custom patch view"))
t.Views().Secondary().
// Space removes the selected line from the patch — and removes ADDED2, not some
// other line resolved from its shifted line number.
PressPrimaryAction().
// The selection lands on the next surviving change, ADDED3.
SelectedLines(
Contains("+ADDED3"),
).
Content(Contains("+ADDED3")).
Content(DoesNotContain("ADDED2")).
Content(DoesNotContain("ADDED1"))
// Applying confirms only ADDED3 was ever in the patch.
t.Common().SelectPatchOption(MatchesRegexp(`Apply patch$`))
t.Views().Files().
Focus().
Lines(
Contains("file1").IsSelected(),
)
t.Views().Main().
Content(Contains("ADDED3")).
Content(DoesNotContain("ADDED1")).
Content(DoesNotContain("ADDED2"))
},
})
@@ -0,0 +1,55 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ResetPatchBuiltFromMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Reset a custom patch that was built straight from the commits panel's main view, without ever having entered the commit files panel",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.NewBranch("branch-a")
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("first commit")
shell.NewBranch("branch-b")
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\n")
shell.Commit("update")
shell.Checkout("branch-a")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Press(keys.Universal.NextItem).
PressEnter()
// Build a patch straight from the whole-commit diff, never entering the commit
// files panel — so its context is never set up with a ref for this commit.
t.Views().SubCommits().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Main.ToggleSelectHunk).
PressPrimaryAction()
t.Views().Information().Content(Contains("Building patch"))
// Resetting the patch from the menu refreshes the (never-initialised) commit files
// context; this used to crash on its nil ref.
t.Views().Main().Press(keys.Universal.CreatePatchOptionsMenu)
t.ExpectPopup().Menu().
Title(Equals("Patch options")).
Select(Contains("Reset patch")).
Confirm()
t.Views().Information().Content(DoesNotContain("Building patch"))
},
})

Some files were not shown because too many files have changed in this diff Show More